@aiti-ai/aiti-git-trace 0.0.0-stage → 0.2.12

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.
Files changed (41) hide show
  1. package/AGENTS.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +38 -2
  4. package/api.ts +424 -0
  5. package/check-client.mjs +273 -0
  6. package/client/src/common.ts +179 -0
  7. package/client/src/entry.tsx +81 -0
  8. package/client/src/host-shim.ts +118 -0
  9. package/client/src/jsx-runtime.ts +2 -0
  10. package/client/src/panels.tsx +1377 -0
  11. package/client/src/trace-app.tsx +275 -0
  12. package/client/src/trace-files-row.pure.ts +88 -0
  13. package/client/src/trace-files-row.tsx +110 -0
  14. package/client/src/version-mention.ts +90 -0
  15. package/deploy-commit.mjs +52 -0
  16. package/dev-pack.mjs +246 -0
  17. package/dist/aiti-git-trace-client.mjs +4 -0
  18. package/dist/entry-C_le59dp.js +1594 -0
  19. package/dist/panels-BpC4XLni.js +1522 -0
  20. package/ext-icons-deploy.mjs +24 -0
  21. package/icon.svg +1 -0
  22. package/icons/git-branch.svg +1 -0
  23. package/icons/git-commit-horizontal.svg +1 -0
  24. package/index.ts +705 -0
  25. package/package.json +33 -4
  26. package/reconcile-deps.mjs +41 -0
  27. package/reload-instances.mjs +60 -0
  28. package/scripts/aiti-trace.mjs +117 -0
  29. package/skills/index.json +1 -0
  30. package/skills/rollback-session/SKILL.md +34 -0
  31. package/skills/trace-ops/SKILL.md +31 -0
  32. package/skills/watch-path/SKILL.md +34 -0
  33. package/test/fixtures/ops-sample.jsonl +3 -0
  34. package/tools/deploy.mjs +103 -0
  35. package/tools/trace-compact.mjs +58 -0
  36. package/trace-core.test.mjs +1120 -0
  37. package/trace-core.ts +1346 -0
  38. package/trace-files-row.test.mjs +66 -0
  39. package/tsconfig.check.json +21 -0
  40. package/version-mention.test.mjs +76 -0
  41. package/vite.config.ts +43 -0
