Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 62 additions & 14 deletions VCPDistributedServer/Plugin/ProjectForge/ProjectForgeService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)}。`);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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})`,
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 24 additions & 7 deletions VCPDistributedServer/shared/fileKit/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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:跳过校验而非报错
Expand All @@ -115,7 +132,7 @@ function validatePython(content) {
fatal: true,
}];
} finally {
try { fs.unlinkSync(tempFile); } catch (_e) { /* 已删除 */ }
await fs.promises.unlink(tempFile).catch(() => { /* 已删除 */ });
}
}

Expand Down Expand Up @@ -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 [];
}
Expand Down
117 changes: 117 additions & 0 deletions tests/project-forge-robustness.test.js
Original file line number Diff line number Diff line change
@@ -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})`);
});
Loading