/** * IPC Workspace Handlers — 工作空间域(P2-9 从 handlers.ts 拆分) * * 工作空间校验、文件继承(SQLite backup API)、数据库完整性检查、信息查询。 */ import { ipcMain } from 'electron'; import { join } from 'path'; import type { IPCContext } from './context'; import log from 'electron-log'; export function registerWorkspaceHandlers(ctx: IPCContext): void { const { workspaceService } = ctx; // 校验工作空间路径:检测路径有效性 + 必需文件状态 + 数据库是否存在 ipcMain.handle('workspace:check', async (_event, targetPath: string) => { if (!targetPath || typeof targetPath !== 'string') { return { valid: false, reason: '路径不能为空' }; } const { existsSync, statSync } = await import('fs'); const { resolve } = await import('path'); const resolvedPath = resolve(targetPath); // 校验 1: 路径是否存在 if (!existsSync(resolvedPath)) { return { valid: true, path: resolvedPath, exists: false, missingFiles: ['SOUL.md', 'MEMORY.md'], isNewWorkspace: true, dbExists: false, reason: '目录不存在,将在切换后自动创建', }; } // 校验 2: 是否为目录 try { const stat = statSync(resolvedPath); if (!stat.isDirectory()) { return { valid: false, reason: '路径不是目录' }; } } catch { return { valid: false, reason: '无法访问路径' }; } // 校验 3: 检测 2 个必需文件状态(SOUL.md + MEMORY.md) const requiredFiles = ['SOUL.md', 'MEMORY.md']; const missingFiles: string[] = []; for (const f of requiredFiles) { if (!existsSync(join(resolvedPath, f))) missingFiles.push(f); } // 校验 4: 检测 .metona/agent.db 是否存在(用于判断是否显示"继承数据库"选项) const dbExists = existsSync(join(resolvedPath, '.metona', 'agent.db')); return { valid: true, path: resolvedPath, exists: true, missingFiles, isNewWorkspace: missingFiles.length === requiredFiles.length, dbExists, }; }); // 从旧工作空间继承文件到新工作空间 ipcMain.handle('workspace:inheritFiles', async (_event, params: { targetPath: string; sourcePath: string; files: string[]; }) => { const { targetPath, sourcePath, files } = params; if (!targetPath || !sourcePath || !Array.isArray(files)) { return { success: false, error: '参数无效' }; } const { existsSync, mkdirSync, copyFileSync } = await import('fs'); const { resolve } = await import('path'); const resolvedTarget = resolve(targetPath); const resolvedSource = resolve(sourcePath); // 确保目标目录存在 if (!existsSync(resolvedTarget)) { mkdirSync(resolvedTarget, { recursive: true }); } // 继承白名单:键为前端传入的标识,值为实际相对路径分段 // 白名单映射防止路径遍历攻击(不直接使用用户传入的路径拼接到 fs 调用) const INHERIT_WHITELIST: Record = { 'SOUL.md': ['SOUL.md'], '.metona/agent.db': ['.metona', 'agent.db'], }; const inherited: string[] = []; const failed: Array<{ file: string; error: string }> = []; for (const fileName of files) { const pathSegments = INHERIT_WHITELIST[fileName]; // 不在白名单中:拒绝(防止路径遍历) if (!pathSegments) { failed.push({ file: fileName, error: '不在继承白名单中' }); continue; } const srcFile = join(resolvedSource, ...pathSegments); const dstFile = join(resolvedTarget, ...pathSegments); try { // 确保目标文件的父目录存在(如 .metona/) const dstDir = join(resolvedTarget, ...pathSegments.slice(0, -1)); if (!existsSync(dstDir)) { mkdirSync(dstDir, { recursive: true }); } if (!existsSync(srcFile)) { failed.push({ file: fileName, error: '源文件不存在' }); continue; } // === 数据库文件特殊处理 === // agent.db 启用了 WAL 模式,直接 copyFileSync 会丢失 WAL 中未 checkpoint 的事务; // 用 better-sqlite3 的 backup API(自带 checkpoint + 一致性快照) if (fileName === '.metona/agent.db') { try { const Database = (await import('better-sqlite3')).default; const srcDb = new Database(srcFile, { readonly: true, fileMustExist: true }); try { srcDb.backup(dstFile); inherited.push(fileName); log.info(`[WORKSPACE] Inherited ${fileName} (via SQLite backup): ${resolvedSource} → ${resolvedTarget}`); } finally { srcDb.close(); } } catch (err) { // backup 失败时回退到 copyFileSync(至少保证基本可用) log.warn(`[WORKSPACE] SQLite backup failed, fallback to copyFileSync: ${(err as Error).message}`); try { copyFileSync(srcFile, dstFile); inherited.push(fileName); log.info(`[WORKSPACE] Inherited ${fileName} (fallback copyFileSync): ${resolvedSource} → ${resolvedTarget}`); } catch (err2) { failed.push({ file: fileName, error: `backup 和 fallback 均失败: ${(err2 as Error).message}` }); } } continue; } // === 普通文件直接复制 === copyFileSync(srcFile, dstFile); inherited.push(fileName); log.info(`[WORKSPACE] Inherited ${fileName}: ${resolvedSource} → ${resolvedTarget}`); } catch (err) { failed.push({ file: fileName, error: (err as Error).message }); } } return { success: true, inherited, failed }; }); // #51 修复: 校验源数据库完整性,防止继承损坏的数据库导致新工作空间数据丢失 ipcMain.handle('workspace:checkDatabaseIntegrity', async (_event, sourcePath: string) => { if (!sourcePath) return { success: false, error: '参数无效' }; const { existsSync } = await import('fs'); const { resolve } = await import('path'); const srcFile = join(resolve(sourcePath), '.metona', 'agent.db'); if (!existsSync(srcFile)) { // 源数据库不存在不算损坏(可能是新工作空间尚未创建数据库),视为校验通过 return { success: true, ok: true, detail: 'source database not exist' }; } try { const Database = (await import('better-sqlite3')).default; const db = new Database(srcFile, { readonly: true, fileMustExist: true }); try { const result = db.prepare('PRAGMA integrity_check').get() as { integrity_check: string }; const ok = result.integrity_check === 'ok'; if (!ok) { log.warn(`[WORKSPACE] Source database integrity check failed: ${result.integrity_check} (source=${sourcePath})`); } return { success: true, ok, detail: result.integrity_check }; } finally { db.close(); } } catch (err) { log.error(`[WORKSPACE] Failed to check database integrity: ${(err as Error).message}`); return { success: false, error: (err as Error).message }; } }); // 获取当前工作空间详情:路径 + 2 个核心文件状态 + 自动目录状态 ipcMain.handle('workspace:getInfo', async () => { const { existsSync, statSync, readFileSync, readdirSync } = await import('fs'); const workspacePath = workspaceService.getPath(); const files = workspaceService.reload(); // 同步外部可能的手动修改 const REQUIRED = ['SOUL.md', 'MEMORY.md'] as const; const AUTO_DIRS = ['logs', '.metona'] as const; const fileInfos = REQUIRED.map((name) => { const filePath = join(workspacePath, name); let exists = false; let size = 0; let mtime = 0; let preview = ''; try { if (existsSync(filePath)) { const stat = statSync(filePath); exists = true; size = stat.size; mtime = stat.mtimeMs; // 截取前 500 字符作为预览 const content = readFileSync(filePath, 'utf-8'); preview = content.length > 500 ? content.slice(0, 500) + '\n...(已截断)' : content; } } catch { // ignore } // 文件内容映射:SOUL.md → files.soul, MEMORY.md → files.memory const contentKey = name.toLowerCase().replace('.md', '') as 'soul' | 'memory'; return { name, path: filePath, exists, size, mtime, preview: exists ? preview : (files[contentKey] ?? ''), }; }); const dirInfos = AUTO_DIRS.map((name) => { const dirPath = join(workspacePath, name); let exists = false; let fileCount = 0; try { if (existsSync(dirPath)) { exists = true; const stat = statSync(dirPath); if (stat.isDirectory()) { fileCount = readdirSync(dirPath).length; } } } catch { // ignore } return { name, path: dirPath, exists, fileCount }; }); return { path: workspacePath, files: fileInfos, dirs: dirInfos, }; }); }