@@ -0,0 +1,1120 @@
1
+ /**
2
+ * aiti-git-trace · server 核心测试(node --test)
3
+ *
4
+ * 覆盖:workspace 配置 CRUD/原子性、台账追加/读取/fork 预览过滤、
5
+ * store 初始化、hermetic 红线(用户仓无新 ref/object/无 status 变化)、
6
+ * 全量回撤(safety → read-tree → 记账)、部分回滚(hunk 过滤 + apply -R + -3 兜底路径)、
7
+ * handleApi 全路由(含 allowed-roots 拒绝、错误分支)。
8
+ */
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { execFileSync } from "node:child_process";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, appendFileSync, readdirSync, renameSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join, dirname } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const extDir = dirname(fileURLToPath(import.meta.url)); // 扩展源码目录(fixture 所在)
18
+
19
+ import {
20
+ workspaceKey, loadWorkspace, saveWorkspace, deleteWorkspace,
21
+ readOps, readOpsWithMeta, appendOps, forkPreviewOps, opsFile, sessionRepoRoots,
22
+ repoRootOf, initStore, storeOf, storeOk, runGit, lastCommit,
23
+ rollbackFull, rollbackPartial, diffPatch, BUILTIN_EXCLUDES_V1,
24
+ cleanupSessionOps, cleanupWorkspace, lineage,
25
+ forkPreviewOpsByUserEntries, rollbackOpsReversed,
26
+ sessionFileSummary, fileDiffText,
27
+ } from "./trace-core.ts";
28
+ import { handleApi } from "./api.ts";
29
+ import ext, { traceService, onTraceOp } from "./index.ts";
30
+
31
+ let tmpRoot = "";
32
+ /** 三目录对称(2026-09-13):configDir = <extRoot>/config,cache/state 在 <extRoot> 下 */
33
+ function extRootOf(configDir) {
34
+ return dirname(configDir);
35
+ }
36
+ function freshDir(name) {
37
+ const d = mkdtempSync(join(tmpdir(), "aiti-trace-" + name + "-"));
38
+ tmpRoot = d;
39
+ const configDir = join(d, "config");
40
+ mkdirSync(configDir, { recursive: true });
41
+ return configDir;
42
+ }
43
+ function sh(cwd, args) {
44
+ return execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", ...args], { cwd, encoding: "utf8" });
45
+ }
46
+ async function commitOnBranch(store, msg) {
47
+ await runGit(store, ["add", "-A"]); // 与引擎一致:先暂存工作区(含 untracked),再 write-tree
48
+ const tree = (await runGit(store, ["write-tree"])).stdout.trim();
49
+ const parentRes = await runGit(store, ["rev-parse", "--verify", "--quiet", "refs/heads/aiti/" + store.sessionId], { allowFail: true });
50
+ const parent = parentRes.stdout.trim();
51
+ const sha = (await runGit(store, ["commit-tree", tree, ...(parent ? ["-p", parent] : []), "-m", msg])).stdout.trim();
52
+ await runGit(store, ["update-ref", "refs/heads/aiti/" + store.sessionId, sha]);
53
+ return sha;
54
+ }
55
+ function makeUserRepo(name, files) {
56
+ const dir = mkdtempSync(join(tmpdir(), "aiti-trace-repo-"));
57
+ mkdirSync(dir, { recursive: true });
58
+ sh(dir, ["init", "--quiet"]);
59
+ for (const [p, content] of Object.entries(files)) {
60
+ mkdirSync(join(dir, p, ".."), { recursive: true });
61
+ writeFileSync(join(dir, p), content);
62
+ }
63
+ sh(dir, ["add", "-A"]);
64
+ sh(dir, ["commit", "--quiet", "-m", "base"]);
65
+ // 统一正斜杠形态:产品返回的 cwd 就是 / 分隔(win 反斜杠形态会让断言互不相等)
66
+ return dir.split("\\").join("/");
67
+ }
68
+
69
+ // ---------- workspace 配置 ----------
70
+ test("workspace config: 默认值 / 保存 / 删除", async () => {
71
+ const configDir = freshDir("ws");
72
+ const key = workspaceKey("/tmp/some/cwd");
73
+ assert.equal(loadWorkspace(configDir, key).autoDiscover, true);
74
+ assert.deepEqual(loadWorkspace(configDir, key).paths, []);
75
+ const saved = saveWorkspace(configDir, key, { autoDiscover: false, paths: [{ path: "/a", repoRoot: null }] });
76
+ assert.equal(saved.autoDiscover, false);
77
+ assert.equal(saved.paths[0].origin, "user"); // origin 归一为 user
78
+ assert.equal(loadWorkspace(configDir, key).autoDiscover, false);
79
+ assert.ok(existsSync(join(extRootOf(configDir), "cache", "workspaces", key + ".json")));
80
+ deleteWorkspace(configDir, key);
81
+ assert.equal(loadWorkspace(configDir, key).paths.length, 0);
82
+ });
83
+
84
+ // ---------- 台账 ----------
85
+ test("ledger: 原子追加 / 读取 / fork 预览过滤", async () => {
86
+ const configDir = freshDir("ledger");
87
+ appendOps(configDir, "sess/1", { op: 1, entryId: "e1", msgSeq: 1, stores: [{ root: "/x", before: "a", after: "b", files: {}, paths: ["p"], partial: false }], leaks: [] });
88
+ appendOps(configDir, "sess/1", { op: 2, entryId: "e2", msgSeq: 1, stores: [{ root: "/x", before: "b", after: "c", files: {}, paths: ["q"], partial: true }], leaks: [{ path: "/tmp/z", size: 1, kind: "create" }] });
89
+ const ops = readOps(configDir, "sess/1");
90
+ assert.equal(ops.length, 2);
91
+ assert.equal(ops[1].leaks[0].path, "/tmp/z");
92
+ // entryId=e1 之后的 op → 只剩 op2
93
+ assert.deepEqual(forkPreviewOps(configDir, "sess/1", "e1").map((o) => o.op), [2]);
94
+ // entryId 未知 → 保守返回全部
95
+ assert.equal(forkPreviewOps(configDir, "sess/1", "nope").length, 2);
96
+ // 坏行容错:手工追加非 JSON 行后仍可读
97
+ appendFileSync(opsFile(configDir, "sess/1"), "not-json-line\n");
98
+ assert.equal(readOps(configDir, "sess/1").length, 2);
99
+ });
100
+
101
+ test("sessionRepoRoots: 台账仓根去重按首现序,坏行/空台账容错", async () => {
102
+ const configDir = freshDir("roots");
103
+ assert.deepEqual(sessionRepoRoots(configDir, "empty"), []); // 无台账
104
+ appendOps(configDir, "s1", { op: 1, stores: [{ root: "/repo-a" }] });
105
+ appendOps(configDir, "s1", { op: 2, stores: [{ root: "/repo-a" }, { root: "/repo-b" }] });
106
+ appendOps(configDir, "s1", { op: 3, stores: [] });
107
+ appendOps(configDir, "s1", { op: 4, stores: [{ root: "/repo-c/" }, {}] }); // 尾斜杠保留原样(归一化归消费方)
108
+ assert.deepEqual(sessionRepoRoots(configDir, "s1"), ["/repo-a", "/repo-b", "/repo-c/"]);
109
+ });
110
+
111
+ // ---------- services.trace 出口(§16 / plan-first §3.2;msg-smart 跨仓分辨已接入) ----------
112
+
113
+ test("services.trace.readOps: sinceOp 增量 + limit 取最新", async () => {
114
+ const configDir = freshDir("svc-ops");
115
+ for (let op = 1; op <= 5; op++) appendOps(configDir, "sx", { op, stores: [] });
116
+ const svc = traceService(configDir);
117
+ assert.equal(svc.version, 2);
118
+ assert.deepEqual(svc.readOps("sx").map((o) => o.op), [1, 2, 3, 4, 5]); // 缺省全量
119
+ assert.deepEqual(svc.readOps("sx", { sinceOp: 3 }).map((o) => o.op), [4, 5]); // 增量
120
+ assert.deepEqual(svc.readOps("sx", { limit: 2 }).map((o) => o.op), [4, 5]); // 取最新
121
+ assert.deepEqual(svc.readOps("sx", { sinceOp: 99 }), []); // 游标越前 → 空(消费方重置逻辑自判)
122
+ });
123
+
124
+ test("services.trace.sessionStores: state 快照 + 台账补全去重,主仓在前", async () => {
125
+ const configDir = freshDir("svc-stores");
126
+ mkdirSync(join(extRootOf(configDir), "cache", "state"), { recursive: true });
127
+ writeFileSync(join(extRootOf(configDir), "cache", "state", "sy.json"), JSON.stringify({
128
+ cwd: "/repo-a", kind: "git", tracked: true,
129
+ stores: [{ root: "/repo-a", role: "primary" }, { root: "/repo-b", role: "secondary" }],
130
+ }));
131
+ // 台账里有 state 快照丢失的副仓(引擎重启场景)+ 与 state 重叠的主仓
132
+ appendOps(configDir, "sy", { op: 1, stores: [{ root: "/repo-a" }, { root: "/repo-c" }] });
133
+ const stores = traceService(configDir).sessionStores("sy");
134
+ assert.deepEqual(stores, [
135
+ { root: "/repo-a", role: "primary" },
136
+ { root: "/repo-b", role: "secondary" },
137
+ { root: "/repo-c", role: "secondary" },
138
+ ]);
139
+ assert.deepEqual(traceService(configDir).sessionStores("nope"), []); // 无 state 无台账
140
+ });
141
+
142
+ test("services.trace.listMd v2: tracked+untracked 全列;.gitignore/.aitiignore 生效", async () => {
143
+ const configDir = freshDir("svc-md2");
144
+ const repo = makeUserRepo("md2", { "docs/a.md": "# A\n", "src/x.ts": "const a=1;\n" });
145
+ // untracked 非 ignore md + gitignored md + .aitiignore 命中的 tracked md
146
+ writeFileSync(join(repo, "docs/b.md"), "B\n");
147
+ writeFileSync(join(repo, "ign.md"), "I\n");
148
+ appendFileSync(join(repo, ".gitignore"), "ign.md\n");
149
+ writeFileSync(join(repo, ".aitiignore"), "docs/a.md\n");
150
+ const store = await initStore(repo);
151
+ await runGit(storeOf(repo, "s9"), ["add", "-A"]); // 会话索引(.gitignore 使 ign.md 不入)
152
+ appendOps(configDir, "s9", { op: 1, stores: [{ root: repo, role: "primary" }] });
153
+ const rels = (await traceService(configDir).listMd("s9")).map((m) => m.path).sort();
154
+ assert.deepEqual(rels, ["docs/b.md"]); // a.md 被 .aitiignore 剔除;ign.md 被 gitignore 排除;x.ts 非 md 不列
155
+ });
156
+
157
+ test("services.trace.watchPaths + listMd: 关注路径映射 / ls-files 去重上限", async () => {
158
+ const configDir = freshDir("svc-md");
159
+ const repo = makeUserRepo("svc", { "docs/a.md": "# A\n", "README.md": "r\n", "src/x.ts": "const a=1;\n" });
160
+ const store = await initStore(repo);
161
+ const szStore = storeOf(repo, "sz");
162
+ await runGit(szStore, ["add", "-A"]); // 引擎每 op 同款:会话索引暂存后 ls-files 才可见(index 按会话分文件)
163
+ saveWorkspace(configDir, workspaceKey(repo), { enabled: true, autoDiscover: true, paths: [{ path: "/ext-repo", origin: "user", repoRoot: "/ext-repo", addedAt: 1 }] });
164
+ const svc = traceService(configDir);
165
+ assert.deepEqual(svc.watchPaths(repo), [{ path: "/ext-repo", repoRoot: "/ext-repo" }]);
166
+ assert.deepEqual(svc.watchPaths(""), []);
167
+ // state + 台账把 repo 记为主仓 → listMd 命中仓内 md(ts 不在列)
168
+ appendOps(configDir, "sz", { op: 1, stores: [{ root: repo, role: "primary" }] });
169
+ const mds = await svc.listMd("sz");
170
+ assert.deepEqual(mds.map((m) => m.path).sort(), ["README.md", "docs/a.md"]);
171
+ assert.ok(mds.every((m) => m.root === repo));
172
+ // 上限 200:写入 250 个 md → 截断
173
+ for (let i = 0; i < 250; i++) writeFileSync(join(repo, "bulk-" + i + ".md"), "x\n");
174
+ await runGit(szStore, ["add", "-A"]);
175
+ assert.equal((await svc.listMd("sz")).length, 200);
176
+ });
177
+
178
+ // ---------- store 初始化与红线 ----------
179
+ test("store: init/hermetic 校验 + 用户仓零污染", async () => {
180
+ const configDir = freshDir("store");
181
+ const repo = makeUserRepo("r1", { "a.txt": "v1\n" });
182
+ // 用户仓无 git 之前的快照
183
+ const refsBefore = sh(repo, ["for-each-ref"]).toString();
184
+ const objBefore = sh(repo, ["rev-parse", "HEAD"]);
185
+
186
+ const store = await initStore(repo);
187
+ assert.ok(storeOk(repo));
188
+ assert.ok(existsSync(join(repo, ".aiti-trace", "HEAD")));
189
+ // hermetic:init 产生的 config 只在 .git/aiti/config(git 分节格式)
190
+ const cfg = readFileSync(join(repo, ".aiti-trace", "config"), "utf8");
191
+ assert.ok(cfg.includes("[user]") && cfg.includes("name = aiti-trace"));
192
+ assert.ok(cfg.includes("[gc]") && /auto = 0/.test(cfg));
193
+
194
+ // 用 trace store 提交一次(模拟 op after)
195
+ writeFileSync(join(repo, "a.txt"), "v2\n");
196
+ await runGit(store, ["add", "-A"]);
197
+ await runGit(store, ["commit", "--quiet", "--allow-empty", "-m", "aiti op=1 after"]);
198
+ const traceHead = await lastCommit(store);
199
+
200
+ // 红线:用户仓 refs 无变化(for-each-ref 不含 .git/aiti 分支)、status 只显示文件改动
201
+ assert.equal(sh(repo, ["for-each-ref"]).toString(), refsBefore);
202
+ assert.equal(sh(repo, ["rev-parse", "HEAD"]), objBefore);
203
+ const status = sh(repo, ["status", "--porcelain"]).toString();
204
+ assert.ok(status.includes("a.txt")); // 只有工作区文件改动
205
+ assert.ok(!status.includes("aiti")); // 追踪仓内容不进用户 status
206
+ void configDir;
207
+ });
208
+
209
+ test("store: init 自携带免打扰 .gitignore/.ignore = *(AI grep 自动跳过)且幂等", async () => {
210
+ const repo = makeUserRepo("sr", { "a.txt": "v1\n" });
211
+ await initStore(repo);
212
+ const gi = readFileSync(join(repo, ".aiti-trace", ".gitignore"), "utf8");
213
+ const ig = readFileSync(join(repo, ".aiti-trace", ".ignore"), "utf8");
214
+ assert.ok(gi.split("\n").includes("*"), "store .gitignore 应含 *");
215
+ assert.ok(ig.split("\n").includes("*"), "store .ignore 应含 *");
216
+ // 幂等:重复 init 不重复追加
217
+ await initStore(repo);
218
+ const gi2 = readFileSync(join(repo, ".aiti-trace", ".gitignore"), "utf8");
219
+ assert.equal(gi2.split("\n").filter((l) => l === "*").length, 1, "重复 init 不应重复追加 *");
220
+ });
221
+
222
+ // ---------- 全量回撤 ----------
223
+ test("rollbackFull: safety → read-tree → 记账,worktree 恢复目标态;commit 落会话分支", async () => {
224
+ const repo = makeUserRepo("r2", { "a.txt": "v1\n" });
225
+ await initStore(repo);
226
+ const store = storeOf(repo, "s1");
227
+ const branch = "refs/heads/aiti/s1"; // 与 paths.sessionBranch/store.sessionBranch 一致
228
+ await commitOnBranch(store, "aiti baseline");
229
+ // op1:改文件提交(模拟;plumbing 落会话分支,与引擎一致)
230
+ writeFileSync(join(repo, "a.txt"), "v2\n");
231
+ writeFileSync(join(repo, "new.txt"), "n\n");
232
+ await commitOnBranch(store, "aiti op=1 after");
233
+ const after1 = await lastCommit(store);
234
+ const before1 = (await runGit(store, ["rev-parse", branch + "~1"])).stdout.trim();
235
+
236
+ const configDir = freshDir("rbfull");
237
+ const r = await rollbackFull({ repoRoot: repo, sessionId: "s1", targetCommit: before1 });
238
+ assert.ok(r.safety && r.rollback);
239
+ assert.equal(readFileSync(join(repo, "a.txt"), "utf8"), "v1\n");
240
+ assert.ok(!existsSync(join(repo, "new.txt"))); // -u 含删除
241
+ assert.ok(after1);
242
+ // 分支归属红线:safety/rollback 记账必须落在会话分支(而非 HEAD 指向的 init 分支)
243
+ const branchLog = (await runGit(store, ["log", "--format=%s", branch])).stdout;
244
+ assert.ok(/phase=safety/.test(branchLog), "safety 应在会话分支");
245
+ assert.ok(/phase=rollback/.test(branchLog), "rollback 应在会话分支");
246
+ // 追踪仓的 master(init 默认分支)保持 unborn:git commit 绝不带写 HEAD
247
+ let masterExists = true;
248
+ try { await runGit(store, ["rev-parse", "--verify", "--quiet", "refs/heads/master"]); } catch { masterExists = false; } finally { void 0; }
249
+ assert.ok(!(await runGit(store, ["for-each-ref", "--format=%(refname)", "refs/heads/master"])).stdout.includes("refs/heads/master"), "master 不应被创建");
250
+ void masterExists; void configDir;
251
+ });
252
+
253
+ test("fork-preview: userEntryId 优先匹配(fork 点=用户消息 entry)", async () => {
254
+ const configDir = freshDir("forkp");
255
+ appendOps(configDir, "sx", { op: 1, entryId: "assistant-1", userEntryId: "user-1", stores: [] });
256
+ appendOps(configDir, "sx", { op: 2, entryId: "assistant-2", userEntryId: "user-2", stores: [] });
257
+ appendOps(configDir, "sx", { op: 3, entryId: "assistant-3", userEntryId: "user-3", stores: [] });
258
+ // fork 在 user-2(用户消息):应只返回其后的 op3
259
+ const after = forkPreviewOps(configDir, "sx", "user-2");
260
+ assert.deepEqual(after.map((o) => o.op), [3]);
261
+ // 旧格式(无 userEntryId)回退 entryId 匹配
262
+ const after2 = forkPreviewOps(configDir, "sx", "assistant-1");
263
+ assert.deepEqual(after2.map((o) => o.op), [2, 3]);
264
+ // 未知 entry:保守返回全部
265
+ assert.equal(forkPreviewOps(configDir, "sx", "unknown").length, 3);
266
+ });
267
+
268
+ // ---------- 部分回滚 ----------
269
+ test("rollbackPartial: hunk 过滤 → 只回滚命中文件", async () => {
270
+ const repo = makeUserRepo("r3", { "a.txt": "l1\nl2\nl3\n", "b.txt": "x1\nx2\nx3\n" });
271
+ await initStore(repo);
272
+ const store = storeOf(repo, "s1");
273
+ const baseline = await commitOnBranch(store, "aiti baseline"); // init 后的第一笔
274
+ // 一次 op 同时改 a.txt 和 b.txt
275
+ writeFileSync(join(repo, "a.txt"), "l1\nl2-mod\nl3\n");
276
+ writeFileSync(join(repo, "b.txt"), "x1\nx2-mod\nx3\n");
277
+ const target = await commitOnBranch(store, "aiti op=1 after");
278
+
279
+ const { hunks } = await diffPatch(store, baseline, target, ["a.txt", "b.txt"]);
280
+ assert.equal(hunks.length, 2);
281
+ // 只回滚 a.txt 的 hunk(idx 0)
282
+ const configDir = freshDir("rbpartial");
283
+ const r = await rollbackPartial({ repoRoot: repo, sessionId: "s1", base: baseline, target, files: ["a.txt", "b.txt"], hunkIdx: [0] });
284
+ assert.ok(r.rollback);
285
+ assert.equal(readFileSync(join(repo, "a.txt"), "utf8"), "l1\nl2\nl3\n");
286
+ assert.equal(readFileSync(join(repo, "b.txt"), "utf8"), "x1\nx2-mod\nx3\n"); // b 未动
287
+
288
+ // 再回滚 b.txt 的 hunk(idx 1)
289
+ await rollbackPartial({ repoRoot: repo, sessionId: "s1", base: baseline, target, files: ["a.txt", "b.txt"], hunkIdx: [1] });
290
+ assert.equal(readFileSync(join(repo, "b.txt"), "utf8"), "x1\nx2\nx3\n");
291
+
292
+ // [P0-1 契约] 空 hunkIdx = 该文件全部 hunk(前端逐 op 回滚的真实调用形态)
293
+ writeFileSync(join(repo, "a.txt"), "l1\nl2-x\nl3\n");
294
+ writeFileSync(join(repo, "b.txt"), "x1\nx2-y\nx3\n");
295
+ const target2 = await commitOnBranch(store, "aiti op=2 after");
296
+ const r2 = await rollbackPartial({ repoRoot: repo, sessionId: "s1", base: target, target: target2, files: ["a.txt", "b.txt"], hunkIdx: [] });
297
+ assert.ok(r2.rollback);
298
+ assert.equal(readFileSync(join(repo, "a.txt"), "utf8"), "l1\nl2-mod\nl3\n", "空 hunkIdx → 回到 base(=target) 内容");
299
+ assert.equal(readFileSync(join(repo, "b.txt"), "utf8"), "x1\nx2-mod\nx3\n", "空 hunkIdx → 回到 base(=target) 内容");
300
+ });
301
+
302
+ // ---------- handleApi 路由 ----------
303
+ test("api: ops/fork-preview/workspace/rollback/rollback-partial 全路由", async () => {
304
+ const configDir = freshDir("api");
305
+ const repo = makeUserRepo("r4", { "a.txt": "v1\n" });
306
+ await initStore(repo);
307
+ const store = storeOf(repo, "s1");
308
+ await commitOnBranch(store, "aiti baseline");
309
+ const base = await lastCommit(store);
310
+ writeFileSync(join(repo, "a.txt"), "v2\n");
311
+ const target = await commitOnBranch(store, "aiti op=1 after");
312
+
313
+ const env = { configDir, dir: extDir, allowRoot: async () => true };
314
+ const call = (method, path, body) => {
315
+ const [p, qs] = path.split("?");
316
+ return handleApi(new Request("http://x" + path, {
317
+ method, body: body ? JSON.stringify(body) : undefined,
318
+ headers: body ? { "content-type": "application/json" } : undefined,
319
+ }), { path: p.split("/").filter(Boolean), searchParams: new URLSearchParams(qs || "") }, env);
320
+ };
321
+
322
+ // ops 空
323
+ let r = await (await call("GET", "/ops?session=s1")).json();
324
+ assert.equal(r.ok, true); assert.equal(r.ops.length, 0);
325
+ // fixture
326
+ r = await (await call("GET", "/ops?session=s1&fixture=sample")).json();
327
+ assert.equal(r.ops.length, 3);
328
+ // fork-preview 有数据
329
+ r = await (await call("GET", "/fork-preview?session=s1&entryId=e1")).json();
330
+ assert.equal(r.ops.length, 0); // 台账为空(只有 fixture 时不读台账)→ 空数组直通
331
+ // workspace CRUD
332
+ r = await (await call("POST", "/workspace/resolve", { cwd: repo })).json();
333
+ assert.equal(r.ok, true); assert.ok(r.key);
334
+ r = await (await call("POST", "/workspace", { key: "k1", autoDiscover: true, paths: [{ path: "/x" }] })).json();
335
+ assert.equal(r.config.paths[0].path, "/x");
336
+ r = await (await call("GET", "/workspace?key=k1")).json();
337
+ assert.equal(r.config.paths.length, 1);
338
+ r = await (await call("DELETE", "/workspace?key=k1")).json();
339
+ assert.equal(r.deleted, true);
340
+ // watch/resolve
341
+ r = await (await call("POST", "/watch/resolve", { path: repo })).json();
342
+ assert.equal(r.isGit, true);
343
+ // new-session-cwd(@到新会话 CWD 判定):目标在当前 cwd 内 → 保持;其它仓 → 切;缺 targetDir → 400
344
+ r = await (await call("POST", "/new-session-cwd", { targetDir: repo + "/sub", cwd: repo })).json();
345
+ assert.equal(r.ok, true); assert.equal(r.cwd.split("\\").join("/"), repo); assert.equal(r.switched, false);
346
+ r = await (await call("POST", "/new-session-cwd", { targetDir: repo + "/sub", cwd: "/elsewhere" })).json();
347
+ assert.equal(r.ok, true); assert.equal(r.cwd.split("\\").join("/"), repo + "/sub"); assert.equal(r.switched, true);
348
+ r = await (await call("POST", "/new-session-cwd", { cwd: repo })).json();
349
+ assert.equal(r.ok, false);
350
+ // rollback:bad store 400
351
+ r = await (await call("POST", "/rollback", { storeRoot: "/tmp/not-exist", sessionId: "s1", targetCommit: base })).json();
352
+ assert.equal(r.ok, false);
353
+ // rollback:正常
354
+ r = await (await call("POST", "/rollback", { storeRoot: repo, sessionId: "s1", targetCommit: base })).json();
355
+ assert.equal(r.ok, true);
356
+ // 台账记了一笔 rollback
357
+ const ops = readOps(configDir, "s1");
358
+ assert.equal(ops[ops.length - 1].kind, "rollback");
359
+ // rollback-partial:缺 files 400([P0-1] 空 hunkIdx 已合法 = 全部 hunk,直连用例覆盖)
360
+ r = await (await call("POST", "/rollback-partial", { storeRoot: repo, sessionId: "s1", base, target, files: [], hunkIdx: [] })).json();
361
+ assert.equal(r.ok, false);
362
+ // allowed-roots 拒绝
363
+ const envDeny = { configDir, dir: "", allowRoot: async () => false };
364
+ r = await (await handleApi(new Request("http://x/rollback", {
365
+ method: "POST", headers: { "content-type": "application/json" },
366
+ body: JSON.stringify({ storeRoot: repo, sessionId: "s1", targetCommit: base }),
367
+ }), { path: ["rollback"], searchParams: new URLSearchParams() }, envDeny)).json();
368
+ assert.equal(r.ok, false);
369
+ // excludes
370
+ r = await (await call("GET", "/excludes")).json();
371
+ assert.ok(r.text.includes("node_modules/"));
372
+ assert.ok(BUILTIN_EXCLUDES_V1.includes("__pycache__/"));
373
+ });
374
+
375
+ // ---------- 回撤一等化记账(api 层) ----------
376
+ test("api: fork-preview POST entryIds + rollback-reversed 记账行一等化", async () => {
377
+ const configDir = freshDir("api-rev");
378
+ const repo = makeUserRepo("r5", { "a.txt": "v0\n", "b.txt": "keep\n" });
379
+ await initStore(repo);
380
+ const store = storeOf(repo, "s1");
381
+ const b0 = await commitOnBranch(store, "aiti baseline");
382
+ writeFileSync(join(repo, "a.txt"), "v1\n");
383
+ const a1 = await commitOnBranch(store, "aiti op1 after");
384
+ writeFileSync(join(repo, "a.txt"), "v2\n");
385
+ const a2 = await commitOnBranch(store, "aiti op2 after");
386
+ appendOps(configDir, "s1", { op: 1, entryId: "a1", userEntryId: "u1", tool: "write", summary: "a v1", cwd: repo, stores: [{ root: repo, before: b0, after: a1, files: { M: 1, A: 0, D: 0 }, paths: ["a.txt"], partial: false }], leaks: [] });
387
+ appendOps(configDir, "s1", { op: 2, entryId: "a2", userEntryId: "u1", tool: "write", summary: "a v2", cwd: repo, stores: [{ root: repo, before: a1, after: a2, files: { M: 1, A: 0, D: 0 }, paths: ["a.txt"], partial: false }], leaks: [] });
388
+
389
+ const env = { configDir, dir: extDir, allowRoot: async () => true };
390
+ const call = (method, path, body) => {
391
+ const [p, qs] = path.split("?");
392
+ return handleApi(new Request("http://x" + path, {
393
+ method, body: body ? JSON.stringify(body) : undefined,
394
+ headers: body ? { "content-type": "application/json" } : undefined,
395
+ }), { path: p.split("/").filter(Boolean), searchParams: new URLSearchParams(qs || "") }, env);
396
+ };
397
+
398
+ // fork-preview POST entryIds:裁 u1 轮 → 圈出 op1,2(含被编辑轮头部)
399
+ let r = await (await call("POST", "/fork-preview", { session: "s1", entryIds: ["u1"] })).json();
400
+ assert.equal(r.ok, true); assert.deepEqual(r.ops.map((o) => o.op), [1, 2]);
401
+ // 未知集合 → 空
402
+ r = await (await call("POST", "/fork-preview", { session: "s1", entryIds: ["u9"] })).json();
403
+ assert.deepEqual(r.ops, []);
404
+
405
+ // rollback-reversed 正常
406
+ r = await (await call("POST", "/rollback-reversed", {
407
+ storeRoot: repo, sessionId: "s1",
408
+ refs: [{ op: 1, before: b0, after: a1 }, { op: 2, before: a1, after: a2 }],
409
+ })).json();
410
+ assert.equal(r.ok, true); assert.equal(r.failed, null); assert.equal(r.applied, 2);
411
+ assert.equal(readFileSync(join(repo, "a.txt"), "utf8"), "v0\n");
412
+ // 一等化记账行:LedgerRow 同构 + stores[].before/after = safety/记账 + rollback.targetOps
413
+ const ops = readOps(configDir, "s1");
414
+ const rb = ops[ops.length - 1];
415
+ assert.equal(rb.kind, "rollback");
416
+ assert.equal(rb.scope, "reverse-ops");
417
+ assert.equal(rb.tool, "rollback");
418
+ assert.ok(/回撤 #1,2/.test(rb.summary));
419
+ assert.deepEqual(rb.rollback.targetOps, [1, 2]);
420
+ assert.equal(rb.stores[0].root, repo);
421
+ assert.ok(rb.stores[0].before && rb.stores[0].after);
422
+ assert.equal(rb.stores[0].paths.includes("a.txt"), true);
423
+
424
+ // 无 refs → 400
425
+ r = await (await call("POST", "/rollback-reversed", { storeRoot: repo, sessionId: "s1", refs: [] })).json();
426
+ assert.equal(r.ok, false);
427
+ // allowed-roots 拒绝
428
+ const envDeny = { configDir, dir: "", allowRoot: async () => false };
429
+ r = await (await handleApi(new Request("http://x/rollback-reversed", {
430
+ method: "POST", headers: { "content-type": "application/json" },
431
+ body: JSON.stringify({ storeRoot: repo, sessionId: "s1", refs: [{ op: 1, before: b0, after: a1 }] }),
432
+ }), { path: ["rollback-reversed"], searchParams: new URLSearchParams() }, envDeny)).json();
433
+ assert.equal(r.ok, false);
434
+ });
435
+
436
+ test("api: 撤销回撤(op=-1)可通过 filter 且记账 targetOps 留空", async () => {
437
+ const configDir = freshDir("api-undo");
438
+ const repo = makeUserRepo("r6", { "a.txt": "v0\n" });
439
+ await initStore(repo);
440
+ const store = storeOf(repo, "s1");
441
+ const b0 = await commitOnBranch(store, "aiti baseline");
442
+ writeFileSync(join(repo, "a.txt"), "v1\n");
443
+ const a1 = await commitOnBranch(store, "aiti op1 after");
444
+ const env = { configDir, dir: extDir, allowRoot: async () => true };
445
+ const call = (method, path, body) => {
446
+ const [p, qs] = path.split("?");
447
+ return handleApi(new Request("http://x" + path, {
448
+ method, body: body ? JSON.stringify(body) : undefined,
449
+ headers: body ? { "content-type": "application/json" } : undefined,
450
+ }), { path: p.split("/").filter(Boolean), searchParams: new URLSearchParams(qs || "") }, env);
451
+ };
452
+ // 先回撤 op1(safety=before, rollback=after),再对 safety→rollback 反向 patch = 撤销回撤
453
+ const rb = await (await call("POST", "/rollback-reversed", {
454
+ storeRoot: repo, sessionId: "s1", refs: [{ op: 1, before: b0, after: a1 }],
455
+ })).json();
456
+ assert.equal(rb.ok, true); assert.equal(readFileSync(join(repo, "a.txt"), "utf8"), "v0\n");
457
+ const undo = await (await call("POST", "/rollback-reversed", {
458
+ storeRoot: repo, sessionId: "s1", refs: [{ op: -1, before: rb.safety, after: rb.rollback }],
459
+ })).json();
460
+ assert.equal(undo.ok, true, JSON.stringify(undo)); // 关键:op=-1 必须通过 filter(曾因 rf.op>0 被吞 → 400)
461
+ assert.equal(undo.applied, 1);
462
+ assert.equal(readFileSync(join(repo, "a.txt"), "utf8"), "v1\n"); // 撤销回撤 → 恢复 v1
463
+ const ops = readOps(configDir, "s1");
464
+ const last = ops[ops.length - 1];
465
+ assert.equal(last.kind, "rollback");
466
+ assert.deepEqual(last.rollback.targetOps, []); // 撤销回撤记账:targetOps 留空
467
+ assert.ok(/撤销回撤/.test(last.summary));
468
+ });
469
+
470
+ // ---------- 归属解析 ----------
471
+ test("resolveWatchPath: walk-up + 嵌套仓向下扫一层", async () => {
472
+ const { resolveWatchPath } = await import("./trace-core.ts");
473
+ const root = mkdtempSync(join(tmpdir(), "aiti-trace-nested-"));
474
+ const inner = join(root, "pi-web");
475
+ mkdirSync(join(inner, ".git"), { recursive: true });
476
+ writeFileSync(join(inner, ".git", "HEAD"), "ref: refs/heads/main\n");
477
+ // 路径在嵌套仓内 → 归属该仓
478
+ const inRepo = resolveWatchPath(join(inner, "src"));
479
+ assert.equal(inRepo.repoRoot, inner);
480
+ // 目录本身无归属 → 向下扫一层发现嵌套仓
481
+ const parent = resolveWatchPath(root);
482
+ assert.equal(parent.repoRoot, null);
483
+ assert.ok(parent.nested.includes(inner));
484
+ rmSync(root, { recursive: true, force: true });
485
+ });
486
+
487
+ // ---------- 清理(§15) ----------
488
+ test("cleanupSessionOps:整删 / beforeOp 截断保留", async () => {
489
+ const configDir = freshDir("cln");
490
+ const sid = "s-cln";
491
+ for (const op of [1, 2, 3, 4]) appendOps(configDir, sid, { op, file: "f" + op + ".txt" });
492
+ const r1 = cleanupSessionOps(configDir, sid, 3); // 清 #3 之前 → 保留 3,4
493
+ assert.equal(r1.removed, 2);
494
+ assert.deepEqual(readOps(configDir, sid).map((r) => r.op), [3, 4]);
495
+ const r2 = cleanupSessionOps(configDir, sid); // 整删
496
+ assert.equal(r2.deleted, true);
497
+ assert.equal(readOps(configDir, sid).length, 0);
498
+ });
499
+
500
+ test("cleanupWorkspace:cwd 前缀匹配删台账 + 仅删自己的追踪仓", async () => {
501
+ const configDir = freshDir("clnw");
502
+ mkdirSync(join(extRootOf(configDir), "state", "ops"), { recursive: true });
503
+ const mk = (sid, cwd) => writeFileSync(join(extRootOf(configDir), "state", "ops", sid + ".jsonl"), JSON.stringify({ op: 1, cwd }) + "\n");
504
+ const root = mkdtempSync(join(tmpdir(), "aiti-ws-")); // workspace 根(真实 tmp 路径)
505
+ mk("in-repo", join(root, "repo", "sub"));
506
+ mk("at-root", root);
507
+ mk("outside", join(root, "..", "elsewhere"));
508
+ // 造一个「我们的」追踪仓(HEAD 存在即认定)
509
+ const store = join(root, ".aiti-trace");
510
+ mkdirSync(join(store, "refs"), { recursive: true });
511
+ writeFileSync(join(store, "HEAD"), "ref: refs/heads/main");
512
+ const r = cleanupWorkspace(configDir, root);
513
+ assert.equal(r.sessions, 2); // in-repo + at-root
514
+ assert.equal(r.storeRemoved, true);
515
+ assert.ok(existsSync(join(extRootOf(configDir), "state", "ops", "outside.jsonl"))); // workspace 外不删
516
+ assert.ok(!existsSync(join(extRootOf(configDir), "ops", "in-repo.jsonl")));
517
+ });
518
+ test("lineage:parent 链聚合(祖先根优先 / 后代 BFS)+ 无关会话不混入", async () => {
519
+ const configDir = freshDir("lineage");
520
+ mkdirSync(join(extRootOf(configDir), "state", "ops"), { recursive: true });
521
+ const mk = (sid, parent) => writeFileSync(join(extRootOf(configDir), "state", "ops", sid + ".jsonl"),
522
+ JSON.stringify({ op: 1, parent: parent || null, t0: 100, t1: 200 }) + "\n");
523
+ mk("A"); mk("B", "A"); mk("C", "B"); mk("D", "A"); mk("E", null); // 链 A←B←C,分叉 D,无关 E
524
+ const r = lineage(configDir, "C");
525
+ assert.deepEqual(r.ancestors.map((n) => n.id), ["A", "B"]); // 根优先
526
+ assert.equal(r.self.id, "C");
527
+ assert.deepEqual(r.descendants.map((n) => n.id), []); // C 无后代
528
+ const r2 = lineage(configDir, "A");
529
+ assert.deepEqual(r2.descendants.map((n) => n.id), ["B", "D", "C"]); // BFS:B/D 同层在前,C 在 B 后
530
+ assert.ok([...r2.ancestors, r2.self, ...r2.descendants].every((n) => n.opCount >= 1));
531
+ });
532
+ // ---------- 事件出口(plan-first §3.2 v2:onOp / sink 链式装卸 / SSE) ----------
533
+
534
+ test("事件 sink:startup 链式安装(前任也收)→ bus 派发 onOp / shutdown 恢复前任", async () => {
535
+ const configDir = freshDir("svc-sink");
536
+ const prevCalls = [];
537
+ const prevEmit = globalThis.__aitiTraceEmit;
538
+ globalThis.__aitiTraceEmit = (e) => prevCalls.push(e); // 模拟前任消费者
539
+ const got = [];
540
+ ext.server.startup({ configDir, dir: extDir });
541
+ try {
542
+ const evt = { kind: "op", sessionId: "sk", row: { op: 9 } };
543
+ const off = traceService(configDir).onOp((e) => got.push(e));
544
+ globalThis.__aitiTraceEmit(evt); // pi 扩展同款 emit 入口
545
+ assert.deepEqual(got, [evt]); // 新消费者经 bus 收到
546
+ assert.deepEqual(prevCalls, [evt]); // 链式:前任也收到(不覆盖)
547
+ off();
548
+ globalThis.__aitiTraceEmit(evt);
549
+ assert.equal(got.length, 1); // 退订后不再收
550
+ } finally {
551
+ ext.server.shutdown();
552
+ globalThis.__aitiTraceEmit = prevEmit; // 恢复现场(shutdown 已恢复,此处双保险)
553
+ }
554
+ });
555
+
556
+ test("SSE /events:event-stream 头 + 首帧 connected + op 事件帧", async () => {
557
+ const configDir = freshDir("svc-sse");
558
+ const res = await handleApi(
559
+ new Request("http://x/events"),
560
+ { path: ["events"], searchParams: new URLSearchParams() },
561
+ { configDir, onTraceOp },
562
+ );
563
+ assert.equal(res.headers.get("content-type"), "text/event-stream");
564
+ const reader = (res.body).getReader();
565
+ const dec = new TextDecoder();
566
+ const first = dec.decode((await reader.read()).value); // ": connected\n\n"
567
+ assert.match(first, /: connected/);
568
+ onTraceOp(() => {}); // 占位:确保 bus 活跃无异常
569
+ // 经 globalThis 桥(未装 sink 时无派发)→ 直接用 onTraceOp 同源 bus 验证帧格式:跳过(sink 测试已覆盖派发)
570
+ await reader.cancel();
571
+ });
572
+
573
+ // ---------- 「从这里编辑」区间 + 逐 op 逆序反向回撤(方案 §7) ----------
574
+
575
+ test("forkPreviewOpsByUserEntries:按被裁轮集合过滤 / 未知→空 / 旧台账兜底 entryId", async () => {
576
+ const configDir = freshDir("fpe");
577
+ appendOps(configDir, "sx", { op: 1, entryId: "a1", userEntryId: "u1", stores: [] });
578
+ appendOps(configDir, "sx", { op: 2, entryId: "a2", userEntryId: "u1", stores: [] }); // u1 轮(两 op)
579
+ appendOps(configDir, "sx", { op: 3, entryId: "a3", userEntryId: "u2", stores: [] }); // u2 轮
580
+ appendOps(configDir, "sx", { op: 4, entryId: "a4", stores: [{ root: "/x", before: "a", after: "b", files: {}, paths: ["p"], partial: false }] }); // 旧格式无 userEntryId
581
+ // 裁 u1+u2 两轮 → 应圈出 op 1,2,3(含被编辑轮 op1,区分 forkPreviewOps 的 slice(last+1) 漏头)
582
+ assert.deepEqual(forkPreviewOpsByUserEntries(configDir, "sx", ["u1", "u2"]).map((o) => o.op), [1, 2, 3]);
583
+ // 只裁 u1 → [1,2]
584
+ assert.deepEqual(forkPreviewOpsByUserEntries(configDir, "sx", ["u1"]).map((o) => o.op), [1, 2]);
585
+ // 旧台账兜底 entryId 匹配(无 userEntryId 的行)
586
+ assert.deepEqual(forkPreviewOpsByUserEntries(configDir, "sx", ["a4"]).map((o) => o.op), [4]);
587
+ // 未知集合 → 空(不弹直通)
588
+ assert.deepEqual(forkPreviewOpsByUserEntries(configDir, "sx", ["unknown"]), []);
589
+ // 空集合 → 空
590
+ assert.deepEqual(forkPreviewOpsByUserEntries(configDir, "sx", []), []);
591
+ });
592
+
593
+ test("rollbackOpsReversed:同文件多 op 逆序反向撤 + before/after 记账可逆", async () => {
594
+ const repo = makeUserRepo("rr", { "a.txt": "v0\n" });
595
+ await initStore(repo);
596
+ const store = storeOf(repo, "s1");
597
+ const b0 = await commitOnBranch(store, "aiti baseline");
598
+ // op1:a.txt v0→v1
599
+ writeFileSync(join(repo, "a.txt"), "v1\n");
600
+ const a1 = await commitOnBranch(store, "aiti op=1 after");
601
+ // op2:a.txt v1→v2(同文件后续 op)
602
+ writeFileSync(join(repo, "a.txt"), "v2\n");
603
+ const a2 = await commitOnBranch(store, "aiti op=2 after");
604
+
605
+ const r = await rollbackOpsReversed({ repoRoot: repo, sessionId: "s1", refs: [
606
+ { op: 1, before: b0, after: a1 },
607
+ { op: 2, before: a1, after: a2 },
608
+ ] });
609
+ assert.ok(r.safety && r.rollback);
610
+ assert.equal(r.failed, null);
611
+ assert.equal(r.applied, 2); // op2 先撤 → op1 再撤
612
+ assert.equal(readFileSync(join(repo, "a.txt"), "utf8"), "v0\n"); // 回到基线
613
+ // 回撤本身可逆:对 safety→rollback 反向 patch = 恢复到回撤前(v2)
614
+ const rb = await rollbackOpsReversed({ repoRoot: repo, sessionId: "s1", refs: [
615
+ { op: "rb", before: r.safety, after: r.rollback },
616
+ ] });
617
+ assert.equal(rb.failed, null);
618
+ assert.equal(readFileSync(join(repo, "a.txt"), "utf8"), "v2\n");
619
+ // 记账 commit 落会话分支(master unborn 红线)
620
+ const branchLog = (await runGit(store, ["log", "--format=%s", "refs/heads/aiti/s1"])).stdout;
621
+ assert.ok(/phase=safety/.test(branchLog));
622
+ assert.ok(/phase=rollback/.test(branchLog));
623
+ assert.ok(!(await runGit(store, ["for-each-ref", "--format=%(refname)", "refs/heads/master"])).stdout.includes("refs/heads/master"));
624
+ });
625
+
626
+ test("rollbackOpsReversed:gap 跳过,且只撤该 op 的增量(保留 gap 来源变更)", async () => {
627
+ const repo = makeUserRepo("rr2", { "a.txt": "v0\n" });
628
+ await initStore(repo);
629
+ const store = storeOf(repo, "s1");
630
+ await commitOnBranch(store, "aiti baseline");
631
+ // op1(gap:before=null)→ 跳过;op1 after = a.txt v0→v-gap
632
+ writeFileSync(join(repo, "a.txt"), "v-gap\n");
633
+ const gapAfter = await commitOnBranch(store, "aiti gap after");
634
+ // op2:before=gapAfter(a.txt=v-gap),after= v2 —— 只撤 v-gap→v2 增量,不碰 gap 来源变更
635
+ writeFileSync(join(repo, "a.txt"), "v2\n");
636
+ const a2 = await commitOnBranch(store, "aiti op=2 after");
637
+
638
+ const r = await rollbackOpsReversed({ repoRoot: repo, sessionId: "s1", refs: [
639
+ { op: 1, before: null, after: gapAfter }, // gap:skip
640
+ { op: 2, before: gapAfter, after: a2 }, // 正常撤
641
+ ] });
642
+ assert.equal(r.failed, null);
643
+ assert.equal(r.skipped.length, 1);
644
+ assert.equal(r.skipped[0].op, 1);
645
+ assert.equal(r.applied, 1);
646
+ assert.equal(readFileSync(join(repo, "a.txt"), "utf8"), "v-gap\n"); // 回 v-gap(gap 增量保留,只撤 op2)
647
+ });
648
+
649
+ test("集成:裁掉中间两轮,只撤被裁轮变更,未裁轮+用户手改保留", async () => {
650
+ const repo = makeUserRepo("int", { "a.txt": "a0\n", "b.txt": "b0\n" });
651
+ await initStore(repo);
652
+ const store = storeOf(repo, "s1");
653
+ const b0 = await commitOnBranch(store, "aiti baseline");
654
+ const configDir = freshDir("int");
655
+ const seq = [
656
+ // 轮1(不裁,保留):改 a.txt a0→a1
657
+ async () => { writeFileSync(join(repo, "a.txt"), "a1\n"); return await commitOnBranch(store, "aiti op1 after"); },
658
+ // 轮2(裁):改 a.txt a1→a2
659
+ async () => { writeFileSync(join(repo, "a.txt"), "a2\n"); return await commitOnBranch(store, "aiti op2 after"); },
660
+ // 轮3(裁):改 b.txt b0→b1
661
+ async () => { writeFileSync(join(repo, "b.txt"), "b1\n"); return await commitOnBranch(store, "aiti op3 after"); },
662
+ ];
663
+ const a1 = await seq[0](); const a2 = await seq[1](); const a3 = await seq[2]();
664
+ // 台账:轮1(保留), 轮2+轮3(被裁)
665
+ appendOps(configDir, "s1", { op: 1, entryId: "a1", userEntryId: "u1", tool: "w", summary: "a1", msgSeq: 1, stores: [{ root: repo, before: b0, after: a1, files: { M: 1, A: 0, D: 0 }, paths: ["a.txt"], partial: false }], leaks: [] });
666
+ appendOps(configDir, "s1", { op: 2, entryId: "a2", userEntryId: "u2", tool: "w", summary: "a2", msgSeq: 2, stores: [{ root: repo, before: a1, after: a2, files: { M: 1, A: 0, D: 0 }, paths: ["a.txt"], partial: false }], leaks: [] });
667
+ appendOps(configDir, "s1", { op: 3, entryId: "a3", userEntryId: "u3", tool: "w", summary: "b1", msgSeq: 3, stores: [{ root: repo, before: a2, after: a3, files: { M: 1, A: 0, D: 0 }, paths: ["b.txt"], partial: false }], leaks: [] });
668
+
669
+ // 被裁轮 = u2,u3 → fork-preview POST 圈出 op2,3
670
+ const env = { configDir, dir: extDir, allowRoot: async () => true };
671
+ const call = (method, path, body) => {
672
+ const [p, qs] = path.split("?");
673
+ return handleApi(new Request("http://x" + path, {
674
+ method, body: body ? JSON.stringify(body) : undefined,
675
+ headers: body ? { "content-type": "application/json" } : undefined,
676
+ }), { path: p.split("/").filter(Boolean), searchParams: new URLSearchParams(qs || "") }, env);
677
+ };
678
+ const preview = await (await call("POST", "/fork-preview", { session: "s1", entryIds: ["u2", "u3"] })).json();
679
+ assert.deepEqual(preview.ops.map((o) => o.op), [2, 3]);
680
+
681
+ // 用户在轮3之后手改了 c.txt(模拟手改,撤销时须保留)
682
+ writeFileSync(join(repo, "c.txt"), "user-edit\n");
683
+ const userHead = await runGit(store, ["add", "-A"]);
684
+ void userHead; // c.txt 加入 index(仅用于制造工作树非干净态;不 commit,仍在工作树)
685
+
686
+ // 撤销:对 op2,3 收集 refs
687
+ const refs = preview.ops.flatMap((o) => (o.stores || []).filter((s) => s.before).map((s) => ({ op: o.op, before: s.before, after: s.after })));
688
+ const rb = await (await call("POST", "/rollback-reversed", { storeRoot: repo, sessionId: "s1", refs })).json();
689
+ assert.equal(rb.failed, null);
690
+ assert.equal(rb.applied, 2);
691
+ // 只撤被裁轮:a.txt 回到轮1的 a1(未裁轮变更保留),b.txt 回到 b0(轮3被裁),c.txt 手改保留
692
+ assert.equal(readFileSync(join(repo, "a.txt"), "utf8"), "a1\n");
693
+ assert.equal(readFileSync(join(repo, "b.txt"), "utf8"), "b0\n");
694
+ assert.equal(readFileSync(join(repo, "c.txt"), "utf8"), "user-edit\n");
695
+ // 时间线读取:最后一行是回撤记账(kind rollback, scope reverse-ops)
696
+ const ops = readOps(configDir, "s1");
697
+ const last = ops[ops.length - 1];
698
+ assert.equal(last.kind, "rollback");
699
+ assert.deepEqual(last.rollback.targetOps, [2, 3]);
700
+ });
701
+
702
+ // ---------- 变更总览(docs/aiti-git-trace-overview-view-plan.md §7 全口径) ----------
703
+
704
+ /** 总览测试仓:b0 →(op1: a.txt v0→v1) a1 →(op2: a.txt v1→v2 + c.txt 新建 + hand.txt 手改不入账) a2 */
705
+ async function makeOverviewRepo() {
706
+ const repo = makeUserRepo("ov", { "a.txt": "v0\nline2\n", "b.txt": "keep\n" });
707
+ await initStore(repo);
708
+ const store = storeOf(repo, "s1");
709
+ const b0 = await commitOnBranch(store, "aiti baseline");
710
+ writeFileSync(join(repo, "a.txt"), "v1\nline2\n");
711
+ const a1 = await commitOnBranch(store, "aiti op1 after");
712
+ writeFileSync(join(repo, "a.txt"), "v2\nline2\n");
713
+ writeFileSync(join(repo, "c.txt"), "c1\nc2\nc3\n");
714
+ writeFileSync(join(repo, "hand.txt"), "hand-edit\n"); // 交集口径(R1):台账外路径
715
+ const a2 = await commitOnBranch(store, "aiti op2 after");
716
+ return { repo, store, b0, a1, a2 };
717
+ }
718
+
719
+ function appendOpRow(configDir, sid, row) {
720
+ appendOps(configDir, sid, {
721
+ entryId: "e", userEntryId: "u", msgSeq: 1, tool: "edit", summary: "s",
722
+ t0: 1000, t1: 1100, cwd: "/x", leaks: [], ...row,
723
+ });
724
+ }
725
+
726
+ // ---------- /ops 解析缓存 + ETag 再校验(§11.3 性能审计落地) ----------
727
+
728
+ test("readOpsWithMeta: mtime+size 命中返回同一解析实例;追加/缩减失效重读;readOps 浅拷贝隔离", () => {
729
+ const configDir = freshDir("ops-cache");
730
+ appendOpRow(configDir, "s1", { op: 1, entryId: "e1" });
731
+ const a = readOpsWithMeta(configDir, "s1");
732
+ assert.ok(a.etag && /^W\/"\d+-\d+"$/.test(a.etag), "etag 形如 W/\"size-mtimeMs\": " + a.etag);
733
+ const b = readOpsWithMeta(configDir, "s1");
734
+ assert.equal(b.rows, a.rows, "未变更 → 缓存命中(同一解析数组实例)");
735
+ assert.equal(b.etag, a.etag);
736
+ // 追加一行 → size/mtime 变 → 重读且含新行
737
+ appendOpRow(configDir, "s1", { op: 2, entryId: "e2" });
738
+ const c = readOpsWithMeta(configDir, "s1");
739
+ assert.notEqual(c.rows, a.rows, "变更后为新解析实例");
740
+ assert.equal(c.rows.length, 2);
741
+ assert.notEqual(c.etag, a.etag);
742
+ // 缩减(清理场景)→ 重读
743
+ const f = opsFile(configDir, "s1");
744
+ writeFileSync(f, readFileSync(f, "utf8").split("\n")[0] + "\n");
745
+ const d = readOpsWithMeta(configDir, "s1");
746
+ assert.equal(d.rows.length, 1);
747
+ // readOps 公开面 = 浅拷贝(两次调用不同实例、与缓存实例不同),内容一致
748
+ const e1 = readOps(configDir, "s1");
749
+ const e2 = readOps(configDir, "s1");
750
+ assert.notEqual(e1, e2);
751
+ assert.deepEqual(e1, e2);
752
+ // 不存在的会话 → 空行 + 无 etag
753
+ assert.deepEqual(readOpsWithMeta(configDir, "nope"), { rows: [], etag: null });
754
+ });
755
+
756
+ test("api: GET /ops 带 ETag;If-None-Match 未变 → 304 零体;变更后 200 + 新 etag", async () => {
757
+ const configDir = freshDir("ops-etag");
758
+ appendOpRow(configDir, "s1", { op: 1, entryId: "e1" });
759
+ const env = { configDir, dir: extDir, allowRoot: async () => true };
760
+ const call = (path, headers) => handleApi(new Request("http://x" + path, { headers }), { path: path.split("?")[0].split("/").filter(Boolean), searchParams: new URLSearchParams(path.split("?")[1] || "") }, env);
761
+ // 首取 200 + etag
762
+ const r1 = await call("/ops?session=s1");
763
+ assert.equal(r1.status, 200);
764
+ const etag = r1.headers.get("etag");
765
+ assert.ok(etag, "200 响应带 etag");
766
+ const body1 = await r1.json();
767
+ assert.equal(body1.ops.length, 1);
768
+ // 未变 + If-None-Match → 304 空体
769
+ const r2 = await call("/ops?session=s1", { "if-none-match": etag });
770
+ assert.equal(r2.status, 304);
771
+ assert.equal(await r2.text(), "");
772
+ // etag 不匹配 → 200 全量
773
+ const r3 = await call("/ops?session=s1", { "if-none-match": 'W/"1-1"' });
774
+ assert.equal(r3.status, 200);
775
+ // 台账变更后同 etag → 200 新 etag 新内容
776
+ appendOpRow(configDir, "s1", { op: 2, entryId: "e2" });
777
+ const r4 = await call("/ops?session=s1", { "if-none-match": etag });
778
+ assert.equal(r4.status, 200);
779
+ assert.notEqual(r4.headers.get("etag"), etag);
780
+ assert.equal((await r4.json()).ops.length, 2);
781
+ });
782
+
783
+ test("sessionFileSummary: 普通聚合 + 交集口径(台账外路径不进列表)+ net ±行数", async () => {
784
+ const { repo, b0, a1, a2 } = await makeOverviewRepo();
785
+ const configDir = freshDir("ov-basic");
786
+ appendOpRow(configDir, "s1", { op: 1, entryId: "e1", userEntryId: "u1", msgSeq: 1, stores: [{ root: repo, before: b0, after: a1, files: { M: 1, A: 0, D: 0 }, paths: ["a.txt"], partial: false }] });
787
+ appendOpRow(configDir, "s1", { op: 2, entryId: "e2", userEntryId: "u2", msgSeq: 2, t0: 2000, t1: 2100, leaks: [{ path: "/tmp/leak", size: 1, kind: "create" }], stores: [{ root: repo, before: a1, after: a2, files: { M: 1, A: 1, D: 0 }, paths: ["a.txt", "c.txt"], partial: false }] });
788
+
789
+ const r = await sessionFileSummary(configDir, "s1", []);
790
+ assert.equal(r.stores.length, 1);
791
+ assert.equal(r.stores[0].root, repo);
792
+ assert.equal(r.stores[0].base, b0);
793
+ assert.equal(r.stores[0].target, a2);
794
+ assert.ok(!r.stores[0].storeMissing && !r.stores[0].rejected);
795
+ const paths = r.files.map((f) => f.path).sort();
796
+ assert.deepEqual(paths, ["a.txt", "c.txt"]); // hand.txt 是区间 diff 的一部分但台账未触达 → 交集口径剔除
797
+ const a = r.files.find((f) => f.path === "a.txt");
798
+ assert.equal(a.status, "M");
799
+ assert.equal(a.touches.length, 2); // op1 + op2
800
+ assert.equal(a.touches[0].kind, "op");
801
+ assert.equal(a.touches[1].op, 2);
802
+ assert.equal(a.lastT, 2100);
803
+ const c = r.files.find((f) => f.path === "c.txt");
804
+ assert.equal(c.status, "A");
805
+ assert.equal(c.adds, 3);
806
+ // 区间净变更:a.txt v0→v2 = +1 −1;c.txt 新建 = +3(hand.txt 剔除后不计)
807
+ assert.equal(r.totals.adds, 4);
808
+ assert.equal(r.totals.dels, 1);
809
+ assert.equal(r.totals.ops, 2);
810
+ assert.equal(r.totals.files, 2);
811
+ assert.equal(r.totals.rollbacks, 0);
812
+ assert.equal(r.totals.leaks, 1);
813
+ assert.equal(r.totals.binary, 0);
814
+ assert.equal(r.filesTruncated, false);
815
+ });
816
+
817
+ test("sessionFileSummary: entryIds 轮次过滤(该轮净增量,base=该轮首 op before)", async () => {
818
+ const { repo, a1, a2 } = await makeOverviewRepo();
819
+ const configDir = freshDir("ov-entry");
820
+ appendOpRow(configDir, "s1", { op: 1, entryId: "e1", userEntryId: "u1", stores: [{ root: repo, before: "b0fake", after: a1, files: { M: 1 }, paths: ["a.txt"] }] });
821
+ appendOpRow(configDir, "s1", { op: 2, entryId: "e2", userEntryId: "u2", t0: 2000, t1: 2100, stores: [{ root: repo, before: a1, after: a2, files: { M: 1, A: 1 }, paths: ["a.txt", "c.txt"] }] });
822
+ const r = await sessionFileSummary(configDir, "s1", ["u2"]);
823
+ assert.equal(r.stores[0].base, a1); // 只用命中轮 op 行解析
824
+ assert.equal(r.stores[0].target, a2);
825
+ assert.equal(r.totals.ops, 1);
826
+ assert.deepEqual(r.files.map((f) => f.path).sort(), ["a.txt", "c.txt"]);
827
+ // 旧台账无 userEntryId → 兜底 entryId 匹配
828
+ const dataDir2 = freshDir("ov-entry2");
829
+ appendOpRow(dataDir2, "s1", { op: 1, entryId: "only-e", stores: [{ root: repo, before: "x", after: "y", files: { M: 1 }, paths: ["a.txt"] }] });
830
+ const r2 = await sessionFileSummary(dataDir2, "s1", ["only-e"]);
831
+ assert.equal(r2.totals.ops, 1);
832
+ });
833
+
834
+ test("sessionFileSummary: 首触 fallback(全 gap → base=最早 after;基线快照不产生文件行)", async () => {
835
+ const repo = makeUserRepo("ovgap", { "a.txt": "v0\n" });
836
+ await initStore(repo);
837
+ const store = storeOf(repo, "s2");
838
+ writeFileSync(join(repo, "a.txt"), "v1\n");
839
+ const ga = await commitOnBranch(store, "aiti gap after");
840
+ writeFileSync(join(repo, "b.txt"), "new\n");
841
+ const ga2 = await commitOnBranch(store, "aiti op2 after");
842
+ const configDir = freshDir("ov-gap");
843
+ // op1 = 首触基线(before=null,paths 是全树快照)→ 不产生文件行;op2 正常增量
844
+ appendOpRow(configDir, "s2", { op: 1, stores: [{ root: repo, before: null, after: ga, files: { A: 2 }, paths: ["a.txt"] }] });
845
+ appendOpRow(configDir, "s2", { op: 2, t0: 2000, t1: 2100, stores: [{ root: repo, before: ga, after: ga2, files: { A: 1 }, paths: ["b.txt"] }] });
846
+ const r = await sessionFileSummary(configDir, "s2", []);
847
+ assert.equal(r.stores[0].base, ga); // 全为空 → 最早 after
848
+ assert.equal(r.stores[0].target, ga2);
849
+ assert.deepEqual(r.files.map((f) => f.path), ["b.txt"]); // a.txt 在区间 ga..ga2 无差异 → 不进列表
850
+ assert.equal(r.totals.ops, 1); // 基线快照(before=null)不计入 ops(与徽标口径一致)
851
+ });
852
+
853
+ test("sessionFileSummary: 回撤三 shape 归一(reversed/partial/full)参与 target+rollbacks 不参与 base", async () => {
854
+ const repo = makeUserRepo("ovrb", { "a.txt": "v0\n" });
855
+ await initStore(repo);
856
+ const store = storeOf(repo, "s3");
857
+ const b0 = await commitOnBranch(store, "aiti baseline");
858
+ writeFileSync(join(repo, "a.txt"), "v1\n");
859
+ const a1 = await commitOnBranch(store, "aiti op1 after");
860
+ // 真实 commit 顶 target 解析(resolveCommit 校验)
861
+ const safety1 = await commitOnBranch(store, "aiti phase=safety pre-1");
862
+ const rb1 = await commitOnBranch(store, "aiti phase=rollback r1");
863
+ const c1 = await commitOnBranch(store, "aiti phase=rollback partial");
864
+ const c2 = await commitOnBranch(store, "aiti phase=rollback full");
865
+ const configDir = freshDir("ov-rb");
866
+ appendOpRow(configDir, "s3", { op: 1, stores: [{ root: repo, before: b0, after: a1, files: { M: 1 }, paths: ["a.txt"] }] });
867
+ // shape 1:rollback-reversed(LedgerRow 同构,stores[].before/after,t1)
868
+ appendOps(configDir, "s3", { kind: "rollback", scope: "reverse-ops", tool: "rollback", summary: "回撤 #1", t0: 3000, t1: 3100, op: null, stores: [{ root: repo, before: safety1, after: rb1, files: { M: 1 }, paths: ["a.txt"] }], leaks: [] });
869
+ // shape 2:rollback-partial(扁平 storeRoot/base/target/safety/commit,只有 t)
870
+ appendOps(configDir, "s3", { kind: "rollback", t: 4000, storeRoot: repo, base: a1, target: a1, files: ["a.txt"], safety: safety1, commit: c1, scope: "partial" });
871
+ // shape 3:rollback full(同扁平,无 base/files)
872
+ appendOps(configDir, "s3", { kind: "rollback", t: 5000, storeRoot: repo, target: b0, safety: c1, commit: c2, scope: "full" });
873
+
874
+ const r = await sessionFileSummary(configDir, "s3", []);
875
+ assert.equal(r.stores[0].base, b0); // 回撤行 before=safety 不参与 base
876
+ assert.equal(r.stores[0].target, c2); // 最新(t1 ?? t)回撤 commit 参与 target
877
+ assert.equal(r.totals.rollbacks, 3);
878
+ assert.equal(r.totals.ops, 1);
879
+ const a = r.files.find((f) => f.path === "a.txt");
880
+ assert.ok(a);
881
+ assert.equal(a.touches.filter((x) => x.kind === "rollback").length, 2); // reversed(paths) + partial(files);full 无 paths 不产生触达
882
+ });
883
+
884
+ test("sessionFileSummary: plan-accept/discard 行排除(R3)不带偏 base/target/ops", async () => {
885
+ const { repo, b0, a2 } = await makeOverviewRepo();
886
+ const configDir = freshDir("ov-plan");
887
+ appendOpRow(configDir, "s1", { op: 1, stores: [{ root: repo, before: b0, after: a2, files: { M: 1 }, paths: ["a.txt"] }] });
888
+ // 若未排除,这些行会污染 base/target(假 commit)并多计 ops
889
+ appendOps(configDir, "s1", { kind: "plan-accept", t: 9000, sandbox: "/x", files: ["ghost.md"], summary: "采认" });
890
+ appendOps(configDir, "s1", { kind: "plan-discard", t: 9100, sandbox: "/x", files: [], summary: "丢弃" });
891
+ const r = await sessionFileSummary(configDir, "s1", []);
892
+ assert.equal(r.stores[0].base, b0);
893
+ assert.equal(r.stores[0].target, a2);
894
+ assert.equal(r.totals.ops, 1);
895
+ assert.ok(!r.files.some((f) => f.path === "ghost.md"));
896
+ });
897
+
898
+ test("sessionFileSummary: 跨仓 / storeMissing / allowRoot 拒(rejected 部分返回)", async () => {
899
+ const r1 = await makeOverviewRepo();
900
+ const repo2 = makeUserRepo("ov2", { "d.txt": "d0\n" });
901
+ await initStore(repo2);
902
+ const store2 = storeOf(repo2, "s1");
903
+ const db0 = await commitOnBranch(store2, "aiti baseline");
904
+ writeFileSync(join(repo2, "d.txt"), "d1\n");
905
+ const da1 = await commitOnBranch(store2, "aiti op after");
906
+ const configDir = freshDir("ov-multi");
907
+ appendOpRow(configDir, "s1", { op: 1, stores: [
908
+ { root: r1.repo, before: r1.b0, after: r1.a2, files: { M: 2, A: 1 }, paths: ["a.txt", "c.txt"] },
909
+ { root: repo2, before: db0, after: da1, files: { M: 1 }, paths: ["d.txt"] },
910
+ { root: "/tmp/definitely-no-trace-store-xyz", before: "x", after: "y", files: { M: 1 }, paths: ["z"] },
911
+ ] });
912
+ const r = await sessionFileSummary(configDir, "s1", []);
913
+ assert.equal(r.stores.length, 3);
914
+ const missing = r.stores.find((s) => s.root === "/tmp/definitely-no-trace-store-xyz");
915
+ assert.equal(missing.storeMissing, true); // storeOk 假 → 标注保留(非静默丢)
916
+ assert.deepEqual(r.files.filter((f) => f.root === repo2).map((f) => f.path), ["d.txt"]);
917
+ assert.equal(r.totals.files, 3); // a.txt + c.txt + d.txt
918
+ // allowRoot 拒(Y5)→ rejected 标注 + 该仓不出文件
919
+ const rDeny = await sessionFileSummary(configDir, "s1", [], { allowRoot: async (root) => root === r1.repo });
920
+ const rej = rDeny.stores.find((s) => s.root === repo2);
921
+ assert.equal(rej.rejected, true);
922
+ assert.ok(!rDeny.files.some((f) => f.root === repo2));
923
+ assert.ok(rDeny.files.some((f) => f.root === r1.repo)); // 放行的仓正常返回
924
+ });
925
+
926
+ test("sessionFileSummary: binary / rename(R 三段)/ 改名未检测(D+A 成对)/ 触达截断 / opSet", async () => {
927
+ // rw.txt 在基线存在、a2 被同名内容零相似替换到 rw2.txt → 相似度不足 = D+A 两行(N1)
928
+ const repo = makeUserRepo("ovx", { "a.txt": "v0\n", "old.txt": "same content\nsame2\n", "rw.txt": "aaaa\nbbbb\ncccc\ndddd\n" });
929
+ await initStore(repo);
930
+ const store = storeOf(repo, "s4");
931
+ const b0 = await commitOnBranch(store, "aiti baseline");
932
+ writeFileSync(join(repo, "a.txt"), "v1\n");
933
+ writeFileSync(join(repo, "bin.dat"), Buffer.from([0x00, 0x01, 0x02]));
934
+ const a1 = await commitOnBranch(store, "aiti op1 after");
935
+ // op2 窗口:rename(内容全同 → R100)+ 彻底改写搬家(D+A)+ binary 改动
936
+ renameSync(join(repo, "old.txt"), join(repo, "new.txt"));
937
+ rmSync(join(repo, "rw.txt"));
938
+ writeFileSync(join(repo, "rw2.txt"), "zz1\nzz2\nzz3\nzz4\nzz5\n");
939
+ writeFileSync(join(repo, "bin.dat"), Buffer.from([0x00, 0x01, 0x03]));
940
+ // 触达截断:同文件 55 行台账(复用同一 before/after 对,无需新 commit)
941
+ const a2 = await commitOnBranch(store, "aiti op2 after");
942
+ const configDir = freshDir("ov-x");
943
+ appendOpRow(configDir, "s4", { op: 1, stores: [{ root: repo, before: b0, after: a1, files: { M: 1, A: 1 }, paths: ["a.txt", "bin.dat"] }] });
944
+ appendOpRow(configDir, "s4", { op: 2, t0: 2000, t1: 2100, stores: [{ root: repo, before: a1, after: a2, files: {}, paths: ["new.txt", "rw.txt", "rw2.txt", "bin.dat"] }] });
945
+ for (let i = 3; i <= 57; i++) appendOpRow(configDir, "s4", { op: i, stores: [{ root: repo, before: a1, after: a2, files: {}, paths: ["a.txt"] }] });
946
+
947
+ const r = await sessionFileSummary(configDir, "s4", []);
948
+ const bin = r.files.find((f) => f.path === "bin.dat");
949
+ assert.equal(bin.binary, true); // numstat -\t-
950
+ assert.equal(bin.adds, 0); assert.equal(bin.dels, 0);
951
+ assert.equal(r.totals.binary, 1);
952
+ const ren = r.files.find((f) => f.path === "new.txt");
953
+ assert.equal(ren.status, "R"); // name-status 权威
954
+ assert.equal(ren.renamedFrom, "old.txt");
955
+ assert.equal(ren.adds, 0); assert.equal(ren.dels, 0);
956
+ // 相似度不足的改名 = D+A 成对两行(N1,不合并诚实呈现)
957
+ const del = r.files.find((f) => f.path === "rw.txt");
958
+ const add = r.files.find((f) => f.path === "rw2.txt");
959
+ assert.ok(del && del.status === "D");
960
+ assert.ok(add && add.status === "A");
961
+ // 触达截断:a.txt 55 行台账 → 上限 50
962
+ const a = r.files.find((f) => f.path === "a.txt");
963
+ assert.equal(a.touches.length, 50);
964
+ // opSet 圈定(GET fork-preview 聚合路径)
965
+ const rOps = await sessionFileSummary(configDir, "s4", [], { opSet: new Set([1]) });
966
+ assert.equal(rOps.totals.ops, 1);
967
+ assert.equal(rOps.stores[0].target, a1);
968
+ });
969
+
970
+ test("fileDiffText: 正文/截断/边界(缺 store、坏 ref)", async () => {
971
+ const { repo, b0, a2, a1 } = await makeOverviewRepo();
972
+ const full = await fileDiffText(repo, "s1", b0, a2, "a.txt");
973
+ assert.equal(full.truncated, false);
974
+ assert.ok(full.text.includes("diff --git a/a.txt"));
975
+ assert.ok(full.text.split("\n").some((l) => l.startsWith("+v2")));
976
+ assert.ok(full.text.split("\n").some((l) => l.startsWith("-v0")));
977
+ assert.ok(full.total > 3);
978
+ // 截断保护:maxLines
979
+ const cut = await fileDiffText(repo, "s1", b0, a2, "a.txt", 3);
980
+ assert.equal(cut.truncated, true);
981
+ assert.equal(cut.text.split("\n").length, 3);
982
+ assert.equal(cut.total, full.total);
983
+ // 不限 path = 全树 diff(含 c.txt)
984
+ const tree = await fileDiffText(repo, "s1", b0, a2, "");
985
+ assert.ok(tree.text.includes("c.txt"));
986
+ // 空 diff(区间内该文件无变化)
987
+ const empty = await fileDiffText(repo, "s1", a1, a1, "a.txt");
988
+ assert.equal(empty.text, "");
989
+ // 边界:缺 store / 坏 ref → throw(api 层转 400)
990
+ await assert.rejects(async () => await fileDiffText("/tmp/no-store-xyz", "s1", b0, a2, "a.txt"));
991
+ await assert.rejects(async () => await fileDiffText(repo, "s1", "deadbeef", a2, "a.txt"));
992
+ });
993
+
994
+ // ---------- API:GET /files、GET /file-diff、fork-preview 附带聚合(R4) ----------
995
+
996
+ test("api: GET /files + /file-diff(allowRoot 拒/storeMissing 降级/maxLines 截断)", async () => {
997
+ const { repo, b0, a1, a2 } = await makeOverviewRepo();
998
+ const configDir = freshDir("ov-api");
999
+ appendOpRow(configDir, "s1", { op: 1, entryId: "e1", userEntryId: "u1", stores: [{ root: repo, before: b0, after: a1, files: { M: 1 }, paths: ["a.txt"] }] });
1000
+ appendOpRow(configDir, "s1", { op: 2, entryId: "e2", userEntryId: "u2", t0: 2000, t1: 2100, stores: [{ root: repo, before: a1, after: a2, files: { M: 1, A: 1 }, paths: ["a.txt", "c.txt"] }] });
1001
+ const env = { configDir, dir: extDir, allowRoot: async () => true };
1002
+ const call = (method, path, body) => {
1003
+ const [p, qs] = path.split("?");
1004
+ return handleApi(new Request("http://x" + path, {
1005
+ method, body: body ? JSON.stringify(body) : undefined,
1006
+ headers: body ? { "content-type": "application/json" } : undefined,
1007
+ }), { path: p.split("/").filter(Boolean), searchParams: new URLSearchParams(qs || "") }, env);
1008
+ };
1009
+
1010
+ // /files 正常
1011
+ let r = await (await call("GET", "/files?session=s1")).json();
1012
+ assert.equal(r.ok, true);
1013
+ assert.equal(r.stores[0].base, b0);
1014
+ assert.equal(r.totals.ops, 2);
1015
+ assert.deepEqual(r.files.map((f) => f.path).sort(), ["a.txt", "c.txt"]);
1016
+ // /files entryIds 解析
1017
+ r = await (await call("GET", "/files?session=s1&entryIds=u2")).json();
1018
+ assert.equal(r.totals.ops, 1);
1019
+ assert.deepEqual(r.entryIds, ["u2"]);
1020
+ // /file-diff 正常
1021
+ r = await (await call("GET", `/file-diff?root=${encodeURIComponent(repo)}&session=s1&base=${b0}&target=${a2}&path=a.txt`)).json();
1022
+ assert.equal(r.ok, true);
1023
+ assert.ok(r.text.includes("a.txt"));
1024
+ assert.equal(r.truncated, false);
1025
+ // /file-diff maxLines 截断
1026
+ r = await (await call("GET", `/file-diff?root=${encodeURIComponent(repo)}&session=s1&base=${b0}&target=${a2}&path=a.txt&maxLines=3`)).json();
1027
+ assert.equal(r.truncated, true);
1028
+ assert.equal(r.text.split("\n").length, 3);
1029
+ // /file-diff 缺 store → 400
1030
+ r = await (await call("GET", `/file-diff?root=/tmp/no-store&session=s1&base=${b0}&target=${a2}&path=a.txt`)).json();
1031
+ assert.equal(r.ok, false);
1032
+ // /file-diff 坏 ref → 400
1033
+ r = await (await call("GET", `/file-diff?root=${encodeURIComponent(repo)}&session=s1&base=deadbeef&target=${a2}`)).json();
1034
+ assert.equal(r.ok, false);
1035
+ // /file-diff 缺 base/target → 400
1036
+ r = await (await call("GET", `/file-diff?root=${encodeURIComponent(repo)}&session=s1`)).json();
1037
+ assert.equal(r.ok, false);
1038
+ // /files + /file-diff allowRoot 拒:/files 部分返回 rejected;/file-diff 403
1039
+ const envDeny = { configDir, dir: "", allowRoot: async () => false };
1040
+ r = await (await handleApi(new Request("http://x/files?session=s1"), { path: ["files"], searchParams: new URLSearchParams("session=s1") }, envDeny)).json();
1041
+ assert.equal(r.ok, true);
1042
+ assert.equal(r.stores[0].rejected, true);
1043
+ assert.deepEqual(r.files, []);
1044
+ r = await (await handleApi(new Request("http://x/file-diff"), { path: ["file-diff"], searchParams: new URLSearchParams(`root=${encodeURIComponent(repo)}&session=s1&base=${b0}&target=${a2}`) }, envDeny)).json();
1045
+ assert.equal(r.ok, false);
1046
+ });
1047
+
1048
+ test("api: fork-preview 响应附带 files/totals 聚合(R4,POST entryIds + GET opSet 双路)", async () => {
1049
+ const { repo, b0, a1, a2 } = await makeOverviewRepo();
1050
+ const configDir = freshDir("ov-fork");
1051
+ appendOpRow(configDir, "s1", { op: 1, entryId: "e1", userEntryId: "u1", stores: [{ root: repo, before: b0, after: a1, files: { M: 1 }, paths: ["a.txt"] }] });
1052
+ appendOpRow(configDir, "s1", { op: 2, entryId: "e2", userEntryId: "u2", t0: 2000, t1: 2100, stores: [{ root: repo, before: a1, after: a2, files: { M: 1, A: 1 }, paths: ["a.txt", "c.txt"] }] });
1053
+ const env = { configDir, dir: extDir, allowRoot: async () => true };
1054
+ const call = (method, path, body) => {
1055
+ const [p, qs] = path.split("?");
1056
+ return handleApi(new Request("http://x" + path, {
1057
+ method, body: body ? JSON.stringify(body) : undefined,
1058
+ headers: body ? { "content-type": "application/json" } : undefined,
1059
+ }), { path: p.split("/").filter(Boolean), searchParams: new URLSearchParams(qs || "") }, env);
1060
+ };
1061
+ // POST entryIds:聚合与轮次同圈(u2 轮)
1062
+ let r = await (await call("POST", "/fork-preview", { session: "s1", entryIds: ["u2"] })).json();
1063
+ assert.equal(r.ok, true);
1064
+ assert.deepEqual(r.ops.map((o) => o.op), [2]);
1065
+ assert.equal(r.totals.ops, 1);
1066
+ assert.deepEqual(r.files.map((f) => f.path).sort(), ["a.txt", "c.txt"]);
1067
+ // GET entryId:fork 点后 ops 圈定(e1 之后 = op2)
1068
+ r = await (await call("GET", "/fork-preview?session=s1&entryId=e1")).json();
1069
+ assert.equal(r.ok, true);
1070
+ assert.deepEqual(r.ops.map((o) => o.op), [2]);
1071
+ assert.equal(r.totals.ops, 1);
1072
+ assert.ok(Array.isArray(r.files));
1073
+ });
1074
+
1075
+
1076
+ // ── SSE /events 退订双钩(2026-09-15 断连泄漏回归)────────────────────────
1077
+ // 客户端静默断开可能不触发 ReadableStream.cancel——req.signal abort 钩必须兜住,
1078
+ // 否则 op 监听器 + 15s ping 永久留在 traceBus(MaxListenersExceeded 实证)。
1079
+ function sseCall(env, signal) {
1080
+ return handleApi(
1081
+ new Request("http://x/events", { signal }),
1082
+ { path: ["events"], searchParams: new URLSearchParams() },
1083
+ env
1084
+ );
1085
+ }
1086
+ const tick = () => new Promise((r) => setImmediate(r));
1087
+
1088
+ test("api: SSE /events req.signal abort → 退订 op 监听(断开泄漏回归)", async () => {
1089
+ const ac = new AbortController();
1090
+ let live = 0;
1091
+ const env = {
1092
+ configDir: freshDir("sse-abort"), dir: extDir,
1093
+ onTraceOp: (l) => { live++; return () => { live--; }; },
1094
+ };
1095
+ const res = await sseCall(env, ac.signal);
1096
+ assert.equal(res.status, 200);
1097
+ assert.match(res.headers.get("content-type") || "", /text\/event-stream/);
1098
+ const reader = res.body.getReader();
1099
+ await reader.read(); // ": connected\n\n"(start 已跑、监听已挂)
1100
+ assert.equal(live, 1);
1101
+ ac.abort();
1102
+ await tick();
1103
+ assert.equal(live, 0, "abort 后监听必须退订");
1104
+ try { await reader.cancel(); } catch { /* 流已关闭 */ }
1105
+ });
1106
+
1107
+ test("api: SSE /events reader.cancel → 退订 op 监听(正常关闭路)", async () => {
1108
+ let live = 0;
1109
+ const env = {
1110
+ configDir: freshDir("sse-cancel"), dir: extDir,
1111
+ onTraceOp: (l) => { live++; return () => { live--; }; },
1112
+ };
1113
+ const res = await sseCall(env, undefined);
1114
+ const reader = res.body.getReader();
1115
+ await reader.read();
1116
+ assert.equal(live, 1);
1117
+ await reader.cancel();
1118
+ await tick();
1119
+ assert.equal(live, 0, "cancel 后监听必须退订");
1120
+ });