refactor(session): 升级存储系统架构
- 添加读写锁机制防止并发写入冲突 - 实现数据迁移框架支持版本化升级 - 分层存储结构:项目/会话/消息独立存储 - 使用 Git root commit hash 作为稳定项目 ID - 增量消息同步避免重复写入 - 每条消息独立文件,按序号命名 (0001.json, 0002.json)
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
export type {
|
||||
SessionData,
|
||||
SessionMetadata,
|
||||
SessionSummary,
|
||||
SessionManagerConfig,
|
||||
Todo,
|
||||
TodoStatus,
|
||||
StoredMessage,
|
||||
CurrentSessionPointer,
|
||||
ProjectMetadata,
|
||||
} from './types.js';
|
||||
|
||||
export { SessionStorage, sessionStorage } from './storage.js';
|
||||
export { SessionManager, sessionManager } from './manager.js';
|
||||
export { getProjectId, isGitRepository } from './project.js';
|
||||
export { runMigrations, getMigrationStatus } from './migration.js';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import type { SessionData, Todo, SessionSummary } from './types.js';
|
||||
import type { SessionData, Todo, SessionSummary, ProjectMetadata } from './types.js';
|
||||
import { SessionStorage, sessionStorage } from './storage.js';
|
||||
|
||||
/**
|
||||
@@ -9,7 +9,9 @@ import { SessionStorage, sessionStorage } from './storage.js';
|
||||
export class SessionManager {
|
||||
private storage: SessionStorage;
|
||||
private currentSession: SessionData | null = null;
|
||||
private currentProject: ProjectMetadata | null = null;
|
||||
private autoSaveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private lastSyncedCount: number = 0;
|
||||
|
||||
constructor(storage?: SessionStorage) {
|
||||
this.storage = storage || sessionStorage;
|
||||
@@ -19,21 +21,30 @@ export class SessionManager {
|
||||
* 初始化 - 尝试恢复或创建新会话
|
||||
*/
|
||||
async init(workdir: string): Promise<SessionData> {
|
||||
// 尝试加载当前会话
|
||||
const existing = await this.storage.loadCurrentSession();
|
||||
// 获取或创建项目
|
||||
this.currentProject = await this.storage.getOrCreateProject(workdir);
|
||||
|
||||
if (existing && existing.workdir === workdir) {
|
||||
// 同一工作目录,恢复会话
|
||||
this.currentSession = existing;
|
||||
} else {
|
||||
// 不同目录或无会话,归档旧会话并创建新的
|
||||
if (existing) {
|
||||
await this.storage.archiveCurrentSession();
|
||||
// 尝试加载当前会话
|
||||
const currentSessionId = await this.storage.getCurrentSessionId();
|
||||
|
||||
if (currentSessionId) {
|
||||
// 尝试加载会话
|
||||
const existing = await this.storage.loadSession(this.currentProject.id, currentSessionId);
|
||||
|
||||
if (existing && existing.workdir === workdir) {
|
||||
// 同一工作目录,恢复会话
|
||||
this.currentSession = existing;
|
||||
this.lastSyncedCount = existing.messages.length;
|
||||
this.startAutoSave();
|
||||
return this.currentSession;
|
||||
}
|
||||
this.currentSession = this.createNewSession(workdir);
|
||||
await this.save();
|
||||
}
|
||||
|
||||
// 创建新会话
|
||||
this.currentSession = this.createNewSession(workdir);
|
||||
this.lastSyncedCount = 0;
|
||||
await this.save();
|
||||
|
||||
// 启动自动保存
|
||||
this.startAutoSave();
|
||||
|
||||
@@ -44,8 +55,13 @@ export class SessionManager {
|
||||
* 创建新会话
|
||||
*/
|
||||
private createNewSession(workdir: string): SessionData {
|
||||
if (!this.currentProject) {
|
||||
throw new Error('Project not initialized. Call init() first.');
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.storage.generateSessionId(),
|
||||
projectId: this.currentProject.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
workdir,
|
||||
@@ -63,12 +79,26 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前会话
|
||||
* 获取当前项目
|
||||
*/
|
||||
getProject(): ProjectMetadata | null {
|
||||
return this.currentProject;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前会话(增量保存消息)
|
||||
*/
|
||||
async save(): Promise<void> {
|
||||
if (this.currentSession) {
|
||||
await this.storage.saveCurrentSession(this.currentSession);
|
||||
}
|
||||
if (!this.currentSession) return;
|
||||
|
||||
// 增量保存消息
|
||||
await this.storage.saveSession(this.currentSession, this.lastSyncedCount);
|
||||
|
||||
// 更新当前会话指针
|
||||
await this.storage.setCurrentSession(this.currentSession.id);
|
||||
|
||||
// 更新同步计数
|
||||
this.lastSyncedCount = this.currentSession.messages.length;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,14 +162,20 @@ export class SessionManager {
|
||||
* 清空当前会话并创建新会话
|
||||
*/
|
||||
async newSession(workdir?: string): Promise<SessionData> {
|
||||
// 归档当前会话
|
||||
if (this.currentSession && this.currentSession.messages.length > 0) {
|
||||
await this.storage.archiveCurrentSession();
|
||||
if (!this.currentProject) {
|
||||
throw new Error('Project not initialized. Call init() first.');
|
||||
}
|
||||
|
||||
// 创建新会话
|
||||
const newWorkdir = workdir || this.currentSession?.workdir || process.cwd();
|
||||
|
||||
// 如果工作目录变化,需要切换项目
|
||||
if (workdir && workdir !== this.currentProject.workdir) {
|
||||
this.currentProject = await this.storage.getOrCreateProject(workdir);
|
||||
}
|
||||
|
||||
this.currentSession = this.createNewSession(newWorkdir);
|
||||
this.lastSyncedCount = 0;
|
||||
await this.save();
|
||||
|
||||
return this.currentSession;
|
||||
@@ -152,9 +188,14 @@ export class SessionManager {
|
||||
* @param title 会话标题
|
||||
*/
|
||||
createChildSession(parentId: string, agentName: string, title?: string): SessionData {
|
||||
if (!this.currentProject) {
|
||||
throw new Error('Project not initialized. Call init() first.');
|
||||
}
|
||||
|
||||
const workdir = this.currentSession?.workdir || process.cwd();
|
||||
const childSession: SessionData = {
|
||||
id: this.storage.generateSessionId(),
|
||||
projectId: this.currentProject.id,
|
||||
parentId,
|
||||
agentName,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -172,7 +213,7 @@ export class SessionManager {
|
||||
* 保存子会话
|
||||
*/
|
||||
async saveChildSession(session: SessionData): Promise<void> {
|
||||
await this.storage.saveSession(session);
|
||||
await this.storage.saveSession(session, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,32 +227,45 @@ export class SessionManager {
|
||||
* 恢复指定会话
|
||||
*/
|
||||
async restoreSession(sessionId: string): Promise<SessionData | null> {
|
||||
const session = await this.storage.loadSession(sessionId);
|
||||
if (!session) return null;
|
||||
|
||||
// 归档当前会话
|
||||
if (this.currentSession && this.currentSession.messages.length > 0) {
|
||||
await this.storage.archiveCurrentSession();
|
||||
if (!this.currentProject) {
|
||||
throw new Error('Project not initialized. Call init() first.');
|
||||
}
|
||||
|
||||
const session = await this.storage.loadSession(this.currentProject.id, sessionId);
|
||||
if (!session) return null;
|
||||
|
||||
this.currentSession = session;
|
||||
await this.save();
|
||||
this.lastSyncedCount = session.messages.length;
|
||||
await this.storage.setCurrentSession(sessionId);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出历史会话
|
||||
* 列出当前项目的历史会话
|
||||
*/
|
||||
async listSessions(): Promise<SessionSummary[]> {
|
||||
return this.storage.listSessions();
|
||||
if (!this.currentProject) {
|
||||
return this.storage.listAllSessions();
|
||||
}
|
||||
return this.storage.listSessionsByProject(this.currentProject.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有项目的会话
|
||||
*/
|
||||
async listAllSessions(): Promise<SessionSummary[]> {
|
||||
return this.storage.listAllSessions();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除历史会话
|
||||
*/
|
||||
async deleteSession(sessionId: string): Promise<boolean> {
|
||||
return this.storage.deleteSession(sessionId);
|
||||
if (!this.currentProject) {
|
||||
return false;
|
||||
}
|
||||
return this.storage.deleteSession(this.currentProject.id, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 数据迁移框架
|
||||
* 支持版本化的数据结构升级
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { Lock } from '../utils/lock.js';
|
||||
|
||||
type Migration = (storageDir: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* 迁移函数列表
|
||||
* 新迁移添加到数组末尾,索引即为版本号
|
||||
*/
|
||||
const MIGRATIONS: Migration[] = [
|
||||
// 迁移 0: 初始化新目录结构
|
||||
// 将旧的 sessions/ 目录下的会话文件迁移到新的分层结构
|
||||
async (storageDir: string) => {
|
||||
const oldSessionsDir = path.join(storageDir, 'sessions');
|
||||
const projectDir = path.join(storageDir, 'project');
|
||||
const sessionDir = path.join(storageDir, 'session');
|
||||
const messageDir = path.join(storageDir, 'message');
|
||||
|
||||
// 确保新目录存在
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
await fs.mkdir(messageDir, { recursive: true });
|
||||
|
||||
// 检查是否有旧数据需要迁移
|
||||
try {
|
||||
const files = await fs.readdir(oldSessionsDir);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
|
||||
const filePath = path.join(oldSessionsDir, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const oldSession = JSON.parse(content);
|
||||
|
||||
// 提取消息
|
||||
const messages = oldSession.messages || [];
|
||||
const sessionId = oldSession.id;
|
||||
|
||||
// 创建消息目录
|
||||
const sessionMessageDir = path.join(messageDir, sessionId);
|
||||
await fs.mkdir(sessionMessageDir, { recursive: true });
|
||||
|
||||
// 写入每条消息
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const messageFile = path.join(sessionMessageDir, `${String(i + 1).padStart(4, '0')}.json`);
|
||||
await fs.writeFile(
|
||||
messageFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
index: i + 1,
|
||||
...messages[i],
|
||||
createdAt: oldSession.createdAt,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
// 创建新的会话元数据 (使用默认 projectId,后续会在 storage 中更新)
|
||||
const metadata = {
|
||||
id: sessionId,
|
||||
projectId: 'default',
|
||||
createdAt: oldSession.createdAt,
|
||||
updatedAt: oldSession.updatedAt,
|
||||
workdir: oldSession.workdir,
|
||||
title: oldSession.title,
|
||||
messageCount: messages.length,
|
||||
discoveredTools: oldSession.discoveredTools || [],
|
||||
todos: oldSession.todos || [],
|
||||
parentId: oldSession.parentId,
|
||||
agentName: oldSession.agentName,
|
||||
};
|
||||
|
||||
// 写入会话元数据到默认项目目录
|
||||
const defaultProjectDir = path.join(sessionDir, 'default');
|
||||
await fs.mkdir(defaultProjectDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(defaultProjectDir, `${sessionId}.json`),
|
||||
JSON.stringify(metadata, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
// 迁移 current-session.json
|
||||
const currentSessionFile = path.join(storageDir, 'current-session.json');
|
||||
try {
|
||||
const currentContent = await fs.readFile(currentSessionFile, 'utf-8');
|
||||
const currentSession = JSON.parse(currentContent);
|
||||
|
||||
if (currentSession.messages) {
|
||||
const sessionId = currentSession.id;
|
||||
const messages = currentSession.messages;
|
||||
|
||||
// 创建消息目录
|
||||
const sessionMessageDir = path.join(messageDir, sessionId);
|
||||
await fs.mkdir(sessionMessageDir, { recursive: true });
|
||||
|
||||
// 写入每条消息
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const messageFile = path.join(sessionMessageDir, `${String(i + 1).padStart(4, '0')}.json`);
|
||||
await fs.writeFile(
|
||||
messageFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
index: i + 1,
|
||||
...messages[i],
|
||||
createdAt: currentSession.createdAt,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
// 更新 current-session.json 为只包含 sessionId
|
||||
await fs.writeFile(currentSessionFile, JSON.stringify({ sessionId }, null, 2), 'utf-8');
|
||||
}
|
||||
} catch {
|
||||
// 没有 current-session.json,忽略
|
||||
}
|
||||
} catch {
|
||||
// sessions 目录不存在,跳过迁移
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取当前迁移版本
|
||||
*/
|
||||
async function getMigrationVersion(storageDir: string): Promise<number> {
|
||||
const versionFile = path.join(storageDir, 'migration');
|
||||
try {
|
||||
const content = await fs.readFile(versionFile, 'utf-8');
|
||||
return parseInt(content.trim(), 10) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存迁移版本
|
||||
*/
|
||||
async function setMigrationVersion(storageDir: string, version: number): Promise<void> {
|
||||
const versionFile = path.join(storageDir, 'migration');
|
||||
await fs.writeFile(versionFile, version.toString(), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行迁移
|
||||
* 在存储初始化时调用
|
||||
*/
|
||||
export async function runMigrations(storageDir: string): Promise<void> {
|
||||
// 使用写锁防止并发迁移
|
||||
using _ = await Lock.write(`migration:${storageDir}`);
|
||||
|
||||
const currentVersion = await getMigrationVersion(storageDir);
|
||||
|
||||
for (let i = currentVersion; i < MIGRATIONS.length; i++) {
|
||||
const migration = MIGRATIONS[i];
|
||||
|
||||
try {
|
||||
console.log(`[migration] Running migration ${i}...`);
|
||||
await migration(storageDir);
|
||||
await setMigrationVersion(storageDir, i + 1);
|
||||
console.log(`[migration] Migration ${i} completed`);
|
||||
} catch (error) {
|
||||
console.error(`[migration] Migration ${i} failed:`, error);
|
||||
// 迁移失败不阻塞启动,但记录错误
|
||||
// 下次启动会重试失败的迁移
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取迁移状态
|
||||
*/
|
||||
export async function getMigrationStatus(storageDir: string): Promise<{
|
||||
currentVersion: number;
|
||||
latestVersion: number;
|
||||
pendingMigrations: number;
|
||||
}> {
|
||||
const currentVersion = await getMigrationVersion(storageDir);
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion: MIGRATIONS.length,
|
||||
pendingMigrations: Math.max(0, MIGRATIONS.length - currentVersion),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 项目 ID 生成模块
|
||||
* 为每个项目生成稳定的唯一标识符
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* 获取项目 ID
|
||||
* 优先使用 Git 初始 commit hash,降级为路径 hash
|
||||
*/
|
||||
export async function getProjectId(workdir: string): Promise<string> {
|
||||
// 1. 尝试获取 Git 初始 commit hash
|
||||
const gitId = await getGitRootCommit(workdir);
|
||||
if (gitId) {
|
||||
return gitId;
|
||||
}
|
||||
|
||||
// 2. 降级为路径 hash (取前 16 位)
|
||||
return crypto.createHash('sha256').update(workdir).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Git 仓库的初始 commit hash
|
||||
* 这个值在整个仓库生命周期内保持稳定
|
||||
*/
|
||||
async function getGitRootCommit(workdir: string): Promise<string | null> {
|
||||
try {
|
||||
// 获取所有根 commit(没有父 commit 的 commit)
|
||||
// 对于大多数仓库只有一个,但 merge 仓库可能有多个
|
||||
const { stdout } = await execAsync('git rev-list --max-parents=0 --all', {
|
||||
cwd: workdir,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const commits = stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.sort(); // 排序确保多根 commit 时结果稳定
|
||||
|
||||
if (commits.length > 0) {
|
||||
// 取第一个(字典序最小的)commit hash 的前 16 位
|
||||
return commits[0].slice(0, 16);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
// 不是 Git 仓库或其他错误
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查目录是否是 Git 仓库
|
||||
*/
|
||||
export async function isGitRepository(workdir: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('git rev-parse --git-dir', {
|
||||
cwd: workdir,
|
||||
timeout: 3000,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import type { SessionData, SessionSummary } from './types.js';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import type {
|
||||
SessionData,
|
||||
SessionMetadata,
|
||||
SessionSummary,
|
||||
StoredMessage,
|
||||
CurrentSessionPointer,
|
||||
ProjectMetadata,
|
||||
} from './types.js';
|
||||
import { Lock } from '../utils/lock.js';
|
||||
import { getProjectId, isGitRepository } from './project.js';
|
||||
import { runMigrations } from './migration.js';
|
||||
|
||||
/**
|
||||
* 获取默认存储目录
|
||||
@@ -17,24 +28,38 @@ function getDefaultStorageDir(): string {
|
||||
|
||||
/**
|
||||
* 会话存储类
|
||||
* 负责会话数据的读写操作
|
||||
* 负责会话数据的读写操作,采用分层存储结构
|
||||
*/
|
||||
export class SessionStorage {
|
||||
private storageDir: string;
|
||||
private sessionsDir: string;
|
||||
private projectDir: string;
|
||||
private sessionDir: string;
|
||||
private messageDir: string;
|
||||
private currentSessionFile: string;
|
||||
private initialized = false;
|
||||
|
||||
constructor(storageDir?: string) {
|
||||
this.storageDir = storageDir || getDefaultStorageDir();
|
||||
this.sessionsDir = path.join(this.storageDir, 'sessions');
|
||||
this.projectDir = path.join(this.storageDir, 'project');
|
||||
this.sessionDir = path.join(this.storageDir, 'session');
|
||||
this.messageDir = path.join(this.storageDir, 'message');
|
||||
this.currentSessionFile = path.join(this.storageDir, 'current-session.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保存储目录存在
|
||||
* 确保存储目录存在并执行迁移
|
||||
*/
|
||||
async ensureDir(): Promise<void> {
|
||||
await fs.mkdir(this.sessionsDir, { recursive: true });
|
||||
if (this.initialized) return;
|
||||
|
||||
await fs.mkdir(this.projectDir, { recursive: true });
|
||||
await fs.mkdir(this.sessionDir, { recursive: true });
|
||||
await fs.mkdir(this.messageDir, { recursive: true });
|
||||
|
||||
// 执行数据迁移
|
||||
await runMigrations(this.storageDir);
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,47 +72,255 @@ export class SessionStorage {
|
||||
return `${timestamp}_${random}`;
|
||||
}
|
||||
|
||||
// ========== 项目管理 ==========
|
||||
|
||||
/**
|
||||
* 保存当前会话
|
||||
* 获取或创建项目
|
||||
*/
|
||||
async saveCurrentSession(session: SessionData): Promise<void> {
|
||||
async getOrCreateProject(workdir: string): Promise<ProjectMetadata> {
|
||||
await this.ensureDir();
|
||||
session.updatedAt = new Date().toISOString();
|
||||
await fs.writeFile(
|
||||
this.currentSessionFile,
|
||||
JSON.stringify(session, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const projectId = await getProjectId(workdir);
|
||||
const projectFile = path.join(this.projectDir, `${projectId}.json`);
|
||||
|
||||
using _ = await Lock.read(projectFile);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(projectFile, 'utf-8');
|
||||
return JSON.parse(content) as ProjectMetadata;
|
||||
} catch {
|
||||
// 项目不存在,创建新项目
|
||||
const isGitRepo = await isGitRepository(workdir);
|
||||
const project: ProjectMetadata = {
|
||||
id: projectId,
|
||||
workdir,
|
||||
createdAt: new Date().toISOString(),
|
||||
isGitRepo,
|
||||
};
|
||||
|
||||
using __ = await Lock.write(projectFile);
|
||||
await fs.writeFile(projectFile, JSON.stringify(project, null, 2), 'utf-8');
|
||||
|
||||
// 确保项目的会话目录存在
|
||||
await fs.mkdir(path.join(this.sessionDir, projectId), { recursive: true });
|
||||
|
||||
return project;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 会话元数据管理 ==========
|
||||
|
||||
/**
|
||||
* 保存会话元数据
|
||||
*/
|
||||
async saveSessionMetadata(session: SessionData): Promise<void> {
|
||||
await this.ensureDir();
|
||||
|
||||
const metadata: SessionMetadata = {
|
||||
id: session.id,
|
||||
projectId: session.projectId,
|
||||
parentId: session.parentId,
|
||||
agentName: session.agentName,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
workdir: session.workdir,
|
||||
title: session.title,
|
||||
messageCount: session.messages.length,
|
||||
discoveredTools: session.discoveredTools,
|
||||
todos: session.todos,
|
||||
};
|
||||
|
||||
const sessionFile = path.join(this.sessionDir, session.projectId, `${session.id}.json`);
|
||||
|
||||
// 确保项目目录存在
|
||||
await fs.mkdir(path.join(this.sessionDir, session.projectId), { recursive: true });
|
||||
|
||||
using _ = await Lock.write(sessionFile);
|
||||
await fs.writeFile(sessionFile, JSON.stringify(metadata, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前会话
|
||||
* 加载会话元数据
|
||||
*/
|
||||
async loadCurrentSession(): Promise<SessionData | null> {
|
||||
async loadSessionMetadata(projectId: string, sessionId: string): Promise<SessionMetadata | null> {
|
||||
await this.ensureDir();
|
||||
|
||||
const sessionFile = path.join(this.sessionDir, projectId, `${sessionId}.json`);
|
||||
|
||||
using _ = await Lock.read(sessionFile);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(sessionFile, 'utf-8');
|
||||
return JSON.parse(content) as SessionMetadata;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 消息管理 ==========
|
||||
|
||||
/**
|
||||
* 追加消息
|
||||
*/
|
||||
async appendMessage(sessionId: string, message: ModelMessage, index: number): Promise<void> {
|
||||
await this.ensureDir();
|
||||
|
||||
const sessionMessageDir = path.join(this.messageDir, sessionId);
|
||||
await fs.mkdir(sessionMessageDir, { recursive: true });
|
||||
|
||||
const messageFile = path.join(sessionMessageDir, `${String(index).padStart(4, '0')}.json`);
|
||||
|
||||
const storedMessage: StoredMessage = {
|
||||
index,
|
||||
role: message.role as StoredMessage['role'],
|
||||
content: message.content,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
using _ = await Lock.write(messageFile);
|
||||
await fs.writeFile(messageFile, JSON.stringify(storedMessage, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量追加消息(用于增量同步)
|
||||
*/
|
||||
async syncMessages(sessionId: string, messages: ModelMessage[], startIndex: number): Promise<void> {
|
||||
for (let i = startIndex; i < messages.length; i++) {
|
||||
await this.appendMessage(sessionId, messages[i], i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载会话的所有消息
|
||||
*/
|
||||
async loadMessages(sessionId: string): Promise<ModelMessage[]> {
|
||||
await this.ensureDir();
|
||||
|
||||
const sessionMessageDir = path.join(this.messageDir, sessionId);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(sessionMessageDir);
|
||||
const messageFiles = files.filter((f) => f.endsWith('.json')).sort();
|
||||
|
||||
const messages: ModelMessage[] = [];
|
||||
|
||||
for (const file of messageFiles) {
|
||||
const filePath = path.join(sessionMessageDir, file);
|
||||
using _ = await Lock.read(filePath);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const stored = JSON.parse(content) as StoredMessage;
|
||||
messages.push({
|
||||
role: stored.role,
|
||||
content: stored.content,
|
||||
} as ModelMessage);
|
||||
}
|
||||
|
||||
return messages;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话的所有消息
|
||||
*/
|
||||
async deleteMessages(sessionId: string): Promise<void> {
|
||||
const sessionMessageDir = path.join(this.messageDir, sessionId);
|
||||
|
||||
try {
|
||||
await fs.rm(sessionMessageDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 完整会话操作 ==========
|
||||
|
||||
/**
|
||||
* 保存完整会话(元数据 + 消息)
|
||||
*/
|
||||
async saveSession(session: SessionData, lastSyncedCount: number = 0): Promise<void> {
|
||||
// 增量同步消息
|
||||
await this.syncMessages(session.id, session.messages, lastSyncedCount);
|
||||
// 保存元数据
|
||||
await this.saveSessionMetadata(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载完整会话
|
||||
*/
|
||||
async loadSession(projectId: string, sessionId: string): Promise<SessionData | null> {
|
||||
const metadata = await this.loadSessionMetadata(projectId, sessionId);
|
||||
if (!metadata) return null;
|
||||
|
||||
const messages = await this.loadMessages(sessionId);
|
||||
|
||||
return {
|
||||
id: metadata.id,
|
||||
projectId: metadata.projectId,
|
||||
parentId: metadata.parentId,
|
||||
agentName: metadata.agentName,
|
||||
createdAt: metadata.createdAt,
|
||||
updatedAt: metadata.updatedAt,
|
||||
workdir: metadata.workdir,
|
||||
title: metadata.title,
|
||||
discoveredTools: metadata.discoveredTools,
|
||||
todos: metadata.todos,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*/
|
||||
async deleteSession(projectId: string, sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
// 删除消息
|
||||
await this.deleteMessages(sessionId);
|
||||
|
||||
// 删除元数据
|
||||
const sessionFile = path.join(this.sessionDir, projectId, `${sessionId}.json`);
|
||||
await fs.unlink(sessionFile);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 当前会话管理 ==========
|
||||
|
||||
/**
|
||||
* 设置当前会话
|
||||
*/
|
||||
async setCurrentSession(sessionId: string): Promise<void> {
|
||||
await this.ensureDir();
|
||||
|
||||
const pointer: CurrentSessionPointer = { sessionId };
|
||||
|
||||
using _ = await Lock.write(this.currentSessionFile);
|
||||
await fs.writeFile(this.currentSessionFile, JSON.stringify(pointer, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前会话 ID
|
||||
*/
|
||||
async getCurrentSessionId(): Promise<string | null> {
|
||||
await this.ensureDir();
|
||||
|
||||
using _ = await Lock.read(this.currentSessionFile);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(this.currentSessionFile, 'utf-8');
|
||||
return JSON.parse(content) as SessionData;
|
||||
const pointer = JSON.parse(content) as CurrentSessionPointer;
|
||||
return pointer.sessionId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 归档当前会话到历史
|
||||
*/
|
||||
async archiveCurrentSession(): Promise<void> {
|
||||
const current = await this.loadCurrentSession();
|
||||
if (!current || current.messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ensureDir();
|
||||
const archivePath = path.join(this.sessionsDir, `${current.id}.json`);
|
||||
await fs.writeFile(archivePath, JSON.stringify(current, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前会话文件
|
||||
* 清除当前会话指针
|
||||
*/
|
||||
async clearCurrentSession(): Promise<void> {
|
||||
try {
|
||||
@@ -97,74 +330,69 @@ export class SessionStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 会话列表 ==========
|
||||
|
||||
/**
|
||||
* 列出历史会话
|
||||
* 列出项目的所有会话
|
||||
*/
|
||||
async listSessions(): Promise<SessionSummary[]> {
|
||||
async listSessionsByProject(projectId: string): Promise<SessionSummary[]> {
|
||||
await this.ensureDir();
|
||||
const files = await fs.readdir(this.sessionsDir);
|
||||
const summaries: SessionSummary[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
const projectSessionDir = path.join(this.sessionDir, projectId);
|
||||
|
||||
try {
|
||||
const filePath = path.join(this.sessionsDir, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const session = JSON.parse(content) as SessionData;
|
||||
try {
|
||||
const files = await fs.readdir(projectSessionDir);
|
||||
const summaries: SessionSummary[] = [];
|
||||
|
||||
summaries.push({
|
||||
id: session.id,
|
||||
title: session.title || this.generateTitle(session),
|
||||
workdir: session.workdir,
|
||||
messageCount: session.messages.length,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
});
|
||||
} catch {
|
||||
// 跳过无法解析的文件
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
|
||||
try {
|
||||
const filePath = path.join(projectSessionDir, file);
|
||||
using _ = await Lock.read(filePath);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const metadata = JSON.parse(content) as SessionMetadata;
|
||||
|
||||
summaries.push({
|
||||
id: metadata.id,
|
||||
title: metadata.title || this.generateTitleFromId(metadata.id),
|
||||
workdir: metadata.workdir,
|
||||
messageCount: metadata.messageCount,
|
||||
createdAt: metadata.createdAt,
|
||||
updatedAt: metadata.updatedAt,
|
||||
});
|
||||
} catch {
|
||||
// 跳过无法解析的文件
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按更新时间降序排列
|
||||
return summaries.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载指定会话
|
||||
*/
|
||||
async loadSession(sessionId: string): Promise<SessionData | null> {
|
||||
try {
|
||||
const filePath = path.join(this.sessionsDir, `${sessionId}.json`);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
return JSON.parse(content) as SessionData;
|
||||
// 按更新时间降序排列
|
||||
return summaries.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
} catch {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存指定会话(用于子会话)
|
||||
* 列出所有会话(跨项目)
|
||||
*/
|
||||
async saveSession(session: SessionData): Promise<void> {
|
||||
async listAllSessions(): Promise<SessionSummary[]> {
|
||||
await this.ensureDir();
|
||||
session.updatedAt = new Date().toISOString();
|
||||
const filePath = path.join(this.sessionsDir, `${session.id}.json`);
|
||||
await fs.writeFile(filePath, JSON.stringify(session, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定会话
|
||||
*/
|
||||
async deleteSession(sessionId: string): Promise<boolean> {
|
||||
const allSummaries: SessionSummary[] = [];
|
||||
|
||||
try {
|
||||
const filePath = path.join(this.sessionsDir, `${sessionId}.json`);
|
||||
await fs.unlink(filePath);
|
||||
return true;
|
||||
const projectDirs = await fs.readdir(this.sessionDir);
|
||||
|
||||
for (const projectId of projectDirs) {
|
||||
const summaries = await this.listSessionsByProject(projectId);
|
||||
allSummaries.push(...summaries);
|
||||
}
|
||||
|
||||
// 按更新时间降序排列
|
||||
return allSummaries.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
} catch {
|
||||
return false;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +400,7 @@ export class SessionStorage {
|
||||
* 清理旧会话(保留最近 N 个)
|
||||
*/
|
||||
async cleanupOldSessions(keepCount: number = 50): Promise<number> {
|
||||
const sessions = await this.listSessions();
|
||||
const sessions = await this.listAllSessions();
|
||||
if (sessions.length <= keepCount) {
|
||||
return 0;
|
||||
}
|
||||
@@ -181,8 +409,24 @@ export class SessionStorage {
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const session of toDelete) {
|
||||
if (await this.deleteSession(session.id)) {
|
||||
deletedCount++;
|
||||
// 需要找到对应的 projectId
|
||||
// 这里通过遍历项目目录来查找
|
||||
try {
|
||||
const projectDirs = await fs.readdir(this.sessionDir);
|
||||
for (const projectId of projectDirs) {
|
||||
const sessionFile = path.join(this.sessionDir, projectId, `${session.id}.json`);
|
||||
try {
|
||||
await fs.access(sessionFile);
|
||||
if (await this.deleteSession(projectId, session.id)) {
|
||||
deletedCount++;
|
||||
}
|
||||
break;
|
||||
} catch {
|
||||
// 不在这个项目目录,继续查找
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,17 +434,10 @@ export class SessionStorage {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从会话生成标题
|
||||
* 从会话 ID 生成标题
|
||||
*/
|
||||
private generateTitle(session: SessionData): string {
|
||||
// 从第一条用户消息生成标题
|
||||
const firstUserMessage = session.messages.find((m) => m.role === 'user');
|
||||
if (firstUserMessage && typeof firstUserMessage.content === 'string') {
|
||||
const content = firstUserMessage.content;
|
||||
// 取前 50 个字符
|
||||
return content.length > 50 ? content.substring(0, 50) + '...' : content;
|
||||
}
|
||||
return `会话 ${session.id}`;
|
||||
private generateTitleFromId(sessionId: string): string {
|
||||
return `会话 ${sessionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,11 +17,13 @@ export interface Todo {
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话数据(持久化存储格式)
|
||||
* 会话元数据(存储格式,不含消息内容)
|
||||
*/
|
||||
export interface SessionData {
|
||||
export interface SessionMetadata {
|
||||
/** 会话 ID */
|
||||
id: string;
|
||||
/** 项目 ID */
|
||||
projectId: string;
|
||||
/** 父会话 ID(子会话时存在) */
|
||||
parentId?: string;
|
||||
/** 关联的 Agent 名称(子会话时存在) */
|
||||
@@ -34,14 +36,36 @@ export interface SessionData {
|
||||
workdir: string;
|
||||
/** 会话标题(可选,从第一条消息生成) */
|
||||
title?: string;
|
||||
/** 对话历史 */
|
||||
messages: ModelMessage[];
|
||||
/** 消息数量 */
|
||||
messageCount: number;
|
||||
/** 已发现的工具 */
|
||||
discoveredTools: string[];
|
||||
/** 待办事项 */
|
||||
todos: Todo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储的消息格式
|
||||
*/
|
||||
export interface StoredMessage {
|
||||
/** 消息序号 (1-based) */
|
||||
index: number;
|
||||
/** 消息角色 */
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
/** 消息内容 */
|
||||
content: ModelMessage['content'];
|
||||
/** 创建时间 */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话数据(运行时格式,包含消息内容)
|
||||
*/
|
||||
export interface SessionData extends Omit<SessionMetadata, 'messageCount'> {
|
||||
/** 对话历史 */
|
||||
messages: ModelMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话摘要(用于列表展示)
|
||||
*/
|
||||
@@ -54,6 +78,27 @@ export interface SessionSummary {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前会话指针(存储在 current-session.json)
|
||||
*/
|
||||
export interface CurrentSessionPointer {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目元数据
|
||||
*/
|
||||
export interface ProjectMetadata {
|
||||
/** 项目 ID (Git root commit hash 或路径 hash) */
|
||||
id: string;
|
||||
/** 工作目录 */
|
||||
workdir: string;
|
||||
/** 创建时间 */
|
||||
createdAt: string;
|
||||
/** 是否为 Git 仓库 */
|
||||
isGitRepo: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话管理器配置
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 进程内读写锁模块
|
||||
* 参考 OpenCode 的实现,支持 using 语法
|
||||
*/
|
||||
|
||||
export namespace Lock {
|
||||
const locks = new Map<
|
||||
string,
|
||||
{
|
||||
readers: number;
|
||||
writer: boolean;
|
||||
waitingReaders: (() => void)[];
|
||||
waitingWriters: (() => void)[];
|
||||
}
|
||||
>();
|
||||
|
||||
function get(key: string) {
|
||||
if (!locks.has(key)) {
|
||||
locks.set(key, {
|
||||
readers: 0,
|
||||
writer: false,
|
||||
waitingReaders: [],
|
||||
waitingWriters: [],
|
||||
});
|
||||
}
|
||||
return locks.get(key)!;
|
||||
}
|
||||
|
||||
function process(key: string) {
|
||||
const lock = locks.get(key);
|
||||
if (!lock || lock.writer || lock.readers > 0) return;
|
||||
|
||||
// 优先处理写锁,防止写饥饿
|
||||
if (lock.waitingWriters.length > 0) {
|
||||
const nextWriter = lock.waitingWriters.shift()!;
|
||||
nextWriter();
|
||||
return;
|
||||
}
|
||||
|
||||
// 唤醒所有等待的读锁
|
||||
while (lock.waitingReaders.length > 0) {
|
||||
const nextReader = lock.waitingReaders.shift()!;
|
||||
nextReader();
|
||||
}
|
||||
|
||||
// 清理空闲锁
|
||||
if (
|
||||
lock.readers === 0 &&
|
||||
!lock.writer &&
|
||||
lock.waitingReaders.length === 0 &&
|
||||
lock.waitingWriters.length === 0
|
||||
) {
|
||||
locks.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取读锁
|
||||
* 多个读锁可以同时持有,但会被写锁阻塞
|
||||
*/
|
||||
export async function read(key: string): Promise<Disposable> {
|
||||
const lock = get(key);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!lock.writer && lock.waitingWriters.length === 0) {
|
||||
lock.readers++;
|
||||
resolve({
|
||||
[Symbol.dispose]: () => {
|
||||
lock.readers--;
|
||||
process(key);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
lock.waitingReaders.push(() => {
|
||||
lock.readers++;
|
||||
resolve({
|
||||
[Symbol.dispose]: () => {
|
||||
lock.readers--;
|
||||
process(key);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取写锁
|
||||
* 写锁是排他的,必须等待所有读锁和写锁释放
|
||||
*/
|
||||
export async function write(key: string): Promise<Disposable> {
|
||||
const lock = get(key);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (!lock.writer && lock.readers === 0) {
|
||||
lock.writer = true;
|
||||
resolve({
|
||||
[Symbol.dispose]: () => {
|
||||
lock.writer = false;
|
||||
process(key);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
lock.waitingWriters.push(() => {
|
||||
lock.writer = true;
|
||||
resolve({
|
||||
[Symbol.dispose]: () => {
|
||||
lock.writer = false;
|
||||
process(key);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 带读锁执行操作
|
||||
*/
|
||||
export async function withRead<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
using _ = await read(key);
|
||||
return fn();
|
||||
}
|
||||
|
||||
/**
|
||||
* 带写锁执行操作
|
||||
*/
|
||||
export async function withWrite<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
using _ = await write(key);
|
||||
return fn();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user