mocode-ai 1.2.1 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/rollback/index.js +167 -24
- package/dist/tools/registry.js +4 -2
- package/package.json +1 -1
package/dist/rollback/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync,
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import * as fsp from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { config } from '../config/index.js';
|
|
5
6
|
import { truncateDisplay } from '../ui/render.js';
|
|
@@ -50,8 +51,19 @@ function readState(full) {
|
|
|
50
51
|
}
|
|
51
52
|
return { kind: 'missing' };
|
|
52
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* 状态等价判定。两侧都捕获了内容时按内容比(与旧行为一致,精确);
|
|
56
|
+
* 任一侧内容未捕获(超预算的大文件)时退化为 stamp 比较——size+mtimeNs 变了就算变。
|
|
57
|
+
*/
|
|
53
58
|
function sameState(a, b) {
|
|
54
|
-
|
|
59
|
+
if (a.kind !== b.kind || a.mode !== b.mode)
|
|
60
|
+
return false;
|
|
61
|
+
if (a.data !== undefined && b.data !== undefined)
|
|
62
|
+
return a.data === b.data;
|
|
63
|
+
if (a.data === undefined && b.data === undefined && a.stamp === undefined && b.stamp === undefined) {
|
|
64
|
+
return true; // directory / missing:kind+mode 已足够
|
|
65
|
+
}
|
|
66
|
+
return a.stamp === b.stamp;
|
|
55
67
|
}
|
|
56
68
|
function stateFingerprint(state) {
|
|
57
69
|
return createHash('sha256')
|
|
@@ -82,6 +94,8 @@ function snapshotFromState(rel, state, sequence, op, createdParents = [], after)
|
|
|
82
94
|
ops: [op],
|
|
83
95
|
createdParents: createdParents.length > 0 ? createdParents : undefined,
|
|
84
96
|
afterFingerprint: after ? stateFingerprint(after) : undefined,
|
|
97
|
+
// 文件但没有内容 = 工作区扫描时超出捕获预算,只能报告"变了",不能拿它覆盖磁盘。
|
|
98
|
+
contentUnavailable: state.kind === 'file' && state.data === undefined ? true : undefined,
|
|
85
99
|
};
|
|
86
100
|
}
|
|
87
101
|
/** 同轮同路径只保留最早的 before;后续实际改动仅合并工具名。 */
|
|
@@ -163,55 +177,172 @@ export function endPathMutation(capture, op) {
|
|
|
163
177
|
if (changed)
|
|
164
178
|
mutationVersion += 1;
|
|
165
179
|
}
|
|
166
|
-
// 构建产物 /
|
|
167
|
-
//
|
|
168
|
-
//
|
|
180
|
+
// 构建产物 / 依赖树 / 缓存 / 运行时状态目录:可再生,扫描它们既昂贵也易把后台 daemon、
|
|
181
|
+
// 打包器、mocode 自身(会话日志 / dev-server 日志 / 截图)的写入误判成模型改动。
|
|
182
|
+
// 回滚本就只应覆盖源码。
|
|
169
183
|
const EXCLUDED_WORKSPACE_DIRS = new Set([
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
'.
|
|
184
|
+
// VCS / 索引 / mocode 自身运行时状态(会话、trace、dev-server 日志、记忆、截图每轮都在写,
|
|
185
|
+
// 既无回滚意义,又会被误判成模型改动)
|
|
186
|
+
'.git', '.hg', '.svn', '.codegraph', '.mocode',
|
|
187
|
+
// 依赖树与包管理器缓存
|
|
188
|
+
'node_modules', 'vendor', 'bower_components', '.yarn', '.pnpm-store', '.venv', 'venv', 'pods',
|
|
189
|
+
// 构建产物
|
|
190
|
+
'dist', 'build', 'out', 'target', 'coverage', '.output', '.next', '.nuxt', '.vite',
|
|
191
|
+
'.turbo', '.svelte-kit', '.angular', '.astro', '.docusaurus', '.dart_tool', '.terraform',
|
|
192
|
+
// 临时与缓存
|
|
193
|
+
'.tmp', 'tmp', '.cache', '.parcel-cache', '.nyc_output', '__pycache__',
|
|
194
|
+
'.pytest_cache', '.mypy_cache', '.ruff_cache', '.gradle',
|
|
173
195
|
]);
|
|
196
|
+
/** 单文件内容捕获上限:更大的文件只留 stamp(可检测变化,不可恢复),避免把巨型二进制读进内存。 */
|
|
197
|
+
const CAPTURE_FILE_LIMIT = 1024 * 1024;
|
|
198
|
+
/** 单次扫描的内容总预算:超出后剩余文件只留 stamp。 */
|
|
199
|
+
const CAPTURE_TOTAL_LIMIT = 32 * 1024 * 1024;
|
|
200
|
+
/** 条目上限:超大工作区不做无边界遍历(超出部分不参与变更检测)。 */
|
|
201
|
+
const CAPTURE_ENTRY_LIMIT = 20000;
|
|
202
|
+
/** 并发文件操作数:冷缓存(尤其 Windows 杀软逐文件扫描)下 I/O 重叠远快于串行。 */
|
|
203
|
+
const SCAN_CONCURRENCY = 16;
|
|
204
|
+
/** 让出事件循环的节奏:每 N 个条目,或每累计编码 M 字节(base64 是纯 CPU,大文件靠字节数兜底)。 */
|
|
205
|
+
const YIELD_EVERY = 64;
|
|
206
|
+
const YIELD_BYTES = 1024 * 1024;
|
|
207
|
+
/**
|
|
208
|
+
* 刚被写过的文件不信缓存,强制重读内容。
|
|
209
|
+
* 原因:stamp 依赖 mtime 精度。NTFS/ext4/APFS 是 100ns~ns 级,但 exFAT / 部分网络盘只有 1~2s,
|
|
210
|
+
* 那里一条"同尺寸原地改写"可能与快照前共享同一时间戳,只比 stamp 会漏掉真实变化。
|
|
211
|
+
* 只对最近 2s 内改动的文件付重读代价(通常正是命令刚碰过的那几个),开销可忽略。
|
|
212
|
+
*/
|
|
213
|
+
const FRESH_WINDOW_MS = 2000;
|
|
214
|
+
/** path → 上次扫描捕获的内容。stamp(size+mtimeNs)未变即复用,未改动的文件不重复读盘。 */
|
|
215
|
+
let contentCache = new Map();
|
|
216
|
+
let cachedSessionDir = '';
|
|
217
|
+
let resolvedSessionDir = '';
|
|
218
|
+
function sessionDirAbs() {
|
|
219
|
+
if (config.sessionDir !== cachedSessionDir) {
|
|
220
|
+
cachedSessionDir = config.sessionDir;
|
|
221
|
+
resolvedSessionDir = path.resolve(config.sessionDir);
|
|
222
|
+
}
|
|
223
|
+
return resolvedSessionDir;
|
|
224
|
+
}
|
|
174
225
|
function isWorkspaceExcluded(full) {
|
|
175
226
|
const base = path.basename(full).toLowerCase();
|
|
176
227
|
// Git 元数据必须永久排除以保护 index;依赖树/代码索引/构建产物/临时目录是可再生运行时状态。
|
|
177
228
|
if (EXCLUDED_WORKSPACE_DIRS.has(base))
|
|
178
229
|
return true;
|
|
179
|
-
|
|
180
|
-
return isInside(sessionDir, full);
|
|
230
|
+
return isInside(sessionDirAbs(), full);
|
|
181
231
|
}
|
|
182
|
-
|
|
232
|
+
const yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
|
|
233
|
+
/**
|
|
234
|
+
* 工作区快照(run_command / dev_server / MCP 等不透明工具用)。
|
|
235
|
+
*
|
|
236
|
+
* **必须是异步且带缓存的**:本函数在每次这类工具调用前后各跑一次,直接坐在用户交互
|
|
237
|
+
* 路径上。旧实现用 readdirSync + 全量 readFileSync(base64) 同步遍历整棵工作树 ——
|
|
238
|
+
* 冷文件缓存下 400 个文件实测就要 ~3s(Windows),几千个文件即数十秒,期间事件循环
|
|
239
|
+
* 完全阻塞:spinner 冻结、走时停摆、键鼠无响应,用户看到的就是「卡在 执行 run_command」。
|
|
240
|
+
*
|
|
241
|
+
* 现在:
|
|
242
|
+
* 1) fs/promises + 有界并发 + 定期 setImmediate 让出 → 事件循环全程可呼吸;
|
|
243
|
+
* 2) 内容按 (size, mtimeNs, mode) 缓存复用 → 未改动的文件不再重复读盘,
|
|
244
|
+
* 一次会话里只有首次扫描付全量代价,之后只读真正变化的文件;
|
|
245
|
+
* 3) 单文件 / 总量 / 条目数三重预算 → 巨型仓库不会把内存和时间吃穿。
|
|
246
|
+
*/
|
|
247
|
+
async function scanWorkspace() {
|
|
183
248
|
const entries = new Map();
|
|
184
|
-
const
|
|
249
|
+
const nextCache = new Map();
|
|
250
|
+
const paths = [];
|
|
251
|
+
// 第一趟:只 readdir 收集条目(廉价,不读内容)。目录本身即刻记账,顺序稳定。
|
|
252
|
+
const walk = async (dir) => {
|
|
253
|
+
if (paths.length >= CAPTURE_ENTRY_LIMIT)
|
|
254
|
+
return;
|
|
185
255
|
let children;
|
|
186
256
|
try {
|
|
187
|
-
children =
|
|
257
|
+
children = await fsp.readdir(dir, { withFileTypes: true });
|
|
188
258
|
}
|
|
189
259
|
catch {
|
|
190
260
|
return;
|
|
191
261
|
}
|
|
192
262
|
for (const child of children) {
|
|
263
|
+
if (paths.length >= CAPTURE_ENTRY_LIMIT)
|
|
264
|
+
return;
|
|
193
265
|
const full = path.join(dir, child.name);
|
|
194
266
|
if (isWorkspaceExcluded(full))
|
|
195
267
|
continue;
|
|
196
|
-
|
|
268
|
+
paths.push(full);
|
|
269
|
+
// isDirectory() 对 symlink 为 false —— 与旧实现一致:不跟随软链。
|
|
270
|
+
if (child.isDirectory())
|
|
271
|
+
await walk(full);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
await walk(rootDir());
|
|
275
|
+
// 第二趟:有界并发读状态。budget 由完成顺序消费——两次扫描间某个大文件是否被捕获
|
|
276
|
+
// 可能不同,sameState 在任一侧缺内容时退化为 stamp 比较,故不会产生伪变化。
|
|
277
|
+
let budget = CAPTURE_TOTAL_LIMIT;
|
|
278
|
+
let cursor = 0;
|
|
279
|
+
let processed = 0;
|
|
280
|
+
let bytesSinceYield = 0;
|
|
281
|
+
const worker = async () => {
|
|
282
|
+
while (cursor < paths.length) {
|
|
283
|
+
const full = paths[cursor++];
|
|
284
|
+
if (++processed % YIELD_EVERY === 0 || bytesSinceYield >= YIELD_BYTES) {
|
|
285
|
+
bytesSinceYield = 0;
|
|
286
|
+
await yieldToEventLoop();
|
|
287
|
+
}
|
|
288
|
+
let state;
|
|
289
|
+
try {
|
|
290
|
+
const stat = await fsp.lstat(full, { bigint: true });
|
|
291
|
+
const mode = Number(stat.mode) & 0o777;
|
|
292
|
+
if (stat.isSymbolicLink()) {
|
|
293
|
+
const target = await fsp.readlink(full);
|
|
294
|
+
state = { kind: 'symlink', data: target, stamp: target, mode };
|
|
295
|
+
}
|
|
296
|
+
else if (stat.isDirectory()) {
|
|
297
|
+
state = { kind: 'directory', mode, stamp: '' };
|
|
298
|
+
}
|
|
299
|
+
else if (stat.isFile()) {
|
|
300
|
+
const size = Number(stat.size);
|
|
301
|
+
const stamp = `${size}:${stat.mtimeNs}`;
|
|
302
|
+
// 未来时间戳(时钟偏移)同样按"新鲜"处理 —— 宁可多读一次,不可漏判变化。
|
|
303
|
+
const fresh = Date.now() - Number(stat.mtimeNs / 1000000n) < FRESH_WINDOW_MS;
|
|
304
|
+
const cached = fresh ? undefined : contentCache.get(full);
|
|
305
|
+
if (cached && cached.stamp === stamp && cached.mode === mode) {
|
|
306
|
+
budget -= size; // 命中也计预算,保证冷/热缓存下的捕获集合一致
|
|
307
|
+
state = { kind: 'file', data: cached.data, stamp, mode };
|
|
308
|
+
nextCache.set(full, cached);
|
|
309
|
+
}
|
|
310
|
+
else if (size <= CAPTURE_FILE_LIMIT && budget - size >= 0) {
|
|
311
|
+
budget -= size;
|
|
312
|
+
bytesSinceYield += size;
|
|
313
|
+
const data = (await fsp.readFile(full)).toString('base64');
|
|
314
|
+
state = { kind: 'file', data, stamp, mode };
|
|
315
|
+
nextCache.set(full, { stamp, mode, data });
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
// 超大文件 / 预算耗尽:只留指纹,能报告变化但不参与内容恢复。
|
|
319
|
+
state = { kind: 'file', stamp, mode };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
state = { kind: 'missing' };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
state = { kind: 'missing' }; // 不存在或不可读:工具若最终也不可读,不会产生伪变化
|
|
328
|
+
}
|
|
197
329
|
if (state.kind === 'missing')
|
|
198
330
|
continue;
|
|
199
|
-
|
|
200
|
-
entries.set(rel, state);
|
|
201
|
-
if (state.kind === 'directory')
|
|
202
|
-
walk(full);
|
|
331
|
+
entries.set(toRel(full), state);
|
|
203
332
|
}
|
|
204
333
|
};
|
|
205
|
-
|
|
334
|
+
await Promise.all(Array.from({ length: Math.min(SCAN_CONCURRENCY, Math.max(1, paths.length)) }, worker));
|
|
335
|
+
// 缓存整体替换:已删除的文件自然被淘汰,内存上限 ≈ CAPTURE_TOTAL_LIMIT。
|
|
336
|
+
contentCache = nextCache;
|
|
206
337
|
return entries;
|
|
207
338
|
}
|
|
208
339
|
/** run_command/MCP 前调用。不跟随 symlink,并排除 .git、会话快照、依赖树与代码索引。 */
|
|
209
|
-
export function beginWorkspaceMutation() {
|
|
210
|
-
return { sequence: ++sequenceCounter, entries: scanWorkspace() };
|
|
340
|
+
export async function beginWorkspaceMutation() {
|
|
341
|
+
return { sequence: ++sequenceCounter, entries: await scanWorkspace() };
|
|
211
342
|
}
|
|
212
343
|
/** 不透明工具执行后比较整个工作区,把实际变化压入当前轮事务日志。 */
|
|
213
|
-
export function endWorkspaceMutation(capture, op) {
|
|
214
|
-
const after = scanWorkspace();
|
|
344
|
+
export async function endWorkspaceMutation(capture, op) {
|
|
345
|
+
const after = await scanWorkspace();
|
|
215
346
|
const paths = new Set([...capture.entries.keys(), ...after.keys()]);
|
|
216
347
|
let changed = false;
|
|
217
348
|
for (const rel of paths) {
|
|
@@ -234,10 +365,16 @@ export function getCurrentTurnMutationState() {
|
|
|
234
365
|
continue;
|
|
235
366
|
let change = byPath.get(snapshot.path);
|
|
236
367
|
if (!change) {
|
|
237
|
-
change = {
|
|
368
|
+
change = {
|
|
369
|
+
path: snapshot.path,
|
|
370
|
+
ops: [],
|
|
371
|
+
snapshotAvailable: snapshot.contentUnavailable !== true,
|
|
372
|
+
};
|
|
238
373
|
byPath.set(snapshot.path, change);
|
|
239
374
|
order.push(snapshot.path);
|
|
240
375
|
}
|
|
376
|
+
if (snapshot.contentUnavailable)
|
|
377
|
+
change.snapshotAvailable = false;
|
|
241
378
|
for (const op of snapshot.ops ?? ['file_change']) {
|
|
242
379
|
if (!change.ops.includes(op))
|
|
243
380
|
change.ops.push(op);
|
|
@@ -281,6 +418,8 @@ export function planRollback(n, history) {
|
|
|
281
418
|
if (snapshot.turnId <= cutoffTurnId)
|
|
282
419
|
continue;
|
|
283
420
|
const change = ensure(snapshot.path);
|
|
421
|
+
if (snapshot.contentUnavailable)
|
|
422
|
+
change.snapshotAvailable = false;
|
|
284
423
|
for (const op of snapshot.ops ?? ['file_change']) {
|
|
285
424
|
if (!change.ops.includes(op))
|
|
286
425
|
change.ops.push(op);
|
|
@@ -300,6 +439,9 @@ function restoreSnapshot(snapshot) {
|
|
|
300
439
|
const full = safeFullPath(snapshot.path);
|
|
301
440
|
if (!full)
|
|
302
441
|
return false;
|
|
442
|
+
// 内容未捕获(工作区扫描时超出预算):宁可报冲突,也不能拿空内容覆盖用户文件。
|
|
443
|
+
if (snapshot.contentUnavailable)
|
|
444
|
+
return false;
|
|
303
445
|
const state = stateFromSnapshot(snapshot);
|
|
304
446
|
try {
|
|
305
447
|
if (state.kind === 'missing') {
|
|
@@ -413,6 +555,7 @@ export function resetState() {
|
|
|
413
555
|
turnIdCounter = 0;
|
|
414
556
|
currentTurnId = 0;
|
|
415
557
|
sequenceCounter = 0;
|
|
558
|
+
contentCache = new Map();
|
|
416
559
|
}
|
|
417
560
|
export function rebuildFromHistory(history) {
|
|
418
561
|
const rebuilt = [];
|
package/dist/tools/registry.js
CHANGED
|
@@ -148,8 +148,10 @@ async function executeToolOnce(tool, args, signal, opts) {
|
|
|
148
148
|
? beginPathMutation(args.path)
|
|
149
149
|
: null;
|
|
150
150
|
capturedPath = pathCapture?.path;
|
|
151
|
+
// 工作区快照是异步的:它遍历整棵工作树,同步实现会在每次 run_command/MCP 调用前后
|
|
152
|
+
// 阻塞事件循环数秒(TUI 完全冻结)。await 让 spinner / 走时 / 键鼠在扫描期间继续工作。
|
|
151
153
|
const workspaceCapture = capabilities.effect === 'process' || capabilities.effect === 'unknown'
|
|
152
|
-
? beginWorkspaceMutation()
|
|
154
|
+
? await beginWorkspaceMutation()
|
|
153
155
|
: null;
|
|
154
156
|
let raw;
|
|
155
157
|
try {
|
|
@@ -159,7 +161,7 @@ async function executeToolOnce(tool, args, signal, opts) {
|
|
|
159
161
|
if (pathCapture)
|
|
160
162
|
endPathMutation(pathCapture, tool.name);
|
|
161
163
|
if (workspaceCapture)
|
|
162
|
-
endWorkspaceMutation(workspaceCapture, tool.name);
|
|
164
|
+
await endWorkspaceMutation(workspaceCapture, tool.name);
|
|
163
165
|
}
|
|
164
166
|
const mutationAfter = getCurrentTurnMutationState();
|
|
165
167
|
const changedFiles = mutationAfter.version !== mutationBefore.version
|