Files
metona-ai-desktop/electron/ipc/workspace.ts
T
thzxx 9329df68af
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m37s
CI / 全量测试 (Electron ABI) (push) Failing after 6m1s
CI / 产物编译验证 (push) Successful in 10m54s
feat: v0.8.3 工作空间切换链路根治 · 思考强度扩档 — 启动TDZ连锁/继承丢数/回显滞后三修复 · xhigh+true档位单源 · 安全防线ready后注册 · 2217 用例全量回归 + 生产模式E2E
2026-09-08 21:14:59 +08:00

305 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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';
// v0.8.2 P2-5: 工作空间校验文案出层(ui.locale 驱动)
import { mt } from '../utils/main-locale';
export function registerWorkspaceHandlers(ctx: IPCContext): void {
const { workspaceService } = ctx;
// ===== v0.8.0 P2-4: @ 文件提及 =====
// 工作空间文件名索引(联想数据源;renderer 侧 Fuse 模糊过滤)
ipcMain.handle('workspace:listFiles', async (_event, limit: unknown) => {
const maxFiles =
typeof limit === 'number' && Number.isFinite(limit) && limit > 0
? Math.min(5000, Math.floor(limit))
: 2000;
try {
return { success: true, data: workspaceService.listFiles(maxFiles) };
} catch (error) {
return { success: false, error: (error as Error).message };
}
});
// 读取提及文件的文本片段(路径边界 + 512KB 上限 + 二进制拒绝 + MEMORY.md 保护)
ipcMain.handle('workspace:readFileClip', async (_event, relPath: unknown, maxBytes: unknown) => {
if (typeof relPath !== 'string' || !relPath.trim()) {
return { success: false, error: 'Invalid path' };
}
const cap =
typeof maxBytes === 'number' && Number.isFinite(maxBytes) && maxBytes > 0
? Math.min(1024 * 1024, Math.floor(maxBytes))
: 512 * 1024;
log.info(`[WORKSPACE] readFileClip: ${relPath.slice(0, 120)} (cap=${cap})`);
return workspaceService.readFileClip(relPath, cap);
});
// 校验工作空间路径:检测路径有效性 + 必需文件状态 + 数据库是否存在
ipcMain.handle('workspace:check', async (_event, targetPath: string) => {
if (!targetPath || typeof targetPath !== 'string') {
return { valid: false, reason: mt('workspace.reason.empty') };
}
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: mt('workspace.reason.notExists'),
};
}
// 校验 2: 是否为目录
try {
const stat = statSync(resolvedPath);
if (!stat.isDirectory()) {
return { valid: false, reason: mt('workspace.reason.notDirectory') };
}
} catch {
return { valid: false, reason: mt('workspace.reason.inaccessible') };
}
// 校验 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<string, string[]> = {
'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 {
// v0.8.3 修复: backup() 返回 Promise,必须 await —— 原实现未等待就
// close() 源库,备份被中途掐断:目标库只剩空壳页(继承数据静默丢失,
// 重启后全量重建表),且 rejection 无人接住(unhandledRejection FATAL)。
await 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,
};
});
}