From 672fba75a3e13ea9d5996e97e9e85e1bfe6b8a59 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 28 Sep 2026 13:25:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(ProjectForge):=20=E5=86=99=E7=9B=98?= =?UTF-8?q?=E5=89=8D=E5=A4=8D=E6=A0=B8=20hash=E3=80=81Rollback=20=E5=8E=9F?= =?UTF-8?q?=E5=AD=90=E5=8C=96=E3=80=81py=5Fcompile=20=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EditCode / CreateFile / Rollback / GUI 单文件回退:写盘前重新读取并比对 hash。 读取与写入之间夹着 await(代码审查、回退规划),期间文件若被外部修改 (编辑器、其他插件、指向同一目录的其他工程),原逻辑会静默覆盖;现改为放弃写入并提示重新 ReadCode。 - Rollback:先写完所有文件,全部成功后再在一个事务里记录批次与节点; 中途写入失败(如文件被占用)时按逆序恢复已写入文件,不留下"回退了一半"且无记录的状态。 - fileKit/validator:py_compile 由 execFileSync 改为异步 execFile。 原实现在常驻进程中最长阻塞事件循环 20s,且 reviewCode 前后各执行一次; 临时文件名追加随机后缀,避免 before/after 并发校验时同毫秒撞名。 - 新增 tests/project-forge-robustness.test.js(3 个用例:旧代码全部失败,新代码全部通过; 原有 tests/project-forge.test.js 全部通过)。 --- .../ProjectForge/ProjectForgeService.js | 76 +++++++++--- .../shared/fileKit/validator.js | 31 +++-- tests/project-forge-robustness.test.js | 117 ++++++++++++++++++ 3 files changed, 203 insertions(+), 21 deletions(-) create mode 100644 tests/project-forge-robustness.test.js diff --git a/VCPDistributedServer/Plugin/ProjectForge/ProjectForgeService.js b/VCPDistributedServer/Plugin/ProjectForge/ProjectForgeService.js index 1468224d2..2a167099a 100644 --- a/VCPDistributedServer/Plugin/ProjectForge/ProjectForgeService.js +++ b/VCPDistributedServer/Plugin/ProjectForge/ProjectForgeService.js @@ -219,6 +219,19 @@ async function readDisk(abs) { return { exists: true, buffer, hash: sha256(buffer) }; } +/** + * 写盘前复核:读取与写入之间可能夹着 await(代码审查、回退规划), + * 期间文件若被外部修改(编辑器、其他插件、另一个工程),直接写入会静默覆盖。 + * hash 不一致时放弃写入,交由调用方重新读取。 + */ +async function assertDiskUnchanged(abs, expectedHash, rel) { + const now = await readDisk(abs); + if (now.hash !== expectedHash) { + const short = h => (h ? String(h).slice(0, 8) : '不存在'); + throw new Error(`${P} ${rel} 在处理期间被外部修改(读取时 ${short(expectedHash)},写入前 ${short(now.hash)}),为避免覆盖已放弃写入。请重新 ReadCode 后再操作。`); + } +} + function decodeText(buffer, rel) { if (buffer.length > runtime.maxEditSize) { throw new Error(`${P} ${rel} 超过可编辑上限 ${T.formatFileSize(runtime.maxEditSize)}。`); @@ -667,6 +680,7 @@ async function commitEdit(ctx, file, plan) { const outBuf = encodeText(result.text, meta); s.putBlob(disk.buffer); const afterHash = s.putBlob(outBuf); + await assertDiskUnchanged(file.abs, disk.hash, file.rel); await fsp.writeFile(file.abs, outBuf); const stepReasons = plan.steps.filter(st => st.reason).map(st => `步骤${st.step}:${st.reason}`).join(';'); let batchId; @@ -751,6 +765,7 @@ async function createFile(args) { } if (disk.exists) s.putBlob(disk.buffer); const afterHash = s.putBlob(outBuf); + await assertDiskUnchanged(file.abs, disk.hash, file.rel); await fsp.mkdir(path.dirname(file.abs), { recursive: true }); await fsp.writeFile(file.abs, outBuf); let batchId; @@ -915,22 +930,54 @@ async function rollback(args) { const todo = items.filter(i => i.action !== '无需操作'); if (!todo.length) return textResult(`## ℹ️ 无需回退:${label}\n${table}`, { command: 'Rollback', status: 'noop' }); - const batchId = s.createBatch(ctx.project.id, 'rollback', A.str(args, 'reason') || label, maidOf(args)); - const done = []; - for (const item of todo) { - if (item.disk.exists) s.putBlob(item.disk.buffer); - if (item.target === null) { - await moveToTrash(item.abs); - } else { - await fsp.mkdir(path.dirname(item.abs), { recursive: true }); - await fsp.writeFile(item.abs, s.getBlob(item.target)); + // 先写完所有文件,全部成功后再在一个事务里记批次与节点; + // 中途失败(例如文件被编辑器占用)时按逆序把已写入的文件恢复为回退前内容,不留下"回退了一半"的状态。 + const applied = []; + try { + for (const item of todo) { + await assertDiskUnchanged(item.abs, item.disk.hash, item.rel); + if (item.disk.exists) s.putBlob(item.disk.buffer); + if (item.target === null) { + await moveToTrash(item.abs); + } else { + await fsp.mkdir(path.dirname(item.abs), { recursive: true }); + await fsp.writeFile(item.abs, s.getBlob(item.target)); + } + applied.push(item); } - const nodeId = s.addNode({ - projectId: ctx.project.id, batchId, filePath: item.rel, op: 'rollback', - beforeHash: item.disk.hash, afterHash: item.target, summary: label, - }); - done.push(`\`${item.rel}\`:${item.action}(节点 \`n${nodeId}\`)`); + } catch (error) { + const unrestored = []; + for (const item of applied.slice().reverse()) { + try { + if (item.disk.exists) { + await fsp.mkdir(path.dirname(item.abs), { recursive: true }); + await fsp.writeFile(item.abs, item.disk.buffer); + } else { + await moveToTrash(item.abs); + } + } catch (restoreError) { + unrestored.push(`\`${item.rel}\`(${restoreError.message})`); + } + } + const lines = [ + `${P} 回退中途失败,未记录回退批次:${error.message}`, + `- 已写入的 ${applied.length} 个文件中,${applied.length - unrestored.length} 个已恢复为回退前内容。`, + ]; + if (unrestored.length) lines.push(`- 以下文件未能自动恢复,请手动检查(回退前内容已存快照):${unrestored.join('、')}`); + throw new Error(lines.join('\n')); } + let batchId; + const done = []; + s.transaction(() => { + batchId = s.createBatch(ctx.project.id, 'rollback', A.str(args, 'reason') || label, maidOf(args)); + for (const item of applied) { + const nodeId = s.addNode({ + projectId: ctx.project.id, batchId, filePath: item.rel, op: 'rollback', + beforeHash: item.disk.hash, afterHash: item.target, summary: label, + }); + done.push(`\`${item.rel}\`:${item.action}(节点 \`n${nodeId}\`)`); + } + }); return textResult([ `## ✅ 已回退:${label}`, `- 回退批次 \`b${batchId}\`(回退本身也可再回退:Rollback batch=b${batchId})`, @@ -1189,6 +1236,7 @@ const gui = { let batchId; let newNodeId; + await assertDiskUnchanged(file.abs, disk.hash, node.file_path); if (disk.exists) s.putBlob(disk.buffer); if (target === null) { await moveToTrash(file.abs); diff --git a/VCPDistributedServer/shared/fileKit/validator.js b/VCPDistributedServer/shared/fileKit/validator.js index 2244bf4d2..20ae59665 100644 --- a/VCPDistributedServer/shared/fileKit/validator.js +++ b/VCPDistributedServer/shared/fileKit/validator.js @@ -6,7 +6,8 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); -const { execFileSync } = require('child_process'); +const crypto = require('crypto'); +const { execFile } = require('child_process'); let eslintInstances = null; let stylelintModule = null; @@ -94,12 +95,28 @@ function validateJson(content) { } } -function validatePython(content) { - const tempFile = path.join(os.tmpdir(), `vcp_val_${process.pid}_${Date.now()}.py`); - fs.writeFileSync(tempFile, content); +// 异步执行 py_compile:同步调用最长阻塞 20s,且 reviewCode 前后各跑一次, +// 在常驻进程(ProjectForge)中会卡住整个事件循环。临时文件名加随机后缀,避免并发校验同毫秒撞名。 +function execFileAsync(file, args, options) { + return new Promise((resolve, reject) => { + execFile(file, args, options, (error, stdout, stderr) => { + if (error) { + error.stdout = stdout; + error.stderr = stderr; + reject(error); + } else { + resolve({ stdout, stderr }); + } + }); + }); +} + +async function validatePython(content) { + const tempFile = path.join(os.tmpdir(), `vcp_val_${process.pid}_${Date.now()}_${crypto.randomBytes(4).toString('hex')}.py`); + await fs.promises.writeFile(tempFile, content); try { // 参数数组形式调用,不经过 shell,杜绝路径注入。 - execFileSync('python', ['-m', 'py_compile', tempFile], { stdio: 'pipe', windowsHide: true, timeout: 20000 }); + await execFileAsync('python', ['-m', 'py_compile', tempFile], { windowsHide: true, timeout: 20000 }); return []; } catch (error) { if (error.code === 'ENOENT') return []; // 未安装 python:跳过校验而非报错 @@ -115,7 +132,7 @@ function validatePython(content) { fatal: true, }]; } finally { - try { fs.unlinkSync(tempFile); } catch (_e) { /* 已删除 */ } + await fs.promises.unlink(tempFile).catch(() => { /* 已删除 */ }); } } @@ -143,7 +160,7 @@ async function validateCode(filePath, content) { case '.json': return validateJson(text); case '.py': - return validatePython(text); + return await validatePython(text); default: return []; } diff --git a/tests/project-forge-robustness.test.js b/tests/project-forge-robustness.test.js new file mode 100644 index 000000000..a1ad206b1 --- /dev/null +++ b/tests/project-forge-robustness.test.js @@ -0,0 +1,117 @@ +'use strict'; +// ProjectForge 健壮性回归测试: +// 1) 写盘前复核 hash:读取与写入之间文件被外部修改时放弃写入; +// 2) Rollback 中途失败时恢复已写入文件、且不记录回退批次; +// 3) Python 校验改为异步后不再阻塞事件循环。 + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// 必须在加载 ProjectForge 之前替换 execFile(validator 在加载时解构引用)。 +const cp = require('child_process'); +const originalExecFile = cp.execFile; +let beforePythonHook = null; +cp.execFile = function patchedExecFile(...args) { + if (beforePythonHook) { + const hook = beforePythonHook; + beforePythonHook = null; + hook(); + } + return originalExecFile.apply(this, args); +}; + +const forge = require('../VCPDistributedServer/Plugin/ProjectForge/ProjectForgeService'); +const { validateCode } = require('../VCPDistributedServer/shared/fileKit/validator'); + +let tmp; +let wsRoot; + +const call = args => forge.processToolCall(args); +const textOf = r => r.content.filter(p => p.type === 'text').map(p => p.text).join('\n'); +const norm = s => s.replace(/\r\n/g, '\n'); + +test.before(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pf-robust-')); + wsRoot = path.join(tmp, 'ws'); + fs.mkdirSync(wsRoot, { recursive: true }); + forge.initialize({ + dbPath: path.join(tmp, 'db', 'pf.db'), + services: { + workspaceService: { + list: () => [{ id: 'ws1', alias: 'demo', path: wsRoot, enabled: true, status: 'ready' }], + getActiveWorkspaceId: () => 'ws1', + }, + }, + trash: async abs => fs.rmSync(abs, { force: true }), + logger: { log() {}, warn() {}, error() {} }, + }); +}); + +test.after(async () => { + cp.execFile = originalExecFile; + await forge.cleanup(); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +test('EditCode:代码审查期间文件被外部修改时放弃写入,不覆盖外部内容', async () => { + const projectId = (await call({ command: 'CreateProject', name: 'lf1', dir: 'lf1' })).details.project.id; + const abs = path.join(wsRoot, 'lf1', 'm.py'); + const created = await call({ command: 'CreateFile', projectId, path: 'm.py', content: 'x = 1\n', reason: 'init' }); + assert.equal(created.details.status, 'ok', textOf(created)); + + beforePythonHook = () => fs.writeFileSync(abs, 'x = 99\n'); + await assert.rejects( + call({ command: 'EditCode', projectId, path: 'm.py', start: 1, end: 1, content: 'x = 2', reason: 'edit' }), + /被外部修改/, + ); + assert.equal(beforePythonHook, null, 'Python 校验钩子未被触发'); + assert.equal(fs.readFileSync(abs, 'utf8'), 'x = 99\n'); +}); + +test('Rollback:中途写入失败时恢复已写入文件,并且不记录回退节点', async () => { + const projectId = (await call({ command: 'CreateProject', name: 'lf2', dir: 'lf2' })).details.project.id; + const a = path.join(wsRoot, 'lf2', 'a.txt'); + const b = path.join(wsRoot, 'lf2', 'b.txt'); + await call({ command: 'CreateFile', projectId, path: 'a.txt', content: 'a1\n', reason: 'init a' }); + const cb = await call({ command: 'CreateFile', projectId, path: 'b.txt', content: 'b1\n', reason: 'init b' }); + const nb = cb.details.nodeId; + assert.ok(nb, textOf(cb)); + for (const [p, c] of [['a.txt', 'a2'], ['b.txt', 'b2']]) { + const r = await call({ command: 'EditCode', projectId, path: p, start: 1, end: 1, content: c, reason: `edit ${p}` }); + assert.equal(r.details.status, 'ok', textOf(r)); + } + + fs.chmodSync(b, 0o444); // 只读文件写入报 EPERM,模拟"被编辑器占用" + try { + await assert.rejects( + call({ command: 'Rollback', projectId, toNode: `n${nb}`, reason: 'test' }), + err => { + assert.match(err.message, /回退中途失败/); + assert.match(err.message, /已写入的 1 个文件中,1 个已恢复/); + return true; + }, + ); + assert.equal(norm(fs.readFileSync(a, 'utf8')), 'a2\n', 'a.txt 应恢复为回退前内容'); + assert.equal(norm(fs.readFileSync(b, 'utf8')), 'b2\n'); + const hist = await call({ command: 'SearchHistory', projectId, limit: 50 }); + assert.ok(!hist.details.nodes.some(n => n.op === 'rollback'), '失败的回退不应留下 rollback 节点'); + } finally { + fs.chmodSync(b, 0o666); + } + + const ok = await call({ command: 'Rollback', projectId, toNode: `n${nb}`, reason: 'retry' }); + assert.equal(ok.details.status, 'ok', textOf(ok)); + assert.equal(norm(fs.readFileSync(a, 'utf8')), 'a1\n'); + assert.equal(norm(fs.readFileSync(b, 'utf8')), 'b1\n'); +}); + +test('Python 校验异步执行,期间事件循环可以继续运行', async () => { + let ticks = 0; + const timer = setInterval(() => { ticks += 1; }, 1); + await validateCode('x.py', 'x = 1\n'); + clearInterval(timer); + assert.ok(ticks >= 1, `校验期间事件循环被阻塞(ticks=${ticks})`); +});