@modusensus/dsh-mneme 0.8.16 → 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.
- package/README.md +91 -16
- package/assets/icon.svg +2 -2
- package/bin/cli.mjs +8 -2
- package/docs/CONFIGURATION.md +27 -1
- package/docs/CONTINUITY.md +18 -3
- package/docs/SEMANTIC.md +3 -3
- package/docs/WORKFLOWS.md +299 -0
- package/lib/api-standalone.js +78 -12
- package/lib/api.js +403 -62
- package/lib/candidates.js +454 -0
- package/lib/capabilities.js +819 -0
- package/lib/client.js +2294 -423
- package/lib/config.js +63 -6
- package/lib/continuity.js +35 -6
- package/lib/dream/decisions.js +17 -1
- package/lib/dream/sleep.js +10 -2
- package/lib/dream.js +5 -1
- package/lib/embedding.js +116 -15
- package/lib/entities/extractor.js +4 -0
- package/lib/index.js +81 -12
- package/lib/inject.js +40 -4
- package/lib/lang.js +124 -0
- package/lib/pipeline-activities.js +178 -0
- package/lib/pipeline-attribution.js +165 -0
- package/lib/pipeline-command.js +279 -0
- package/lib/pipeline-confirm.js +142 -0
- package/lib/pipeline-exec.js +174 -0
- package/lib/pipeline-nudge.js +1281 -0
- package/lib/pipeline-observations.js +181 -0
- package/lib/pipeline-observe.js +133 -0
- package/lib/pipeline-runner.js +346 -0
- package/lib/pipeline-runs.js +442 -0
- package/lib/pipeline.js +655 -0
- package/lib/scope.js +16 -4
- package/lib/semantic.js +66 -10
- package/lib/serve.js +1 -1
- package/lib/service.js +59 -18
- package/lib/session-usage.js +350 -0
- package/lib/settings.js +138 -1
- package/lib/store.js +132 -6
- package/lib/summarize.js +30 -0
- package/lib/tools.js +11 -2
- package/package.json +3 -3
- package/scripts/changelog-prep.mjs +62 -7
- package/scripts/check-sync.js +8 -6
- package/scripts/publish-gate.mjs +197 -0
- package/scripts/release-docs.mjs +204 -0
- package/scripts/release-notes.mjs +162 -0
- package/src/api-standalone.js +78 -12
- package/src/api.js +403 -62
- package/src/candidates.js +454 -0
- package/src/capabilities.js +819 -0
- package/src/config.js +63 -6
- package/src/continuity.js +35 -6
- package/src/dream/decisions.js +17 -1
- package/src/dream/sleep.js +10 -2
- package/src/dream.js +5 -1
- package/src/embedding.js +116 -15
- package/src/entities/extractor.js +4 -0
- package/src/index.js +81 -12
- package/src/inject.js +40 -4
- package/src/lang.js +124 -0
- package/src/pipeline-activities.js +178 -0
- package/src/pipeline-attribution.js +165 -0
- package/src/pipeline-command.js +279 -0
- package/src/pipeline-confirm.js +142 -0
- package/src/pipeline-exec.js +174 -0
- package/src/pipeline-nudge.js +1281 -0
- package/src/pipeline-observations.js +181 -0
- package/src/pipeline-observe.js +133 -0
- package/src/pipeline-runner.js +346 -0
- package/src/pipeline-runs.js +442 -0
- package/src/pipeline.js +655 -0
- package/src/scope.js +16 -4
- package/src/semantic.js +66 -10
- package/src/serve.js +1 -1
- package/src/service.js +59 -18
- package/src/session-usage.js +350 -0
- package/src/settings.js +138 -1
- package/src/store.js +132 -6
- package/src/summarize.js +30 -0
- package/src/tools.js +11 -2
- package/test/api-hardening.test.js +173 -0
- package/test/api.test.js +244 -7
- package/test/candidates.test.js +345 -0
- package/test/capabilities.test.js +577 -0
- package/test/changelog-prep.test.js +74 -8
- package/test/cli-enums.test.js +37 -0
- package/test/client.test.js +510 -2
- package/test/continuity.test.js +84 -0
- package/test/docs-config-keys.test.js +65 -0
- package/test/embed-ready-poll.test.js +94 -0
- package/test/entities.test.js +13 -2
- package/test/failure-query.test.js +147 -0
- package/test/failure-retention.test.js +105 -0
- package/test/graph-api.test.js +36 -2
- package/test/helpers/write-admission-samples.js +6 -2
- package/test/inject-recall.test.js +32 -4
- package/test/lib-smoke.test.js +20 -0
- package/test/llm-audit.test.js +92 -1
- package/test/mint-source.test.js +202 -0
- package/test/persona-api.test.js +172 -0
- package/test/persona-flag.test.js +36 -0
- package/test/persona-inject.test.js +145 -0
- package/test/persona-settings.test.js +145 -0
- package/test/pipeline-activities.test.js +155 -0
- package/test/pipeline-attribution.test.js +175 -0
- package/test/pipeline-command.test.js +243 -0
- package/test/pipeline-confirm.test.js +148 -0
- package/test/pipeline-exec.test.js +172 -0
- package/test/pipeline-nudge.test.js +1036 -0
- package/test/pipeline-observations.test.js +157 -0
- package/test/pipeline-observe.test.js +127 -0
- package/test/pipeline-r1r2.test.js +149 -0
- package/test/pipeline-runner.test.js +442 -0
- package/test/pipeline-runs.test.js +767 -0
- package/test/pipeline.test.js +636 -0
- package/test/placement.test.js +1 -1
- package/test/publish-gate.test.js +272 -0
- package/test/recall-retention.test.js +128 -0
- package/test/reindex-backfill.test.js +83 -4
- package/test/release-docs.test.js +130 -0
- package/test/release-notes.test.js +240 -0
- package/test/release-workflow.test.js +93 -0
- package/test/route-policy.test.js +113 -0
- package/test/scope-distill-stamp.test.js +135 -0
- package/test/security-doc.test.js +139 -0
- package/test/sensitive-scan.test.js +44 -0
- package/test/serve.test.js +1 -1
- package/test/session-usage.test.js +308 -0
- package/test/store-write-path-columns.test.js +191 -0
- package/test/summarize.test.js +176 -0
- package/test/supersede-state-tracking.test.js +4 -4
- package/test/tools.test.js +16 -1
- package/test/workflow-guards.test.js +156 -0
package/test/api.test.js
CHANGED
|
@@ -680,7 +680,14 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
680
680
|
// #135 成本审计派生新增 dreamMaxMergePerRun(单轮 merge 决策上限))
|
|
681
681
|
// issue #399 新增 distillRecords.enabled/distillRecords.retentionDays(蒸馏原文留存 +
|
|
682
682
|
// 溯源:开关与保留期都在嵌套对象里,两个都进 NESTED_FLAG_PATHS))
|
|
683
|
-
|
|
683
|
+
// v0.9.0 新增 actionPipelineEnabled(M.O. beta 门控;顶层扁平键,
|
|
684
|
+
// 走 configFlagValue 默认分支,不需要 NESTED_FLAG_PATHS 条目)。
|
|
685
|
+
// v0.9.0 第二批新增 actionRunEnabled(M.O. 执行记账;同样顶层扁平键)。
|
|
686
|
+
// persona 页新增 personaEnabled(人格注入顶层总闸;顶层扁平键,同上)。
|
|
687
|
+
// 计数锁钉的是「白名单键 ↔ config schema 成对」:schema 加了键而漏了白名单
|
|
688
|
+
// (或反过来)时,effective 会静默少键/多键,面板读不到也报不出来。
|
|
689
|
+
// 合并 #451(人格页)后的真实计数:98(本批前)+ actionRunEnabled + personaEnabled = 99
|
|
690
|
+
assert.equal(Object.keys(data.effective).length, 99);
|
|
684
691
|
assert.equal(data.effective.dreamSkipInvalid, true);
|
|
685
692
|
assert.equal(data.effective.allowCrossTypeMerge, false);
|
|
686
693
|
assert.equal(data.effective.dreamMergeGuard, true);
|
|
@@ -714,6 +721,14 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
714
721
|
// 保留期默认 90 天(与 llmAudit.retentionDays 同款)。
|
|
715
722
|
assert.equal(data.effective["distillRecords.enabled"], false);
|
|
716
723
|
assert.equal(data.effective["distillRecords.retentionDays"], 90);
|
|
724
|
+
// v0.9.0:M.O. beta 门控默认关——客户端 GET /features 必须能读到这个键
|
|
725
|
+
// (读不到就静默不注册任何座位),且默认值必须是 false(beta opt-in)。
|
|
726
|
+
assert.equal(data.effective.actionPipelineEnabled, false);
|
|
727
|
+
// v0.9.0 第二批:M.O. 执行记账同样默认关,且**是独立的一个键**——看图与写账
|
|
728
|
+
// 分开,避免「只想看图」的人不知不觉获得热路径上的写入能力。
|
|
729
|
+
assert.equal(data.effective.actionRunEnabled, false);
|
|
730
|
+
// 人格页(beta):注入总闸默认关,effective 必须能读到(同 #254 坑形防线)。
|
|
731
|
+
assert.equal(data.effective.personaEnabled, false);
|
|
717
732
|
// 新增字符串 / URL / 枚举键
|
|
718
733
|
assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5");
|
|
719
734
|
assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
|
|
@@ -746,14 +761,16 @@ test("PUT /api/dsh-mneme/features round-trips, overrides effective and persists"
|
|
|
746
761
|
const { routes, settings } = setup(undefined, "", FLAGS_CFG);
|
|
747
762
|
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
748
763
|
const res = new FakeRes();
|
|
749
|
-
await route.handler(req("/api/dsh-mneme/features", "PUT", { autoDream: false, distillMaxChars: 48000 }), res);
|
|
764
|
+
await route.handler(req("/api/dsh-mneme/features", "PUT", { autoDream: false, distillMaxChars: 48000, actionPipelineEnabled: true }), res);
|
|
750
765
|
assert.equal(res.statusCode, 200);
|
|
751
766
|
const data = JSON.parse(res.body);
|
|
752
|
-
assert.deepEqual(data.overrides, { autoDream: false, distillMaxChars: 48000 });
|
|
767
|
+
assert.deepEqual(data.overrides, { autoDream: false, distillMaxChars: 48000, actionPipelineEnabled: true });
|
|
753
768
|
assert.equal(data.effective.autoDream, false, "override wins over config default");
|
|
754
769
|
assert.equal(data.effective.distillMaxChars, 48000);
|
|
770
|
+
// v0.9.0:默认关的键也能被 PUT 打开并读回(beta 功能的启用路径)。
|
|
771
|
+
assert.equal(data.effective.actionPipelineEnabled, true, "override flips the beta gate on");
|
|
755
772
|
assert.equal(data.effective.autoInject, true, "keys not in the patch still report config");
|
|
756
|
-
assert.deepEqual(settings.getFeatureFlags(), { autoDream: false, distillMaxChars: 48000 });
|
|
773
|
+
assert.deepEqual(settings.getFeatureFlags(), { autoDream: false, distillMaxChars: 48000, actionPipelineEnabled: true });
|
|
757
774
|
});
|
|
758
775
|
|
|
759
776
|
test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys", async () => {
|
|
@@ -1435,8 +1452,12 @@ const MOCK_LLM = {
|
|
|
1435
1452
|
if (provider === "broken") throw new Error("provider not reachable");
|
|
1436
1453
|
return [{ id: "deepseek-chat", name: "DeepSeek Chat" }, { id: "deepseek-reasoner", name: "DeepSeek Reasoner" }];
|
|
1437
1454
|
},
|
|
1455
|
+
// 每次 stream 的 options 留档:既给「字段省略 / 透传」的断言用,也让「档位被拒
|
|
1456
|
+
// 后重试一次」能数出调用次数(降级必须恰好两次,多一次就是盲重试)。
|
|
1457
|
+
streamCalls: [],
|
|
1438
1458
|
async *stream(options) {
|
|
1439
1459
|
MOCK_LLM.lastOptions = options;
|
|
1460
|
+
MOCK_LLM.streamCalls.push(options);
|
|
1440
1461
|
if (options.model === "bad-model") {
|
|
1441
1462
|
yield {
|
|
1442
1463
|
type: "finish",
|
|
@@ -1444,6 +1465,26 @@ const MOCK_LLM = {
|
|
|
1444
1465
|
};
|
|
1445
1466
|
return;
|
|
1446
1467
|
}
|
|
1468
|
+
// 流级档位拒收:rc.1 起的适配器把 provider 的拒收折叠成终止 error finish
|
|
1469
|
+
// chunk(不抛错),这条路径曾让 withEffortFallback 的重试形同死代码。
|
|
1470
|
+
if (options.model === "stream-reject" && options.reasoningEffort) {
|
|
1471
|
+
yield {
|
|
1472
|
+
type: "finish",
|
|
1473
|
+
reason: {
|
|
1474
|
+
kind: "error",
|
|
1475
|
+
failure: {
|
|
1476
|
+
code: "UNSUPPORTED_REASONING_EFFORT",
|
|
1477
|
+
message: `provider "mock" model "mock-model" does not support reasoning effort "${options.reasoningEffort}"`
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
// 抛错型真失败(网络中断 / provider 崩溃):错误以异常形式冒到 catch 路径,
|
|
1484
|
+
// 必须原样上报——「档位被拒才重试」不能退化成「见到错误就重试」。
|
|
1485
|
+
if (options.model === "crash-model") {
|
|
1486
|
+
throw new Error("socket hang up");
|
|
1487
|
+
}
|
|
1447
1488
|
if (options.reasoningEffort === "reject") {
|
|
1448
1489
|
throw new Error('UNSUPPORTED_REASONING_EFFORT: provider "mock" model "mock-model" does not support reasoning effort "reject"');
|
|
1449
1490
|
}
|
|
@@ -1517,15 +1558,122 @@ test("POST /api/dsh-mneme/test-model reports stream-level failure with 502", asy
|
|
|
1517
1558
|
assert.match(data.error, /AUTH_FAILED/, "stream failure reason surfaced like audit rows");
|
|
1518
1559
|
});
|
|
1519
1560
|
|
|
1520
|
-
|
|
1561
|
+
// issue #215 的后续:探针是仓里唯一「接受并转发调用方档位、却此前没有兜底」的
|
|
1562
|
+
// LLM 调用面(直调 llm.stream 的其余各面要么在 withEffortFallback 之内,要么
|
|
1563
|
+
// 是 src/index.js 的手写版兜底,要么像 pre-inject-gate 的判定池那样不转发档位),
|
|
1564
|
+
// 档位被拒则丢掉字段重试一次,于是同一份配置「后台跑得通、点测试报红」,用户被
|
|
1565
|
+
// 引向「换哪家 Provider 都失败」。探针必须与真实 run 同语义:降级跑通 → 200 +
|
|
1566
|
+
// effortDropped:true(不是 502)。
|
|
1567
|
+
test("POST /api/dsh-mneme/test-model degrades on a rejected effort instead of failing (issue #215 follow-up)", async () => {
|
|
1568
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1569
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1570
|
+
const before = MOCK_LLM.streamCalls.length;
|
|
1571
|
+
const res = new FakeRes();
|
|
1572
|
+
await route.handler(
|
|
1573
|
+
req("/api/dsh-mneme/test-model", "POST", { provider: "mock", model: "mock-model", reasoningEffort: "reject" }),
|
|
1574
|
+
res
|
|
1575
|
+
);
|
|
1576
|
+
assert.equal(res.statusCode, 200, "a rejected effort is a downgrade, not a connectivity failure");
|
|
1577
|
+
const data = JSON.parse(res.body);
|
|
1578
|
+
assert.equal(data.ok, true);
|
|
1579
|
+
assert.equal(data.reply, "ok", "the no-effort retry actually answered");
|
|
1580
|
+
assert.equal(data.effortDropped, true, "the caller must be able to tell 'connected, effort dropped' from 'connected as configured'");
|
|
1581
|
+
const calls = MOCK_LLM.streamCalls.slice(before);
|
|
1582
|
+
assert.equal(calls.length, 2, "exactly one retry: attempt with the effort, then once without");
|
|
1583
|
+
assert.equal(calls[0].reasoningEffort, "reject", "first attempt carries the configured effort");
|
|
1584
|
+
assert.equal("reasoningEffort" in calls[1], false, "the retry omits the field entirely");
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
test("POST /api/dsh-mneme/test-model degrades on a STREAM-level effort rejection too", async () => {
|
|
1588
|
+
// rc.1 起适配器不抛错,把 provider 的拒收折叠成终止 error finish chunk——只在
|
|
1589
|
+
// catch 里做兜底的话这条路径就是死的,探针会照旧 502。
|
|
1590
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1591
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1592
|
+
const before = MOCK_LLM.streamCalls.length;
|
|
1593
|
+
const res = new FakeRes();
|
|
1594
|
+
await route.handler(
|
|
1595
|
+
req("/api/dsh-mneme/test-model", "POST", { provider: "mock", model: "stream-reject", reasoningEffort: "high" }),
|
|
1596
|
+
res
|
|
1597
|
+
);
|
|
1598
|
+
assert.equal(res.statusCode, 200, "stream-level rejection also downgrades");
|
|
1599
|
+
const data = JSON.parse(res.body);
|
|
1600
|
+
assert.equal(data.ok, true);
|
|
1601
|
+
assert.equal(data.effortDropped, true);
|
|
1602
|
+
const calls = MOCK_LLM.streamCalls.slice(before);
|
|
1603
|
+
assert.equal(calls.length, 2, "stream rejection retries once without the effort");
|
|
1604
|
+
assert.equal("reasoningEffort" in calls[1], false);
|
|
1605
|
+
});
|
|
1606
|
+
|
|
1607
|
+
test("POST /api/dsh-mneme/test-model keeps real failures red and never reports a phantom downgrade", async () => {
|
|
1608
|
+
// 非档位原因的失败绝不能被兜底吞掉:真连不上还得是 502、还得带原始原因。
|
|
1609
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1610
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1611
|
+
const before = MOCK_LLM.streamCalls.length;
|
|
1612
|
+
const res = new FakeRes();
|
|
1613
|
+
await route.handler(
|
|
1614
|
+
req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "bad-model", reasoningEffort: "low" }),
|
|
1615
|
+
res
|
|
1616
|
+
);
|
|
1617
|
+
assert.equal(res.statusCode, 502);
|
|
1618
|
+
const data = JSON.parse(res.body);
|
|
1619
|
+
assert.equal(data.ok, false);
|
|
1620
|
+
assert.match(data.error, /AUTH_FAILED/);
|
|
1621
|
+
assert.equal(data.effortDropped, undefined, "a genuine failure is not a downgrade");
|
|
1622
|
+
assert.equal(MOCK_LLM.streamCalls.length - before, 1, "non-effort failures are not retried blindly");
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
test("POST /api/dsh-mneme/test-model reports a thrown transport failure as-is (catch path)", async () => {
|
|
1626
|
+
// 错误以异常形式到达时(socket 断开 / provider 崩溃)走 catch 路径:谎报成功不行,
|
|
1627
|
+
// 拿 without-effort 的裸奔重试去掩盖真故障更不行——「档位被拒才重试」不能退化成
|
|
1628
|
+
// 「见到错误就重试」,否则真故障会被一次盲重试糊住(还多烧一次配额)。
|
|
1521
1629
|
const { routes } = setupLlm(MOCK_LLM);
|
|
1522
1630
|
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1631
|
+
const before = MOCK_LLM.streamCalls.length;
|
|
1523
1632
|
const res = new FakeRes();
|
|
1524
|
-
await route.handler(
|
|
1633
|
+
await route.handler(
|
|
1634
|
+
req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "crash-model", reasoningEffort: "low" }),
|
|
1635
|
+
res
|
|
1636
|
+
);
|
|
1525
1637
|
assert.equal(res.statusCode, 502);
|
|
1526
1638
|
const data = JSON.parse(res.body);
|
|
1527
1639
|
assert.equal(data.ok, false);
|
|
1528
|
-
assert.match(data.error, /
|
|
1640
|
+
assert.match(data.error, /socket hang up/, "the thrown cause reaches the panel verbatim");
|
|
1641
|
+
assert.equal(data.effortDropped, undefined, "a thrown failure is not a downgrade");
|
|
1642
|
+
assert.equal(MOCK_LLM.streamCalls.length - before, 1, "a thrown failure is not retried blindly either");
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
test("POST /api/dsh-mneme/test-model omits effortDropped when no effort was configured", async () => {
|
|
1646
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1647
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1648
|
+
const res = new FakeRes();
|
|
1649
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
|
|
1650
|
+
assert.equal(res.statusCode, 200);
|
|
1651
|
+
const data = JSON.parse(res.body);
|
|
1652
|
+
assert.equal(data.effortDropped, undefined, "no effort configured -> nothing was dropped");
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
// 防分叉静态锁(与 #405 的归一逻辑同款):档位拒收的判据与降级语义全仓只有一份
|
|
1656
|
+
// ——dream.js 的 EFFORT_REJECT_RE + withEffortFallback。api.js 手写正则或自己做
|
|
1657
|
+
// 重试,两条路必然漂移(漂移方向就是「探针与服务端行为不一致」,本次修的就是这个)。
|
|
1658
|
+
test("api.js reuses the single effort-rejection source instead of forking it", () => {
|
|
1659
|
+
const src = readFileSync(new URL("../src/api.js", import.meta.url), "utf8");
|
|
1660
|
+
assert.match(
|
|
1661
|
+
src,
|
|
1662
|
+
/import \{[^}]*withEffortFallback[^}]*\} from "\.\/dream\.js"/,
|
|
1663
|
+
"the probe must reuse dream.js's withEffortFallback instead of hand-rolling a retry"
|
|
1664
|
+
);
|
|
1665
|
+
assert.match(
|
|
1666
|
+
src,
|
|
1667
|
+
/import \{[^}]*EFFORT_REJECT_RE[^}]*\} from "\.\/dream\.js"/,
|
|
1668
|
+
"the rejection predicate must come from dream.js (single source), not a local regex"
|
|
1669
|
+
);
|
|
1670
|
+
assert.ok(
|
|
1671
|
+
!/\/reasoning\[/.test(src),
|
|
1672
|
+
"api.js must not carry its own copy of the effort-rejection pattern"
|
|
1673
|
+
);
|
|
1674
|
+
// 降级语义只能有一处:探针内不得再出现第二个「无档位重试」判据。
|
|
1675
|
+
const attempts = src.split("withEffortFallback(").length - 1;
|
|
1676
|
+
assert.equal(attempts, 1, `the probe must route through withEffortFallback exactly once (got ${attempts})`);
|
|
1529
1677
|
});
|
|
1530
1678
|
|
|
1531
1679
|
test("POST /api/dsh-mneme/test-model resolves empty body against the consolidation route", async () => {
|
|
@@ -1723,3 +1871,92 @@ test("GET /api/dsh-mneme/inject-preview: returns last assembly snapshot (issue #
|
|
|
1723
1871
|
assert.equal(res.statusCode, 404);
|
|
1724
1872
|
injector(); // 快照是模块全局:用完即清,不污染后续用例
|
|
1725
1873
|
});
|
|
1874
|
+
|
|
1875
|
+
// --- /pipeline:观察旁挂能真的挂进投影(CodeRabbit #458 F1 的锁)--------------
|
|
1876
|
+
|
|
1877
|
+
test("GET /api/dsh-mneme/pipeline 带观察旁挂:observationsById 真的进了投影而不是被 catch 吞掉", async () => {
|
|
1878
|
+
// 锁 F1:ids 原本声明在账本分支的块级作用域里,旁挂块一引用就 ReferenceError,
|
|
1879
|
+
// 被 catch 静默吞成「没有观察」——面板上一切正常,fix-step-tool 提议却永远少一类。
|
|
1880
|
+
const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import("node:fs");
|
|
1881
|
+
const { tmpdir } = await import("node:os");
|
|
1882
|
+
const { join } = await import("node:path");
|
|
1883
|
+
const { saveObservations } = await import("../src/pipeline-observations.js");
|
|
1884
|
+
|
|
1885
|
+
const root = mkdtempSync(join(tmpdir(), "mo-api-f1-"));
|
|
1886
|
+
try {
|
|
1887
|
+
const project = join(root, "proj");
|
|
1888
|
+
const pipeline = {
|
|
1889
|
+
id: "demo-f1",
|
|
1890
|
+
name: "演示F1",
|
|
1891
|
+
project: "someone/repo",
|
|
1892
|
+
version: 0,
|
|
1893
|
+
status: "active",
|
|
1894
|
+
done: "做完",
|
|
1895
|
+
trigger: [{ kind: "phrase", value: "跑一下" }],
|
|
1896
|
+
steps: [{
|
|
1897
|
+
id: "S1",
|
|
1898
|
+
phase: "做",
|
|
1899
|
+
title: "跑测试",
|
|
1900
|
+
when: "有人要跑",
|
|
1901
|
+
do: "npm test",
|
|
1902
|
+
who: "main",
|
|
1903
|
+
tool: "npm test",
|
|
1904
|
+
done: "测试全绿",
|
|
1905
|
+
risk: "R0",
|
|
1906
|
+
needs: [],
|
|
1907
|
+
why: "没有它后面没得跑",
|
|
1908
|
+
evidence: ["跑过"]
|
|
1909
|
+
}]
|
|
1910
|
+
};
|
|
1911
|
+
// 项目层工作流(<project>/.mneme/pipelines/*.json)
|
|
1912
|
+
mkdirSync(join(project, ".mneme", "pipelines"), { recursive: true });
|
|
1913
|
+
writeFileSync(join(project, ".mneme", "pipelines", "demo-f1.json"), JSON.stringify(pipeline));
|
|
1914
|
+
// 运行账:<project>/.mneme/pipelines/<id>.runs.jsonl(有账才走到旁挂分支)。
|
|
1915
|
+
// 行形状走 normalizeRun 契约:run.at/by + step.at 全必填(时间不补默认)。
|
|
1916
|
+
writeFileSync(
|
|
1917
|
+
join(project, ".mneme", "pipelines", "demo-f1.runs.jsonl"),
|
|
1918
|
+
JSON.stringify({ at: "2026-10-17T01:00:00.000Z", by: "main", steps: [{ id: "S1", at: "2026-10-17T01:00:00.000Z", skipped: false, detoured: false, refuted: false }] }) + "\n"
|
|
1919
|
+
);
|
|
1920
|
+
// 观察旁挂:<memoryDir>/observations/<项目键>/demo-f1.jsonl。项目键走 candidates.js
|
|
1921
|
+
// 的 projectKey(projectRoot)(sha256 前 8 位 + 清洗名),与 api.js 同源——不能用
|
|
1922
|
+
// 裸 observations/ 目录,那不是 api 侧会查的桶。
|
|
1923
|
+
const { projectKey } = await import("../src/candidates.js");
|
|
1924
|
+
const memoryDir = join(root, "memory");
|
|
1925
|
+
const odir = join(memoryDir, "observations", projectKey(project));
|
|
1926
|
+
const saved = saveObservations({
|
|
1927
|
+
dir: odir,
|
|
1928
|
+
pipelineId: "demo-f1",
|
|
1929
|
+
confirmAt: "2026-10-17T02:00:00.000Z",
|
|
1930
|
+
activities: [{ key: "git push", count: 2 }]
|
|
1931
|
+
});
|
|
1932
|
+
assert.equal(saved.ok, true);
|
|
1933
|
+
|
|
1934
|
+
const { routes } = setup(undefined, "", { memoryDir });
|
|
1935
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/pipeline");
|
|
1936
|
+
const res = new FakeRes();
|
|
1937
|
+
await route.handler(req(`/api/dsh-mneme/pipeline?id=demo-f1&project=${encodeURIComponent(project)}`), res);
|
|
1938
|
+
assert.equal(res.statusCode, 200);
|
|
1939
|
+
const data = JSON.parse(res.body);
|
|
1940
|
+
const row = (data.available || []).find((r) => r.id === "demo-f1");
|
|
1941
|
+
assert.ok(row, "工作流要出现在清单里");
|
|
1942
|
+
assert.ok(data.pipeline, "选中投影要存在");
|
|
1943
|
+
// F1 的直接断言:观察旁挂真的喂进了 suggestions 的判据(修复前 observationsById
|
|
1944
|
+
// 恒为 {}——ReferenceError 被 catch 吞掉,观察永远空)。
|
|
1945
|
+
// 观察里只写了 git push,而 S1 声明 tool=npm test 且账本确认走过 1 次 → 矛盾
|
|
1946
|
+
// 成立,fix-step-tool 必须冒头(下方正向锁);观察为空时它不该冒,那是
|
|
1947
|
+
// pipeline.test.js 的判据锁。
|
|
1948
|
+
// runsState 是账本健康度(RUNS_STATES: absent/empty/ok/partial/broken),
|
|
1949
|
+
// 不是节点状态——账本一行合法 run 即为 ok。节点级 trusted 由 deriveState 管,
|
|
1950
|
+
// 在 summarizePipeline 的 perNode 里,这里不重复锁。
|
|
1951
|
+
assert.equal(row.runsState, "ok", "账本一行合法 run,健康度应是 ok");
|
|
1952
|
+
// suggestions 在投影顶层(不在 pipeline 对象下)。
|
|
1953
|
+
assert.ok(Array.isArray(data.suggestions), "投影必须带 suggestions 数组");
|
|
1954
|
+
// 正向锁:观察里只有 git push、S1 声明 npm test 且确认走过 → fix-step-tool 必须冒头。
|
|
1955
|
+
// F1 未修时 observationsById 恒为空,这条断言必挂——正是要抓的回归。
|
|
1956
|
+
const fix = data.suggestions.find((s) => s.kind === "fix-step-tool");
|
|
1957
|
+
assert.ok(fix, "观察进了判据(npm test 未见于观察)→ 必须有 fix-step-tool 提议");
|
|
1958
|
+
assert.equal(fix.stepId, "S1");
|
|
1959
|
+
} finally {
|
|
1960
|
+
rmSync(root, { recursive: true, force: true });
|
|
1961
|
+
}
|
|
1962
|
+
});
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync, readdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
decideDetour,
|
|
8
|
+
scanIrreversible,
|
|
9
|
+
projectKey,
|
|
10
|
+
candidateDir,
|
|
11
|
+
candidateId,
|
|
12
|
+
validateCandidate,
|
|
13
|
+
loadCandidates,
|
|
14
|
+
saveCandidate,
|
|
15
|
+
recordDetour,
|
|
16
|
+
maybePropose,
|
|
17
|
+
archiveStale,
|
|
18
|
+
promoteCandidate,
|
|
19
|
+
summarizeCandidate,
|
|
20
|
+
readCandidateDoc,
|
|
21
|
+
removeCandidate,
|
|
22
|
+
IRREVERSIBLE_PATTERNS,
|
|
23
|
+
PROMOTE_MIN_SEEN,
|
|
24
|
+
STALE_AFTER_DAYS
|
|
25
|
+
} from "../src/candidates.js";
|
|
26
|
+
|
|
27
|
+
// 这一组守的是 M.O. 的**候选层与绕行判定**——这一版解决的是「刻板的图会让 agent 失去自主性」。
|
|
28
|
+
// 核心规则一句话:自主性住在 R0/R1,刚性只住在它必须住的地方。
|
|
29
|
+
// 里面有几条是**踩过坑才写下的**(子命令误拦、requires/forbids 语义搞反),
|
|
30
|
+
// 所以那些用例不是举例子,是回归锁。
|
|
31
|
+
|
|
32
|
+
function withDir(fn) {
|
|
33
|
+
const dir = mkdtempSync(join(tmpdir(), "mo-cand-"));
|
|
34
|
+
try {
|
|
35
|
+
return fn(dir);
|
|
36
|
+
} finally {
|
|
37
|
+
rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const STEPS = [
|
|
42
|
+
{ title: "看仓库干不干净", when: "发版前", do: "git status", done: "无未提交", risk: "R0" },
|
|
43
|
+
{ title: "跳过 release-prep", when: "上一次已 bump", do: "手工改版本号", done: "版本号对", risk: "R1" }
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const okCandidate = (over = {}) => ({
|
|
47
|
+
id: "cand-abcdef123456",
|
|
48
|
+
origin: "detour",
|
|
49
|
+
steps: STEPS,
|
|
50
|
+
reason: "仓库干净,release-prep 这步多余",
|
|
51
|
+
counterfactual: null,
|
|
52
|
+
seen: [],
|
|
53
|
+
status: "collecting",
|
|
54
|
+
createdAt: new Date().toISOString(),
|
|
55
|
+
...over
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── 分档:B 档的核心规则 ────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
test("R0/R1 且不跨闸门、兜底未命中 → 记账先走(自主性住在这里)", () => {
|
|
61
|
+
assert.equal(decideDetour({ risk: "R0", actions: ["gh pr view 438", "ls"] }).decidedBy, "model");
|
|
62
|
+
assert.equal(decideDetour({ risk: "R1", actions: ["npm test", "git status"] }).decidedBy, "model");
|
|
63
|
+
assert.equal(decideDetour({ risk: "R1", actions: [] }).decidedBy, "model");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("R2/R3 → 停下等点头(刚性只住在这里)", () => {
|
|
67
|
+
assert.equal(decideDetour({ risk: "R2", actions: [] }).decidedBy, "human");
|
|
68
|
+
assert.equal(decideDetour({ risk: "R3", actions: [] }).decidedBy, "human");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("兜底清单不看自报:自报 R0 但命令里有对外动作,照样拦", () => {
|
|
72
|
+
// 这条是「模型在自己上下文里给自己打分不可靠」的直接应用——不给单点判断。
|
|
73
|
+
const r = decideDetour({ risk: "R0", actions: ["git push origin main"] });
|
|
74
|
+
assert.equal(r.decidedBy, "human");
|
|
75
|
+
assert.equal(r.risk, "R2", "命中兜底时风险档要按较高者记,结算卡上才看得准");
|
|
76
|
+
assert.deepEqual(r.scan.matches.map((m) => m.id), ["git-push"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("自报非法风险档 → 按最保守处理,不是放行", () => {
|
|
80
|
+
// 一个模型说「我的风险是 R9」时,正确的反应是别信它,而不是当成没风险。
|
|
81
|
+
assert.equal(decideDetour({ risk: "R9", actions: [] }).decidedBy, "human");
|
|
82
|
+
assert.equal(decideDetour({ risk: undefined, actions: [] }).decidedBy, "model");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("闸门保护与风险档无关:纯只读绕行只要跨闸门也停下", () => {
|
|
86
|
+
// 闸门本来就放在「错了很难撤」的地方,绕开它等于把安全阀绕掉——
|
|
87
|
+
// 哪怕这条绕行本身全是只读动作。
|
|
88
|
+
const r = decideDetour({ risk: "R0", actions: ["ls"], crossesGate: true });
|
|
89
|
+
assert.equal(r.decidedBy, "human");
|
|
90
|
+
assert.match(r.reason, /闸门/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── 兜底清单:子命令必须分得开 ──────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
test("兜底清单分得清只读与写:同一工具的子命令,性质相反", () => {
|
|
96
|
+
// 回归锁:第一版把工具名整条当动作,`gh pr view`(只看一眼 PR)也被拦下了,
|
|
97
|
+
// 直接毁掉「自主性住在 R0/R1」。工具名 + 子命令才是动作。
|
|
98
|
+
const readOnly = [
|
|
99
|
+
"gh pr view 438", "gh pr list", "gh pr checks", "gh pr diff",
|
|
100
|
+
"gh issue view 12", "gh release view v1", "gh auth status",
|
|
101
|
+
"gh api repos/x/y", "gh repo view", "gh run list",
|
|
102
|
+
"git tag -l", "git status", "npm test",
|
|
103
|
+
"curl https://example.com", "Remove-Item x.txt"
|
|
104
|
+
];
|
|
105
|
+
for (const cmd of readOnly) {
|
|
106
|
+
assert.equal(scanIrreversible([cmd]).hit, false, `只读的 ${cmd} 不该被拦`);
|
|
107
|
+
}
|
|
108
|
+
const writes = [
|
|
109
|
+
"git push origin main", "git tag v1.0.0", "git tag -d v1",
|
|
110
|
+
"gh pr create --title x", "gh pr merge 438", "gh pr close 438",
|
|
111
|
+
"gh issue create -t x", "gh release create v1",
|
|
112
|
+
"gh api -X POST /repos/x/y", "gh api -f name=v",
|
|
113
|
+
"gh auth login", "gh secret set FOO", "gh repo edit", "gh workflow run ci.yml",
|
|
114
|
+
"npm publish", "yarn publish", "pnpm publish",
|
|
115
|
+
"curl -X POST https://x.com", "curl --data a=1 https://x.com",
|
|
116
|
+
"Remove-Item -Recurse -Force x", "rm -rf build", "drop table memories"
|
|
117
|
+
];
|
|
118
|
+
for (const cmd of writes) {
|
|
119
|
+
assert.equal(scanIrreversible([cmd]).hit, true, `要写的 ${cmd} 必须被拦`);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("requires 与 forbids 的语义不能混:默认安全的动作只能用 requires", () => {
|
|
124
|
+
// 回归锁:`gh api` 不写方法时是 GET(安全),只有显式写方法与字段才危险。
|
|
125
|
+
// 用 forbids 表达「默认安全」是错的——不写方法时两个特征都不出现,
|
|
126
|
+
// forbids 不成立、规则照样命中,于是只读调用被误拦。
|
|
127
|
+
const api = IRREVERSIBLE_PATTERNS.find((p) => p.id === "gh-api-write");
|
|
128
|
+
assert.ok(api && Array.isArray(api.requires), "gh api 那条必须用 requires");
|
|
129
|
+
assert.equal(api.forbids, undefined, "同一语义不能既用 requires 又用 forbids");
|
|
130
|
+
// 而「默认危险、列出只读例外」的动作用 forbids。
|
|
131
|
+
const pr = IRREVERSIBLE_PATTERNS.find((p) => p.id === "gh-pr-write");
|
|
132
|
+
assert.ok(pr && Array.isArray(pr.forbids));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("扫多条动作:任一命中即算命中,同一条不重复算", () => {
|
|
136
|
+
const r = scanIrreversible(["ls", "git push", "git push --tags"]);
|
|
137
|
+
assert.equal(r.hit, true);
|
|
138
|
+
assert.equal(r.matches.filter((m) => m.id === "git-push").length, 1);
|
|
139
|
+
assert.equal(scanIrreversible([]).hit, false);
|
|
140
|
+
assert.equal(scanIrreversible(null).hit, false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ── 候选的住址与身份 ───────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
test("项目键稳定、不含绝对路径(文件名里不能有本机用户名)", () => {
|
|
146
|
+
const a = projectKey("C:\\Users\\someone\\Desktop\\dsh-mneme");
|
|
147
|
+
const b = projectKey("C:\\Users\\someone\\Desktop\\dsh-mneme");
|
|
148
|
+
const other = projectKey("C:\\Users\\someone\\Desktop\\another-project");
|
|
149
|
+
assert.equal(a, b, "同一个项目每次算到同一个键");
|
|
150
|
+
assert.notEqual(a, other, "不同项目必须分开");
|
|
151
|
+
assert.equal(a.includes("someone"), false, "不能把用户名带进文件名");
|
|
152
|
+
assert.ok(a.startsWith("dsh-mneme-"), "保留目录名便于人读");
|
|
153
|
+
assert.equal(projectKey(""), null);
|
|
154
|
+
assert.equal(projectKey(null), null);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("项目键对两种分隔符一视同仁:Windows 路径不得退化成整条路径", () => {
|
|
158
|
+
// 回归锁,指向一个**只在 posix 上现形**的缺陷:原实现借 path.basename() 取目录名,
|
|
159
|
+
// 它跟着运行平台走——Linux 上 `C:\Users\someone\Desktop\dsh-mneme` 整条被当成
|
|
160
|
+
// 文件名,清洗后 C--Users-someone-Desktop-dsh-mne,用户名的残片进了目录名。
|
|
161
|
+
// 这正是 PR #439 里 ubuntu 四腿全红、windows 两腿全绿的那条。
|
|
162
|
+
//
|
|
163
|
+
// **这条锁在 Windows 上是绿的,且永远抓不住回退**:win32.basename 两种分隔符都认,
|
|
164
|
+
// 旧实现在本机结果就是对的。它的杀伤力只在 ubuntu 矩阵腿上——别删了它以为没事。
|
|
165
|
+
const nameOf = (key) => key.replace(/-[0-9a-f]{8}$/, "");
|
|
166
|
+
const bs = projectKey("C:\\Users\\someone\\Desktop\\dsh-mneme");
|
|
167
|
+
const fwd = projectKey("C:/Users/someone/Desktop/dsh-mneme");
|
|
168
|
+
const trail = projectKey("C:\\Users\\someone\\Desktop\\dsh-mneme\\");
|
|
169
|
+
assert.equal(nameOf(bs), "dsh-mneme", "反斜杠路径要取到目录名");
|
|
170
|
+
assert.equal(nameOf(fwd), "dsh-mneme", "正斜杠路径要取到目录名");
|
|
171
|
+
assert.equal(nameOf(trail), "dsh-mneme", "尾随分隔符不改变结果");
|
|
172
|
+
assert.equal(bs.includes("someone"), false, "用户名不得进目录名");
|
|
173
|
+
assert.equal(bs.includes("-Users-"), false, "整条路径不得被清洗成目录名");
|
|
174
|
+
assert.match(bs, /^[A-Za-z0-9._-]+$/, "目录名只许出现安全字符");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
test("候选的家在 memoryDir 下、按项目分桶;拿不到项目根就没有家", () => {
|
|
179
|
+
const dir = candidateDir({ memoryDir: "~/.dsh/memory", projectRoot: "C:\\x\\y" });
|
|
180
|
+
assert.ok(dir && dir.includes("candidates"), "落在 candidates/ 下");
|
|
181
|
+
assert.equal(dir.includes("C:\\x\\y"), false, "路径不进字符串");
|
|
182
|
+
assert.equal(candidateDir({ memoryDir: "/tmp/m", projectRoot: null }), null);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("候选 id 由步骤形状决定:同一段路走两次会落到同一条上", () => {
|
|
186
|
+
// 这正是「复用第二次才算类型,第一次只是实例」的落点——
|
|
187
|
+
// 如果每次绕行都生成新 id,第二次永远并不到第一次上,门槛就永远够不着。
|
|
188
|
+
const id1 = candidateId(STEPS);
|
|
189
|
+
const id2 = candidateId(STEPS.map((s) => ({ ...s })));
|
|
190
|
+
const id3 = candidateId([STEPS[0], { ...STEPS[1], title: "换了个做法" }]);
|
|
191
|
+
assert.equal(id1, id2);
|
|
192
|
+
assert.notEqual(id1, id3);
|
|
193
|
+
assert.match(id1, /^cand-[0-9a-f]{12}$/);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ── 校验:候选松,但 reason 一个都不能少 ────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
test("候选比定稿松,但「写不出理由就不许绕」是硬要求", () => {
|
|
199
|
+
assert.equal(validateCandidate(okCandidate()).ok, true);
|
|
200
|
+
// 定稿要求 id/name/project/version/status/done/trigger 一个不缺;候选只要这四样。
|
|
201
|
+
const minimal = { id: "cand-abcdef123456", origin: "manual", steps: STEPS, reason: "为什么", seen: [], status: "collecting" };
|
|
202
|
+
assert.equal(validateCandidate(minimal).ok, true, "候选不该要求定稿那一整套字段");
|
|
203
|
+
|
|
204
|
+
const noReason = validateCandidate(okCandidate({ reason: " " }));
|
|
205
|
+
assert.equal(noReason.ok, false);
|
|
206
|
+
assert.ok(noReason.problems.some((p) => p.includes("reason 必填")));
|
|
207
|
+
|
|
208
|
+
assert.equal(validateCandidate(okCandidate({ id: "bad-id" })).ok, false);
|
|
209
|
+
assert.equal(validateCandidate(okCandidate({ origin: "telepathy" })).ok, false);
|
|
210
|
+
assert.equal(validateCandidate(okCandidate({ status: "whatever" })).ok, false);
|
|
211
|
+
assert.equal(validateCandidate(okCandidate({ steps: [] })).ok, false);
|
|
212
|
+
assert.equal(validateCandidate(okCandidate({ steps: [{ title: "x" }] })).ok, false, "步骤缺字段要报出来");
|
|
213
|
+
assert.equal(validateCandidate(null).ok, false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("坏文件不静默跳过:与定稿同一纪律", () => {
|
|
217
|
+
withDir((dir) => {
|
|
218
|
+
saveCandidate(dir, okCandidate());
|
|
219
|
+
assert.equal(loadCandidates(dir).length, 1);
|
|
220
|
+
rmSync(join(dir, "cand-abcdef123456.json"));
|
|
221
|
+
writeFileSync(join(dir, "broken.json"), "{ not json", "utf8");
|
|
222
|
+
assert.throws(() => loadCandidates(dir), /broken\.json: JSON 解析失败/);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ── 绕行 → 候选:两次才算数 ─────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
test("第一次绕行:落成候选,但还够不着提议", () => {
|
|
229
|
+
withDir((dir) => {
|
|
230
|
+
const r = recordDetour(dir, { steps: STEPS, reason: "仓库干净,这步多余", outcome: "worked", evidence: ["测试全绿"], runId: "r1" });
|
|
231
|
+
assert.equal(r.created, true);
|
|
232
|
+
assert.equal(r.promotedNow, false);
|
|
233
|
+
assert.equal(r.candidate.status, "collecting");
|
|
234
|
+
assert.equal(r.candidate.seen.length, 1);
|
|
235
|
+
assert.equal(readdirSync(dir).filter((f) => f.endsWith(".json")).length, 1);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test(`第二次走同一段路:并进同一条,翻成 proposed(门槛 ${PROMOTE_MIN_SEEN} 次)`, () => {
|
|
240
|
+
withDir((dir) => {
|
|
241
|
+
recordDetour(dir, { steps: STEPS, reason: "同上", outcome: "worked", evidence: ["绿"], runId: "r1" });
|
|
242
|
+
const b = recordDetour(dir, { steps: STEPS, reason: "同上", outcome: "worked", evidence: ["又绿"], runId: "r2" });
|
|
243
|
+
assert.equal(b.created, false, "不该新建——同一段路只有一条候选");
|
|
244
|
+
assert.equal(b.promotedNow, true);
|
|
245
|
+
assert.equal(b.candidate.status, "proposed");
|
|
246
|
+
assert.equal(b.candidate.seen.length, 2);
|
|
247
|
+
assert.equal(readdirSync(dir).filter((f) => f.endsWith(".json")).length, 1);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("两次里只要有一次没跑通,就够不着提议", () => {
|
|
252
|
+
// 「复用第二次才算类型」的前提是**那次真的走通了**;两次都失败的路不该被提议进图。
|
|
253
|
+
const c = okCandidate({ seen: [{ outcome: "failed" }, { outcome: "unknown" }] });
|
|
254
|
+
assert.equal(maybePropose(c), false);
|
|
255
|
+
assert.equal(c.status, "collecting");
|
|
256
|
+
const d = okCandidate({ seen: [{ outcome: "failed" }, { outcome: "worked" }] });
|
|
257
|
+
assert.equal(maybePropose(d), true, "有一次跑通就算走通了这条路");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("失败也留痕:走不通是知识,不是垃圾", () => {
|
|
261
|
+
withDir((dir) => {
|
|
262
|
+
recordDetour(dir, { steps: STEPS, reason: "试试", outcome: "failed", evidence: ["C2 直接报错"] });
|
|
263
|
+
const [c] = loadCandidates(dir);
|
|
264
|
+
assert.equal(c.seen[0].outcome, "failed");
|
|
265
|
+
assert.deepEqual(c.seen[0].evidence, ["C2 直接报错"]);
|
|
266
|
+
const row = summarizeCandidate(c);
|
|
267
|
+
assert.equal(row.failed, 1);
|
|
268
|
+
assert.equal(row.worked, 0);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ── 归档与升格 ─────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
test(`只走过一次、${STALE_AFTER_DAYS} 天没再被走到 → 归档;归档不删文件`, () => {
|
|
275
|
+
withDir((dir) => {
|
|
276
|
+
const old = okCandidate({
|
|
277
|
+
seen: [{ outcome: "worked", at: new Date(Date.now() - (STALE_AFTER_DAYS + 10) * 86400000).toISOString() }]
|
|
278
|
+
});
|
|
279
|
+
saveCandidate(dir, old);
|
|
280
|
+
const archived = archiveStale(dir, {});
|
|
281
|
+
assert.deepEqual(archived, [old.id]);
|
|
282
|
+
const after = loadCandidates(dir).find((c) => c.id === old.id);
|
|
283
|
+
assert.equal(after.status, "dropped");
|
|
284
|
+
assert.match(after.droppedReason, /没再被走到/);
|
|
285
|
+
assert.ok(readdirSync(dir).some((f) => f.startsWith(old.id)), "文件还在——留着当负清单的邻居");
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("归档只碰 collecting:够格的和已归档的都不动", () => {
|
|
290
|
+
withDir((dir) => {
|
|
291
|
+
const proposed = okCandidate({ id: "cand-proposed0001", status: "proposed", seen: [{ outcome: "worked", at: new Date(Date.now() - 100 * 86400000).toISOString() }] });
|
|
292
|
+
const fresh = okCandidate({ id: "cand-fresh0000001", seen: [{ outcome: "worked", at: new Date().toISOString() }] });
|
|
293
|
+
saveCandidate(dir, proposed);
|
|
294
|
+
saveCandidate(dir, fresh);
|
|
295
|
+
assert.deepEqual(archiveStale(dir, {}), []);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("升格:补齐字段后必须过定稿的 validatePipeline", () => {
|
|
300
|
+
const cand = okCandidate();
|
|
301
|
+
const good = promoteCandidate(cand, { name: "发版(绕开 release-prep)", project: "slow-stack/mneme", done: "发出去且 registry 对得上" });
|
|
302
|
+
assert.equal(good.ok, true);
|
|
303
|
+
assert.equal(typeof good.pipeline.id, "string");
|
|
304
|
+
assert.equal(good.pipeline.id.startsWith("cand-"), false, "句柄要去掉 cand- 前缀");
|
|
305
|
+
assert.deepEqual(good.pipeline.steps[1].needs, ["S1"], "needs 要串成链,否则定稿那边会报图不连通");
|
|
306
|
+
assert.equal(good.pipeline.version, 0);
|
|
307
|
+
// 升格不是降低标准,是补齐标准:缺 project 照样过不了。
|
|
308
|
+
const bad = promoteCandidate(cand, { name: "缺 project" });
|
|
309
|
+
assert.equal(bad.ok, false);
|
|
310
|
+
assert.ok(bad.problems.some((p) => p.includes("project")));
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("升格带上候选的反事实——它是准入判据,不是备注", () => {
|
|
314
|
+
const cand = okCandidate({ counterfactual: "没有它,C 阶段会拿旧版本号发出去" });
|
|
315
|
+
const r = promoteCandidate(cand, { name: "x", project: "a/b" });
|
|
316
|
+
assert.equal(r.ok, true);
|
|
317
|
+
assert.deepEqual(r.pipeline.steps[0].evidence, ["没有它,C 阶段会拿旧版本号发出去"]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ── 投影与契约 ─────────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
test("清单行取整段路里最高的风险档(最坏情况才是它该被看待的档位)", () => {
|
|
323
|
+
const row = summarizeCandidate(okCandidate({ steps: [STEPS[0], STEPS[1], { title: "问人", when: "w", do: "d", done: "x", risk: "R2" }] }));
|
|
324
|
+
assert.equal(row.risk, "R2");
|
|
325
|
+
assert.equal(row.steps, 3);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("契约把「这一刀不做的事」也写进去,免得以后有人以为忘了做", () => {
|
|
329
|
+
withDir((dir) => {
|
|
330
|
+
const doc = readCandidateDoc({ memoryDir: dir, projectRoot: "C:\\x\\y" });
|
|
331
|
+
assert.equal(doc.contract, "mneme.candidates/v1");
|
|
332
|
+
assert.deepEqual(doc.boundaries, { autoPromote: false, autoApprove: false, injectIntoMemory: false });
|
|
333
|
+
assert.deepEqual(doc.counts, { collecting: 0, proposed: 0, promoted: 0, dropped: 0 });
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("removeCandidate 只删指定的那条", () => {
|
|
338
|
+
withDir((dir) => {
|
|
339
|
+
saveCandidate(dir, okCandidate());
|
|
340
|
+
saveCandidate(dir, okCandidate({ id: "cand-second000001" }));
|
|
341
|
+
assert.equal(removeCandidate(dir, "cand-abcdef123456"), true);
|
|
342
|
+
assert.equal(loadCandidates(dir).length, 1);
|
|
343
|
+
assert.equal(removeCandidate(dir, "cand-nope00000000"), false);
|
|
344
|
+
});
|
|
345
|
+
});
|