@modusensus/dsh-mneme 0.9.0 → 0.9.1

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 (88) hide show
  1. package/README.md +78 -16
  2. package/bin/cli.mjs +8 -2
  3. package/docs/CONFIGURATION.md +25 -0
  4. package/docs/SEMANTIC.md +3 -3
  5. package/lib/api-standalone.js +78 -12
  6. package/lib/api.js +282 -57
  7. package/lib/candidates.js +454 -454
  8. package/lib/client.js +598 -248
  9. package/lib/config.js +33 -2
  10. package/lib/embedding.js +116 -15
  11. package/lib/index.js +32 -0
  12. package/lib/inject.js +34 -3
  13. package/lib/lang.js +124 -0
  14. package/lib/pipeline-activities.js +178 -0
  15. package/lib/pipeline-attribution.js +165 -0
  16. package/lib/pipeline-command.js +279 -0
  17. package/lib/pipeline-confirm.js +142 -0
  18. package/lib/pipeline-exec.js +174 -0
  19. package/lib/pipeline-nudge.js +1281 -0
  20. package/lib/pipeline-observations.js +181 -0
  21. package/lib/pipeline-observe.js +133 -0
  22. package/lib/pipeline-runner.js +346 -0
  23. package/lib/pipeline-runs.js +442 -0
  24. package/lib/pipeline.js +128 -7
  25. package/lib/semantic.js +66 -10
  26. package/lib/settings.js +132 -1
  27. package/lib/store.js +77 -3
  28. package/lib/summarize.js +2 -3
  29. package/package.json +3 -3
  30. package/scripts/changelog-prep.mjs +62 -7
  31. package/scripts/check-sync.js +8 -6
  32. package/scripts/release-docs.mjs +204 -0
  33. package/scripts/release-notes.mjs +162 -0
  34. package/src/api-standalone.js +78 -12
  35. package/src/api.js +282 -57
  36. package/src/candidates.js +454 -454
  37. package/src/config.js +33 -2
  38. package/src/embedding.js +116 -15
  39. package/src/index.js +32 -0
  40. package/src/inject.js +34 -3
  41. package/src/lang.js +124 -0
  42. package/src/pipeline-activities.js +178 -0
  43. package/src/pipeline-attribution.js +165 -0
  44. package/src/pipeline-command.js +279 -0
  45. package/src/pipeline-confirm.js +142 -0
  46. package/src/pipeline-exec.js +174 -0
  47. package/src/pipeline-nudge.js +1281 -0
  48. package/src/pipeline-observations.js +181 -0
  49. package/src/pipeline-observe.js +133 -0
  50. package/src/pipeline-runner.js +346 -0
  51. package/src/pipeline-runs.js +442 -0
  52. package/src/pipeline.js +128 -7
  53. package/src/semantic.js +66 -10
  54. package/src/settings.js +132 -1
  55. package/src/store.js +77 -3
  56. package/src/summarize.js +2 -3
  57. package/test/api-hardening.test.js +173 -0
  58. package/test/api.test.js +98 -1
  59. package/test/changelog-prep.test.js +74 -8
  60. package/test/cli-enums.test.js +37 -0
  61. package/test/client.test.js +152 -3
  62. package/test/docs-config-keys.test.js +65 -0
  63. package/test/helpers/write-admission-samples.js +6 -2
  64. package/test/lib-smoke.test.js +20 -0
  65. package/test/persona-api.test.js +172 -0
  66. package/test/persona-flag.test.js +36 -0
  67. package/test/persona-inject.test.js +145 -0
  68. package/test/persona-settings.test.js +145 -0
  69. package/test/pipeline-activities.test.js +155 -0
  70. package/test/pipeline-attribution.test.js +175 -0
  71. package/test/pipeline-command.test.js +243 -0
  72. package/test/pipeline-confirm.test.js +148 -0
  73. package/test/pipeline-exec.test.js +172 -0
  74. package/test/pipeline-nudge.test.js +1036 -0
  75. package/test/pipeline-observations.test.js +157 -0
  76. package/test/pipeline-observe.test.js +127 -0
  77. package/test/pipeline-r1r2.test.js +149 -0
  78. package/test/pipeline-runner.test.js +442 -0
  79. package/test/pipeline-runs.test.js +767 -0
  80. package/test/pipeline.test.js +137 -6
  81. package/test/reindex-backfill.test.js +83 -4
  82. package/test/release-docs.test.js +130 -0
  83. package/test/release-notes.test.js +240 -0
  84. package/test/release-workflow.test.js +16 -0
  85. package/test/route-policy.test.js +113 -0
  86. package/test/security-doc.test.js +139 -0
  87. package/test/sensitive-scan.test.js +44 -0
  88. package/test/workflow-guards.test.js +156 -0
