305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
/**
|
||
* 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,
|
||
};
|
||
});
|
||
}
|