@@ -0,0 +1,767 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { readPipelineDoc } from "../src/pipeline.js";
8
+ import {
9
+ RUNS_SUFFIX,
10
+ RUNS_STATES,
11
+ RunError,
12
+ runsDir,
13
+ runsPath,
14
+ readRuns,
15
+ appendRun,
16
+ normalizeRun,
17
+ loadRunsById
18
+ } from "../src/pipeline-runs.js";
19
+ import { createStore } from "../src/store.js";
20
+ import { createService } from "../src/service.js";
21
+ import { createSettings } from "../src/settings.js";
22
+ import { createApi } from "../src/api.js";
23
+
24
+ // 这组测试守的是 M.O. 执行链的**第一步**:把「跑过一次」真的写下来,并让读侧读得回来。
25
+ //
26
+ // 为什么单独一个文件:读侧(src/pipeline.js)的契约已冻结,这里守的是它与盘面的接缝
27
+ // ——落点在哪、坏行怎么报、写进去的东西能不能被 tallyRuns 数出来。接缝坏掉时读侧
28
+ // 一切照旧地绿,只有面板上「所有节点都是 candidate」这一个症状,最容易查不出来。
29
+ //
30
+ // 很大一部分用例在守**读盘路径**:账本是别人写的(手写、历史、别的写入方),
31
+ // 读侧最大的风险不是读不出来,而是**替它补一个含义**——补出来的正面证据,面板会当真。
32
+
33
+ const here = dirname(fileURLToPath(import.meta.url));
34
+
35
+ function withDir(fn) {
36
+ const dir = mkdtempSync(join(tmpdir(), "mneme-runs-"));
37
+ try {
38
+ return fn(dir);
39
+ } finally {
40
+ rmSync(dir, { recursive: true, force: true });
41
+ }
42
+ }
43
+
44
+ // 与 test/pipeline.test.js 同款的最小工作流:两条步骤,引用自洽,够验投影。
45
+ function pipelineFile(steps) {
46
+ return JSON.stringify({
47
+ id: "demo",
48
+ name: "演示",
49
+ project: "someone/repo",
50
+ version: 0,
51
+ status: "active",
52
+ done: "做完",
53
+ trigger: [{ kind: "phrase", value: "跑一下" }],
54
+ steps:
55
+ steps ||
56
+ [
57
+ {
58
+ id: "A1",
59
+ phase: "准备",
60
+ title: "第一步",
61
+ when: "有人要跑",
62
+ do: "做一件事",
63
+ who: "main",
64
+ tool: "true",
65
+ done: "这件事做完了",
66
+ risk: "R0",
67
+ needs: [],
68
+ why: "没有它后面没得跑",
69
+ evidence: ["上次没有它,跑到一半才发现前置没做"]
70
+ },
71
+ {
72
+ id: "A2",
73
+ phase: "收尾",
74
+ title: "第二步",
75
+ when: "第一步做完",
76
+ do: "做另一件事",
77
+ who: "main",
78
+ tool: "true",
79
+ done: "这件事也做完了",
80
+ risk: "R0",
81
+ needs: ["A1"],
82
+ why: "没有它没人知道跑完了",
83
+ evidence: ["上次漏了它,以为没跑完"]
84
+ }
85
+ ]
86
+ });
87
+ }
88
+
89
+ function seededProject(root, { steps = null } = {}) {
90
+ const dir = join(root, ".mneme", "pipelines");
91
+ mkdirSync(dir, { recursive: true });
92
+ writeFileSync(join(dir, "demo.json"), pipelineFile(steps), "utf8");
93
+ return dir;
94
+ }
95
+
96
+ /** 一条**完整**的账本行。手写账也得完整:读盘路径不替它补缺的布尔。 */
97
+ function ledgerLine({ at, by = "main", triggeredBy = "", steps }) {
98
+ return JSON.stringify({
99
+ at,
100
+ by,
101
+ triggeredBy,
102
+ steps: steps.map((s) => ({ skipped: false, detoured: false, refuted: false, note: "", ...s }))
103
+ });
104
+ }
105
+
106
+ function writeLedger(dir, id, lines, { trailingNewline = true } = {}) {
107
+ writeFileSync(join(dir, `${id}${RUNS_SUFFIX}`), lines.join("\n") + (trailingNewline ? "\n" : ""), "utf8");
108
+ }
109
+
110
+ function goodRun(overrides = {}) {
111
+ return {
112
+ at: "2026-10-15T09:00:00.000Z",
113
+ by: "main",
114
+ triggeredBy: "跑一下",
115
+ steps: [{ id: "A1", at: "2026-10-15T09:00:01.000Z" }],
116
+ ...overrides
117
+ };
118
+ }
119
+
120
+ class FakeRes {
121
+ constructor() {
122
+ this.statusCode = 200;
123
+ this.body = "";
124
+ }
125
+ writeHead(code) {
126
+ this.statusCode = code;
127
+ return this;
128
+ }
129
+ end(text) {
130
+ this.body = text ?? "";
131
+ return this;
132
+ }
133
+ }
134
+
135
+ function buildApi() {
136
+ const store = createStore(":memory:");
137
+ const service = createService({ store, mirror: null, config: {} });
138
+ const settings = createSettings(store.db);
139
+ const routes = [];
140
+ const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
141
+ createApi(ctx, service, settings, { add() {}, remove() {}, list: () => [] }, null, undefined, "");
142
+ return routes.find((r) => r.path === "/api/dsh-mneme/pipeline");
143
+ }
144
+
145
+ /** 走真路由读一次面板数据;返回 { status, doc }。 */
146
+ function apiDoc(root, { id = null } = {}) {
147
+ const query = new URLSearchParams();
148
+ if (id !== null) query.set("id", id);
149
+ if (root !== null) query.set("project", root);
150
+ const res = new FakeRes();
151
+ buildApi().handler({ url: `/api/dsh-mneme/pipeline?${query}`, method: "GET", headers: {} }, res);
152
+ return { status: res.statusCode, doc: JSON.parse(res.body) };
153
+ }
154
+
155
+ // ── 落点:与人手写的 .json 分开 ────────────────────────────────────────────────
156
+
157
+ test("落点是 <项目>/.mneme/pipelines/<id>.runs.jsonl,与手写的 .json 分开", () => {
158
+ // 分开是刻意的:.json 是人手写、要 review、要提交的;.runs.jsonl 是每次都追加的
159
+ // 运行账。同住一个文件的话,一次 run 就会把人的 diff 冲掉。
160
+ const root = join(tmpdir(), "proj");
161
+ const p = runsPath(root, "demo");
162
+ assert.equal(p, join(root, ".mneme", "pipelines", `demo${RUNS_SUFFIX}`));
163
+ assert.equal(p === join(root, ".mneme", "pipelines", "demo.json"), false);
164
+ assert.equal(runsDir(root), dirname(p));
165
+ });
166
+
167
+ test("id 不是文件名,是**一个**文件名:路径穿越与绝对路径都拒掉", () => {
168
+ // 拒而不是清洗:清洗过的 id 会指向一个用户没指定的文件,那是更难查的一类错。
169
+ const root = join(tmpdir(), "proj");
170
+ assert.throws(() => runsPath(root, "../../etc/passwd"), (e) => e.name === "RunError");
171
+ assert.throws(() => runsPath(root, "a/b"), (e) => e.name === "RunError");
172
+ assert.throws(() => runsPath(root, "a\\b"), (e) => e.name === "RunError");
173
+ assert.throws(() => runsPath(root, ".."), (e) => e.name === "RunError");
174
+ assert.throws(() => runsPath(root, ""), (e) => e.name === "RunError");
175
+ assert.throws(() => runsPath(root, null), (e) => e.name === "RunError");
176
+ });
177
+
178
+ // ── 读侧:空是实话,坏行报噪(但不再判死整份账本)────────────────────────────
179
+
180
+ test("状态词表是封闭的:absent / empty / ok / partial / broken", () => {
181
+ // 锁住词表:客户端按这五个值分支,多一个少一个都要有人先改这里。
182
+ assert.deepEqual([...RUNS_STATES], ["absent", "empty", "ok", "partial", "broken"]);
183
+ });
184
+
185
+ test("目录不存在、文件不存在 → absent;空文件 → empty:两者必须分得开", () => {
186
+ // 「没人写过账」与「有人碰过这个文件但还没写进一次运行」在节点状态上都表现为
187
+ // 全体 candidate;面板要能说出是哪一种,就不能把它们压成一个值。
188
+ withDir((root) => {
189
+ const missing = readRuns(root, "demo");
190
+ assert.deepEqual(missing.runs, []);
191
+ assert.deepEqual(missing.problems, []);
192
+ assert.equal(missing.state, "absent");
193
+
194
+ const dir = seededProject(root);
195
+ writeFileSync(join(dir, `demo${RUNS_SUFFIX}`), "", "utf8");
196
+ const blank = readRuns(root, "demo");
197
+ assert.deepEqual(blank.runs, []);
198
+ assert.deepEqual(blank.problems, []);
199
+ assert.equal(blank.state, "empty");
200
+
201
+ // 只有空白字符的账本同样算「空的」:它没有一行数据。
202
+ writeFileSync(join(dir, `demo${RUNS_SUFFIX}`), "\n \n", "utf8");
203
+ assert.equal(readRuns(root, "demo").state, "empty");
204
+ });
205
+ });
206
+
207
+ test("中间一行坏掉:报噪 + 跳过那一行,其余照收(不再判死整份账本)", () => {
208
+ // 一次坏行就让全部历史永久读不出来,代价与故障严重程度不成比例;
209
+ // 但**报噪不能省**:坏行进 problems、状态降成 partial,端点再带成 runsErrors。
210
+ withDir((root) => {
211
+ const dir = seededProject(root);
212
+ writeLedger(dir, "demo", [
213
+ ledgerLine({ at: "2026-10-13T09:00:00.000Z", steps: [{ id: "A1", at: "2026-10-13T09:00:01.000Z" }] }),
214
+ "{ 半个对象",
215
+ ledgerLine({ at: "2026-10-15T09:00:00.000Z", steps: [{ id: "A1", at: "2026-10-15T09:00:01.000Z" }] })
216
+ ]);
217
+
218
+ const report = readRuns(root, "demo");
219
+ assert.equal(report.runs.length, 2, "好的两行要读出来");
220
+ assert.equal(report.problems.length, 1);
221
+ assert.equal(report.problems[0].line, 2, "要指到第几行");
222
+ assert.equal(report.problems[0].truncated, false, "中间那行不是截断,是垃圾");
223
+ assert.equal(report.state, "partial");
224
+ });
225
+ });
226
+
227
+ test("末行被截断(不以换行结尾):读回 N-1 条 + problems 非空", () => {
228
+ withDir((root) => {
229
+ const dir = seededProject(root);
230
+ writeLedger(
231
+ dir,
232
+ "demo",
233
+ [
234
+ ledgerLine({ at: "2026-10-13T09:00:00.000Z", steps: [{ id: "A1", at: "2026-10-13T09:00:01.000Z" }] }),
235
+ ledgerLine({ at: "2026-10-14T09:00:00.000Z", steps: [{ id: "A1", at: "2026-10-14T09:00:01.000Z" }] }),
236
+ '{"at":"2026-10-15T09:00:00.000Z","by":"main","steps":[{"id":"A1","at":"2026-10-1'
237
+ ],
238
+ { trailingNewline: false }
239
+ );
240
+
241
+ const report = readRuns(root, "demo");
242
+ assert.equal(report.runs.length, 2, "截断那一行之外的两条要在");
243
+ assert.equal(report.problems.length, 1);
244
+ assert.equal(report.problems[0].line, 3);
245
+ assert.equal(report.problems[0].truncated, true, "可判定的截断:末行 + 文件不以换行结尾");
246
+ assert.equal(report.state, "partial");
247
+ });
248
+ });
249
+
250
+ test("截断过的账本仍能继续追加:写成功不会让读永久失败", () => {
251
+ // 这是「写侧报成功、读侧永久抛」的真实危害:一次断电之后账本不仅读不出来,
252
+ // 还会因为随后的合法追加而**永远**读不出来(坏行成了中间行)。
253
+ withDir((root) => {
254
+ const dir = seededProject(root);
255
+ writeLedger(
256
+ dir,
257
+ "demo",
258
+ [
259
+ ledgerLine({ at: "2026-10-14T09:00:00.000Z", steps: [{ id: "A1", at: "2026-10-14T09:00:01.000Z" }] }),
260
+ '{"at":"2026-10-15T09:00:00.0'
261
+ ],
262
+ { trailingNewline: false }
263
+ );
264
+
265
+ appendRun(root, "demo", goodRun({ at: "2026-10-15T10:00:00.000Z" }));
266
+
267
+ const report = readRuns(root, "demo");
268
+ assert.equal(report.runs.length, 2, "旧的好行 + 新追加的那条");
269
+ assert.equal(report.problems.length, 1, "坏行仍然被报出来");
270
+ assert.equal(report.state, "partial");
271
+ assert.equal(report.runs[1].at, "2026-10-15T10:00:00.000Z", "新那条没和坏行粘在一起");
272
+ });
273
+ });
274
+
275
+ test("尾部空行不算一行账;BOM 不算数据(两种可修复的读不出来)", () => {
276
+ // 多按一次回车、编辑器自作主张加 BOM:两者都会让「整份账本读不出来」,
277
+ // 而它们都不是账本的内容——修在读侧,比让人拿十六进制编辑器去救便宜。
278
+ withDir((root) => {
279
+ const dir = seededProject(root);
280
+ const line = ledgerLine({ at: "2026-10-15T09:00:00.000Z", steps: [{ id: "A1", at: "2026-10-15T09:00:01.000Z" }] });
281
+
282
+ writeFileSync(join(dir, `demo${RUNS_SUFFIX}`), `${line}\n\n\n`, "utf8");
283
+ assert.equal(readRuns(root, "demo").state, "ok", "尾部空行不该算坏行");
284
+
285
+ writeFileSync(join(dir, `demo${RUNS_SUFFIX}`), `\ufeff${line}\n`, "utf8");
286
+ const bom = readRuns(root, "demo");
287
+ assert.equal(bom.state, "ok", "BOM 不该让第一行解析失败");
288
+ assert.equal(bom.runs.length, 1);
289
+ });
290
+ });
291
+
292
+ test("形状不对的行照样报噪:by 不在 ACTORS / steps 空 / step id 重复", () => {
293
+ withDir((root) => {
294
+ const dir = seededProject(root);
295
+ const step = { id: "A1", at: "2026-10-15T09:00:01.000Z" };
296
+
297
+ writeLedger(dir, "demo", [ledgerLine({ at: "2026-10-15T09:00:00.000Z", by: "robot", steps: [step] })]);
298
+ assert.match(readRuns(root, "demo").problems[0].detail, /by/);
299
+
300
+ writeLedger(dir, "demo", [JSON.stringify({ at: "2026-10-15T09:00:00.000Z", by: "main", steps: [] })]);
301
+ assert.match(readRuns(root, "demo").problems[0].detail, /steps/);
302
+
303
+ // 同一条 run 里同一个 step 出现两次:tallyRuns 会数两次,使用次数虚高。
304
+ writeLedger(dir, "demo", [
305
+ ledgerLine({ at: "2026-10-15T09:00:00.000Z", steps: [step, { ...step, at: "2026-10-15T09:00:02.000Z" }] })
306
+ ]);
307
+ assert.match(readRuns(root, "demo").problems[0].detail, /两次/);
308
+ });
309
+ });
310
+
311
+ test("读盘缺布尔就算缺:只有 {id, at} 的行绝不能被算作一次使用", () => {
312
+ // 回归锁,指向一个「读侧在制造正面证据」的缺陷:补 `false` 让 tallyRuns 认为
313
+ // 「既没跳过也没绕行」→ uses +1 → 节点由 candidate 变 trusted。
314
+ // 账本没说话,面板却在作证;这比读不出来严重得多。
315
+ withDir((root) => {
316
+ const dir = seededProject(root);
317
+ writeLedger(dir, "demo", [
318
+ JSON.stringify({ at: "2026-10-15T09:00:00.000Z", by: "main", steps: [{ id: "A1", at: "2026-10-15T09:00:01.000Z" }] })
319
+ ]);
320
+
321
+ const report = readRuns(root, "demo");
322
+ assert.equal(report.runs.length, 0, "缺字段的行不进 runs");
323
+ assert.equal(report.problems.length, 1);
324
+ assert.match(report.problems[0].detail, /skipped/);
325
+ assert.equal(report.state, "broken", "一条可用的记录都没收到");
326
+
327
+ // 端到端:那个节点必须还是 candidate、credit 还是 null(不是「用过一次」)。
328
+ const doc = readPipelineDoc({ projectRoot: root, id: "demo", pluginDir: root, runsById: { demo: report.runs } });
329
+ const a1 = doc.nodes.find((n) => n.id === "A1");
330
+ assert.equal(a1.state, "candidate");
331
+ assert.equal(a1.credit, null);
332
+ });
333
+ });
334
+
335
+ test("kind 只认 detour:不认识的取值报噪,不静默丢掉这一步的结局", () => {
336
+ withDir((root) => {
337
+ const dir = seededProject(root);
338
+ writeLedger(dir, "demo", [
339
+ JSON.stringify({
340
+ at: "2026-10-15T09:00:00.000Z",
341
+ by: "main",
342
+ steps: [{ id: "A1", at: "2026-10-15T09:00:01.000Z", skipped: false, refuted: false, kind: "skip" }]
343
+ })
344
+ ]);
345
+ const report = readRuns(root, "demo");
346
+ assert.equal(report.runs.length, 0);
347
+ assert.match(report.problems[0].detail, /kind/);
348
+ });
349
+ });
350
+
351
+ // ── 写侧:一行一条,形状由收口函数保证 ────────────────────────────────────────
352
+
353
+ test("normalizeRun 把一条 run 收成契约形状(缺的补默认、多余的丢掉)", () => {
354
+ const run = normalizeRun({
355
+ at: "2026-10-15T09:00:00.000Z",
356
+ by: "subagent",
357
+ triggeredBy: "跑一下",
358
+ steps: [{ id: "A1", at: "2026-10-15T09:00:01.000Z", skipped: true, note: "这次不用" }]
359
+ });
360
+ assert.deepEqual(run, {
361
+ at: "2026-10-15T09:00:00.000Z",
362
+ by: "subagent",
363
+ triggeredBy: "跑一下",
364
+ steps: [
365
+ { id: "A1", at: "2026-10-15T09:00:01.000Z", skipped: true, detoured: false, refuted: false, note: "这次不用" }
366
+ ]
367
+ });
368
+ });
369
+
370
+ test("时间戳收成 ISO 毫秒:秒级、带偏移、Date、毫秒数都拉齐成同一个形状", () => {
371
+ assert.equal(normalizeRun(goodRun({ at: new Date("2026-10-15T09:00:00.000Z") })).at, "2026-10-15T09:00:00.000Z");
372
+ assert.equal(normalizeRun(goodRun({ at: "2026-10-15T09:00:00Z" })).at, "2026-10-15T09:00:00.000Z");
373
+ assert.equal(normalizeRun(goodRun({ at: "2026-10-15T17:00:00+08:00" })).at, "2026-10-15T09:00:00.000Z");
374
+ assert.equal(normalizeRun(goodRun({ at: Date.parse("2026-10-15T09:00:00.000Z") })).at, "2026-10-15T09:00:00.000Z");
375
+ // 收成唯一一种形状,是因为读侧拿 step.at **按字符串比大小**取 lastUsedAt:
376
+ // 混着 `...:01Z` 与 `...:01.500Z` 时,比较结果与人对先后的直觉不一致。
377
+ assert.equal(normalizeRun(goodRun({ at: "2026-10-15" })).at, "2026-10-15T00:00:00.000Z");
378
+ // 时间不做「补默认」:一次 run 什么时候跑的只有调用方知道,替它猜就是编数据。
379
+ assert.throws(() => normalizeRun(goodRun({ at: "不是时间" })), (e) => e.name === "RunError");
380
+ assert.throws(() => normalizeRun(goodRun({ at: undefined })), (e) => e.name === "RunError");
381
+ assert.throws(() => normalizeRun(goodRun({ at: "" })), (e) => e.name === "RunError");
382
+ assert.throws(() => normalizeRun(goodRun({ steps: [{ id: "A1" }] })), (e) => /at/.test(e.message));
383
+ });
384
+
385
+ test("normalizeRun:图里没有的步骤 id 报出来,不许悄悄记进去", () => {
386
+ // 记进去会让 tallyRuns 数出一个不属于任何节点的账:面板上没有任何地方显示它,
387
+ // 这条 run 于是成了「写了但看不见」。
388
+ const bad = goodRun({ steps: [{ id: "NOPE", at: "2026-10-15T09:00:01.000Z" }] });
389
+ assert.throws(() => normalizeRun(bad, { expectedStepIds: ["A1", "A2"] }), (e) => /NOPE/.test(e.message));
390
+ assert.doesNotThrow(() => normalizeRun(bad));
391
+ });
392
+
393
+ test("appendRun 也要过 step id 闸门:写错的 id 当场拒,盘上不留东西", () => {
394
+ // 闸门只挂在测试调用的那一层等于没挂:写盘之前拒掉,才是唯一能防住
395
+ // 「落盘了、数得出来、面板永远看不见」的时刻。
396
+ withDir((root) => {
397
+ seededProject(root);
398
+ assert.throws(
399
+ () => appendRun(root, "demo", goodRun({ steps: [{ id: "NOPE", at: "2026-10-15T09:00:01.000Z" }] }), { expectedStepIds: ["A1", "A2"] }),
400
+ (e) => e instanceof RunError && /NOPE/.test(e.message)
401
+ );
402
+ assert.equal(existsSync(runsPath(root, "demo")), false, "被拒的 run 不该留下账本文件");
403
+
404
+ // 图上有的步骤照常写得进去。
405
+ const written = appendRun(root, "demo", goodRun(), { expectedStepIds: ["A1", "A2"] });
406
+ assert.equal(written.steps[0].id, "A1");
407
+ assert.equal(readRuns(root, "demo").state, "ok");
408
+ });
409
+ });
410
+
411
+ test("appendRun 一次一行:目录自己建、已有内容不以换行结尾也不会粘行", () => {
412
+ withDir((root) => {
413
+ // 目录都不存在时也要能落下来——第一次跑一条工作流就是这个状态。
414
+ appendRun(root, "demo", goodRun({ at: "2026-10-14T09:00:00.000Z" }));
415
+ assert.equal(existsSync(runsPath(root, "demo")), true);
416
+
417
+ // 手工写一条**不带结尾换行**的半行(模拟被别的东西截断/手写过的账本):
418
+ // 直接追加会把两条 run 粘成一行。手写的行也得是完整的——读盘路径不替它补布尔。
419
+ writeFileSync(
420
+ runsPath(root, "demo"),
421
+ ledgerLine({ at: "2026-10-14T09:00:00.000Z", steps: [{ id: "A1", at: "2026-10-14T09:00:01.000Z" }] }),
422
+ "utf8"
423
+ );
424
+ appendRun(root, "demo", goodRun({ at: "2026-10-15T09:00:00.000Z" }));
425
+
426
+ const runs = readRuns(root, "demo").runs;
427
+ assert.equal(runs.length, 2, "两条 run 必须各自成行");
428
+ assert.deepEqual(runs.map((r) => r.at), ["2026-10-14T09:00:00.000Z", "2026-10-15T09:00:00.000Z"]);
429
+ });
430
+ });
431
+
432
+ test("appendRun 只看账本尾 1 字节,不整份读(热路径代价与账本长度无关)", () => {
433
+ // 复杂度不是可观测行为,一次调用的结果证伪不了它,所以这里锁实现方式,
434
+ // 另用一个几千行的账本证明行为仍然对。为什么值得一条锁:账本只增不减,
435
+ // 「每次追加重读全文」的代价是渐进的——不会有人报 bug,只会有人觉得面板变慢。
436
+ const src = readFileSync(join(here, "..", "src", "pipeline-runs.js"), "utf8");
437
+ const start = src.indexOf("function tailNeedsNewline");
438
+ const end = src.indexOf("export function loadRunsById");
439
+ assert.ok(start > 0 && end > start, "定位不到追加路径的实现");
440
+ const appendPath = src.slice(start, end);
441
+ assert.equal(/readFileSync/.test(appendPath), false, "追加路径不得整份读账本");
442
+ assert.equal(/fstatSync/.test(appendPath) && /readSync/.test(appendPath), true, "尾字节要用 open + fstat + read 取");
443
+
444
+ withDir((root) => {
445
+ const dir = seededProject(root);
446
+ const lines = [];
447
+ for (let i = 0; i < 2000; i++) {
448
+ const day = String((i % 28) + 1).padStart(2, "0");
449
+ lines.push(ledgerLine({ at: `2026-09-${day}T09:00:00.000Z`, steps: [{ id: "A1", at: `2026-09-${day}T09:00:01.000Z` }] }));
450
+ }
451
+ writeLedger(dir, "demo", lines);
452
+ appendRun(root, "demo", goodRun({ at: "2026-10-15T09:00:00.000Z" }));
453
+
454
+ const report = readRuns(root, "demo");
455
+ assert.equal(report.problems.length, 0);
456
+ assert.equal(report.runs.length, 2001, "大账本照常追加");
457
+ assert.equal(report.runs[2000].at, "2026-10-15T09:00:00.000Z");
458
+ });
459
+ });
460
+
461
+ test("落盘格式是锁死的:一行一条 JSON,字段齐全", () => {
462
+ // 账本是人也要读的(`git diff` 里会看到它)。少一个默认值、多个字段,
463
+ // 都会让「人工看一条 run」变成解谜。
464
+ withDir((root) => {
465
+ seededProject(root);
466
+ appendRun(root, "demo", goodRun());
467
+ const raw = readFileSync(runsPath(root, "demo"), "utf8");
468
+ const lines = raw.split(/\r\n|\n/).filter((l) => l !== "");
469
+ assert.equal(lines.length, 1, "一次 run 只占一行");
470
+ assert.deepEqual(Object.keys(JSON.parse(lines[0])), ["at", "by", "triggeredBy", "steps"]);
471
+ assert.deepEqual(Object.keys(JSON.parse(lines[0]).steps[0]), ["id", "at", "skipped", "detoured", "refuted", "note"]);
472
+ assert.equal(raw.endsWith("\n"), true, "以换行结尾,下一次追加才不会粘行");
473
+ });
474
+ });
475
+
476
+ // ── kind:"detour" 的往返:绕行不许被读成一次成功使用 ─────────────────────────
477
+
478
+ test("kind:\"detour\" 写进去、读回来仍是绕行,不是一次成功使用", () => {
479
+ // 回归锁:归一化只留契约那六个键,`kind` 被丢掉时,读侧仍认 `kind === "detour"`——
480
+ // 于是一次绕行经「写→读」往返后 credit 从 {uses:0, detoured:1} 翻成 {uses:1, detoured:0},
481
+ // 节点由 candidate 变 trusted,rework-path 提议永不出现。修在收口处,
482
+ // 因为手写账、历史账、别的写入方都走这条口。
483
+ withDir((root) => {
484
+ seededProject(root);
485
+ appendRun(root, "demo", goodRun({ steps: [{ id: "A1", at: "2026-10-15T09:00:01.000Z", kind: "detour" }] }));
486
+
487
+ const report = readRuns(root, "demo");
488
+ assert.equal(report.problems.length, 0);
489
+ assert.equal(report.runs[0].steps[0].detoured, true);
490
+ assert.equal(report.runs[0].steps[0].skipped, false);
491
+
492
+ const doc = readPipelineDoc({ projectRoot: root, id: "demo", pluginDir: root, runsById: { demo: report.runs } });
493
+ const a1 = doc.nodes.find((n) => n.id === "A1");
494
+ assert.equal(a1.credit.uses, 0, "绕行不是使用");
495
+ assert.equal(a1.credit.detoured, 1);
496
+ assert.notEqual(a1.state, "trusted", "被绕开不等于这一步被验证过");
497
+ });
498
+ });
499
+
500
+ test("手写账里的 kind:\"detour\" 同样认:只给 kind、不给三个布尔也算完整", () => {
501
+ // 旧写法只有一个 `kind` 字段。它必须被认下来(而不是因为「缺 detoured」被判成坏行),
502
+ // 否则老账本会整段变成噪音。
503
+ withDir((root) => {
504
+ const dir = seededProject(root);
505
+ writeLedger(dir, "demo", [
506
+ JSON.stringify({
507
+ at: "2026-10-15T09:00:00.000Z",
508
+ by: "main",
509
+ steps: [{ id: "A1", at: "2026-10-15T09:00:01.000Z", kind: "detour", skipped: false, refuted: false }]
510
+ })
511
+ ]);
512
+ const report = readRuns(root, "demo");
513
+ assert.equal(report.problems.length, 0);
514
+ assert.equal(report.runs[0].steps[0].detoured, true);
515
+ assert.equal(report.state, "ok");
516
+ });
517
+ });
518
+
519
+ // ── 行为级:写下去的那条 run 要能被读侧数出来 ────────────────────────────────
520
+
521
+ test("行为级:一次 run 落盘后,readPipelineDoc 的 nodes[].credit 与 state 真的变了", () => {
522
+ withDir((root) => {
523
+ seededProject(root);
524
+
525
+ // 写之前:没有账 → 全体 candidate(这是读侧如实说的「还没跑过」)。
526
+ const before = readPipelineDoc({ projectRoot: root, id: "demo", pluginDir: root });
527
+ assert.deepEqual([...new Set(before.nodes.map((n) => n.state))], ["candidate"]);
528
+
529
+ appendRun(root, "demo", {
530
+ at: "2026-10-15T09:00:00.000Z",
531
+ by: "human",
532
+ triggeredBy: "跑一下",
533
+ steps: [
534
+ { id: "A1", at: "2026-10-15T09:00:01.000Z" },
535
+ { id: "A2", at: "2026-10-15T09:00:02.000Z", refuted: true }
536
+ ]
537
+ });
538
+
539
+ const runsById = loadRunsById(root, ["demo"]).runsById;
540
+ const after = readPipelineDoc({ projectRoot: root, id: "demo", pluginDir: root, runsById });
541
+ assert.equal(after.runs.length, 1);
542
+
543
+ const byId = Object.fromEntries(after.nodes.map((n) => [n.id, n]));
544
+ // 照图走了、没被推翻 → trusted,并记下用了 1 次与最后一次的时间。
545
+ assert.equal(byId.A1.state, "trusted");
546
+ assert.deepEqual(byId.A1.credit, { uses: 1, refuted: 0, skipped: 0, detoured: 0, lastUsedAt: "2026-10-15T09:00:01.000Z" });
547
+ // 走过但被推翻 → refuted:这是唯一有硬判据的信号,染色留给它。
548
+ assert.equal(byId.A2.state, "refuted");
549
+ assert.equal(byId.A2.credit.refuted, 1);
550
+ assert.equal(byId.A2.credit.uses, 1);
551
+
552
+ // 清单页的账也要跟着动:首屏不能还是「全 candidate」。
553
+ assert.deepEqual(after.available[0].states, { candidate: 0, trusted: 1, stale: 0, refuted: 1 });
554
+ });
555
+ });
556
+
557
+ test("行为级:跳过与绕开各自独立累加,谁都不吞谁(两种提议一起发得出来)", () => {
558
+ withDir((root) => {
559
+ seededProject(root);
560
+ // 一次同时「跳过」+「绕开」:两件事驱动的提议不同(删这一步 / 重画这条路),
561
+ // 第一版写成 if/else if 时绕行把跳过吃掉了,两种提议一起哑掉。
562
+ const line = { id: "A1", at: "2026-10-15T09:00:01.000Z", skipped: true, detoured: true };
563
+ for (const at of ["2026-10-13T09:00:00.000Z", "2026-10-14T09:00:00.000Z", "2026-10-15T09:00:00.000Z"]) {
564
+ appendRun(root, "demo", { at, by: "main", triggeredBy: "", steps: [line] });
565
+ }
566
+
567
+ const runsById = loadRunsById(root, ["demo"]).runsById;
568
+ const doc = readPipelineDoc({ projectRoot: root, id: "demo", pluginDir: root, runsById });
569
+ const a1 = doc.nodes.find((n) => n.id === "A1");
570
+ assert.equal(a1.credit.uses, 0, "跳过/绕开都不是「用了这一步」");
571
+ assert.equal(a1.credit.skipped, 3);
572
+ assert.equal(a1.credit.detoured, 3);
573
+
574
+ // 计数到阈值就该冒出提议——这是执行链存在的理由:不用人等巡检。
575
+ // R2 新语义:A1 是必经(无 optional)且从未 uses、被跳 3 次 → drop-step 与
576
+ // rework-path **并存**(删与修图两种读法都给人,谁也不吞谁)。
577
+ const kinds = doc.suggestions.map((s) => s.kind).sort();
578
+ assert.deepEqual(kinds, ["drop-step", "rework-path", "rework-path"].sort(), JSON.stringify(doc.suggestions));
579
+ // 绕开不染色:染色要留给真正被推翻过的。
580
+ assert.equal(a1.state, "stale");
581
+ });
582
+ });
583
+
584
+ // ── 端点接线:面板拿得到的那一份 ─────────────────────────────────────────────
585
+
586
+ test("端点:一次 run 喂进去,节点不再是 candidate,且状态是 ok", () => {
587
+ withDir((root) => {
588
+ seededProject(root);
589
+ appendRun(root, "demo", goodRun({ steps: [{ id: "A1", at: "2026-10-15T09:00:01.000Z" }] }));
590
+
591
+ const { status, doc } = apiDoc(root, { id: "demo" });
592
+ assert.equal(status, 200);
593
+ assert.equal(doc.runs.length, 1, "run 记录要走到投影里");
594
+ const a1 = doc.nodes.find((n) => n.id === "A1");
595
+ assert.equal(a1.state, "trusted");
596
+ assert.equal(a1.credit.uses, 1);
597
+ assert.equal(doc.runsState, "ok");
598
+ assert.equal(doc.available[0].runsState, "ok");
599
+ assert.deepEqual(doc.runsErrors, [], "读得干净时不该有噪音");
600
+ });
601
+ });
602
+
603
+ test("端点:absent 与 empty 在响应里分得开(不靠 state 猜)", () => {
604
+ withDir((root) => {
605
+ const dir = seededProject(root);
606
+ const absent = apiDoc(root, { id: "demo" }).doc;
607
+ assert.equal(absent.runsState, "absent");
608
+ assert.deepEqual(absent.runsErrors, []);
609
+
610
+ writeFileSync(join(dir, `demo${RUNS_SUFFIX}`), "", "utf8");
611
+ const empty = apiDoc(root, { id: "demo" }).doc;
612
+ assert.equal(empty.runsState, "empty");
613
+ // 两种情况下节点状态完全同形(都是 candidate)——这正是必须另给一个字段的原因。
614
+ assert.deepEqual(absent.available[0].states, empty.available[0].states);
615
+ });
616
+ });
617
+
618
+ test("端点:账本坏掉时说清坏在哪,但**不**吞掉那张读得出来的图(不再回 500)", () => {
619
+ // 回归锁:早先对坏账本回 500,客户端把它当成「离线」,连带把工作流本身丢掉。
620
+ // 账本坏了不等于工作流坏了:图照给,噪音与状态另说。
621
+ withDir((root) => {
622
+ const dir = seededProject(root);
623
+ writeLedger(dir, "demo", ["{ 半个对象"]);
624
+
625
+ const { status, doc } = apiDoc(root, { id: "demo" });
626
+ assert.equal(status, 200, "账本坏不该让整个端点失败");
627
+ assert.equal(doc.pipeline.id, "demo", "工作流本身还在");
628
+ assert.ok(doc.nodes.length > 0, "图还在");
629
+ assert.equal(doc.runsState, "broken");
630
+ assert.equal(doc.runsErrors.length, 1);
631
+ assert.equal(doc.runsErrors[0].id, "demo");
632
+ assert.equal(doc.runsErrors[0].line, 1, "要能指到行");
633
+ assert.match(doc.runsErrors[0].detail, /demo\.runs\.jsonl/);
634
+ // 读不出来的账不能被算成「跑过」:节点仍是 candidate。
635
+ assert.deepEqual([...new Set(doc.nodes.map((n) => n.state))], ["candidate"]);
636
+ });
637
+ });
638
+
639
+ test("端点:部分坏 = partial,好的那几条照样投影出 credit", () => {
640
+ // 「报噪」的意思不是「整份作废」:坏行归坏行,好行必须继续说话。
641
+ withDir((root) => {
642
+ const dir = seededProject(root);
643
+ writeLedger(dir, "demo", [
644
+ ledgerLine({ at: "2026-10-15T09:00:00.000Z", steps: [{ id: "A1", at: "2026-10-15T09:00:01.000Z" }] }),
645
+ "{ 半个对象"
646
+ ]);
647
+
648
+ const { doc } = apiDoc(root, { id: "demo" });
649
+ assert.equal(doc.runsState, "partial");
650
+ assert.equal(doc.runs.length, 1);
651
+ assert.equal(doc.nodes.find((n) => n.id === "A1").state, "trusted");
652
+ assert.equal(doc.runsErrors.length, 1);
653
+ });
654
+ });
655
+
656
+ test("端点:默认视图(不带 id)逐行带 runsState——那一屏才是最常被打开的", () => {
657
+ // 回归锁:早先只有顶层 runsErrors,而默认视图连 500 兜底都没有;
658
+ // 用户的第一个界面看到的是「全体 candidate」,看不出账本是坏的。
659
+ withDir((root) => {
660
+ const dir = seededProject(root);
661
+ writeLedger(dir, "demo", ["{ 半个对象"]);
662
+ // 再放一条干净的工作流进项目层:两行状态必须各说各的。
663
+ writeFileSync(
664
+ join(dir, "other.json"),
665
+ JSON.stringify({
666
+ id: "other",
667
+ name: "另一条",
668
+ project: "someone/repo",
669
+ version: 0,
670
+ status: "active",
671
+ done: "做完",
672
+ trigger: [{ kind: "phrase", value: "跑一下" }],
673
+ steps: [
674
+ { id: "B1", phase: "p", title: "一步", when: "w", do: "d", who: "main", tool: "", done: "x", risk: "R0", needs: [], why: "y", evidence: ["e"] }
675
+ ]
676
+ }),
677
+ "utf8"
678
+ );
679
+ appendRun(root, "other", { at: "2026-10-15T09:00:00.000Z", by: "ci", triggeredBy: "", steps: [{ id: "B1", at: "2026-10-15T09:00:01.000Z" }] });
680
+
681
+ const { status, doc } = apiDoc(root);
682
+ assert.equal(status, 200);
683
+ const states = Object.fromEntries(doc.available.map((row) => [row.id, row.runsState]));
684
+ assert.deepEqual(states, { demo: "broken", other: "ok" });
685
+ assert.equal(doc.runsErrors.length, 1);
686
+ });
687
+ });
688
+
689
+ test("端点:账本里写了图上没有的 step id → 看得见的噪音,不是静默", () => {
690
+ // 这条守的是「上线后最常见的错」:宿主步骤与工作流步骤的归属规则还没定,
691
+ // 写进来的 id 对不上图——那条 run 能落盘、能被读,却没有任何节点拿到它,
692
+ // 面板症状与「从没跑过」完全同形。闸门挂在生产路径上(端点的 loadRunsById)。
693
+ withDir((root) => {
694
+ const dir = seededProject(root);
695
+ writeLedger(dir, "demo", [
696
+ ledgerLine({ at: "2026-10-15T09:00:00.000Z", steps: [{ id: "A1", at: "2026-10-15T09:00:01.000Z" }] }),
697
+ ledgerLine({ at: "2026-10-16T09:00:00.000Z", steps: [{ id: "ghost-step", at: "2026-10-16T09:00:01.000Z" }] })
698
+ ]);
699
+
700
+ const { doc } = apiDoc(root, { id: "demo" });
701
+ assert.equal(doc.runsState, "partial");
702
+ assert.equal(doc.runs.length, 1, "对不上的那条不进 runs");
703
+ assert.ok(doc.runsErrors.some((e) => /不在工作流的步骤表里/.test(e.detail)), JSON.stringify(doc.runsErrors));
704
+ // 好行照常投影:闸门只拦对不上的那条。
705
+ assert.equal(doc.nodes.find((n) => n.id === "A1").state, "trusted");
706
+ });
707
+ });
708
+
709
+ test("端点:没有项目根时不谎称「没有账本文件」——runsState 给 null", () => {
710
+ // 「读不到」与「读到空」是两回事:项目根拿不到时账本层根本无从读起。
711
+ const { status, doc } = apiDoc(null, { id: "demo" });
712
+ assert.equal(status, 200);
713
+ assert.equal(doc.runsState, null);
714
+ assert.equal(doc.available.every((row) => row.runsState === undefined), true);
715
+ });
716
+
717
+ test("loadRunsById 只读问到的那些 id,没问到的连盘都不碰", () => {
718
+ withDir((root) => {
719
+ const dir = seededProject(root);
720
+ appendRun(root, "demo", goodRun());
721
+ // 同目录里再放一份别人的账本:它没被问到,就不该出现在结果里。
722
+ writeFileSync(join(dir, `other${RUNS_SUFFIX}`), "{ 半个对象\n", "utf8");
723
+
724
+ const { runsById, errors, statesById } = loadRunsById(root, ["demo", "never-written"]);
725
+ assert.deepEqual(Object.keys(runsById).sort(), ["demo", "never-written"]);
726
+ assert.equal(runsById.demo.length, 1);
727
+ assert.deepEqual(runsById["never-written"], [], "没跑过就是空数组,不是报错");
728
+ assert.deepEqual(errors, [], "没被问到的坏账本不该污染这次读数");
729
+ // 这张表按 id 查,prototype 是 null(与 runsById 同款,免得 id 叫 __proto__ 时查错);
730
+ // 断言前摊平成普通对象,别把「原型不同」当成内容不同。
731
+ assert.deepEqual({ ...statesById }, { demo: "ok", "never-written": "absent" });
732
+ });
733
+ });
734
+
735
+ test("loadRunsById:没有步骤表的 id 不套闸门(问不到图就不能假装知道图)", () => {
736
+ withDir((root) => {
737
+ const dir = seededProject(root);
738
+ writeLedger(dir, "demo", [
739
+ ledgerLine({ at: "2026-10-15T09:00:00.000Z", steps: [{ id: "ghost-step", at: "2026-10-15T09:00:01.000Z" }] })
740
+ ]);
741
+
742
+ const guarded = loadRunsById(root, ["demo"], { expectedStepIdsById: { demo: ["A1", "A2"] } });
743
+ assert.equal(guarded.runsById.demo.length, 0);
744
+ assert.match(guarded.errors[0].detail, /ghost-step/);
745
+
746
+ // 白名单里没有这条工作流(比如 .json 被删了、账本还在):只能放行,但状态如实报。
747
+ const unguarded = loadRunsById(root, ["demo"], { expectedStepIdsById: {} });
748
+ assert.equal(unguarded.runsById.demo.length, 1);
749
+ assert.deepEqual(unguarded.errors, []);
750
+ });
751
+ });
752
+
753
+ test("runsErrors 的 detail 带文件名与行号:报噪要能直接定位到那一行", () => {
754
+ withDir((root) => {
755
+ const dir = seededProject(root);
756
+ writeLedger(dir, "demo", [
757
+ ledgerLine({ at: "2026-10-15T09:00:00.000Z", steps: [{ id: "A1", at: "2026-10-15T09:00:01.000Z" }] }),
758
+ " ",
759
+ "{ 半个对象"
760
+ ]);
761
+ const { errors } = loadRunsById(root, ["demo"]);
762
+ assert.equal(errors.length, 2);
763
+ assert.equal(errors[0].line, 2);
764
+ assert.match(errors[0].detail, /demo\.runs\.jsonl 第 2 行/);
765
+ assert.equal(errors[1].line, 3);
766
+ });
767
+ });