@modusensus/dsh-mneme 0.9.2 → 0.9.4
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 +11 -7
- package/docs/CONFIGURATION.md +9 -2
- package/docs/WORKFLOWS.md +70 -3
- package/lib/api.js +178 -3
- package/lib/capabilities.js +1 -1
- package/lib/client.js +315 -27
- package/lib/config.js +70 -3
- package/lib/dream.js +1 -1
- package/lib/index.js +21 -1
- package/lib/inject.js +10 -25
- package/lib/lang.js +25 -0
- package/lib/organize.js +4 -2
- package/lib/pipeline-nudge-auto.js +126 -0
- package/lib/pipeline-nudge.js +337 -28
- package/lib/pipeline-runs.js +1 -1
- package/lib/pipeline.js +4 -4
- package/lib/profile-extract.js +161 -0
- package/lib/scope.js +30 -1
- package/lib/service.js +3 -2
- package/lib/settings.js +22 -14
- package/lib/summarize.js +64 -3
- package/lib/tools.js +240 -1
- package/lib/user-settings.js +78 -0
- package/package.json +1 -1
- package/src/api.js +178 -3
- package/src/capabilities.js +1 -1
- package/src/config.js +70 -3
- package/src/dream.js +1 -1
- package/src/index.js +21 -1
- package/src/inject.js +10 -25
- package/src/lang.js +25 -0
- package/src/organize.js +4 -2
- package/src/pipeline-nudge-auto.js +126 -0
- package/src/pipeline-nudge.js +337 -28
- package/src/pipeline-runs.js +1 -1
- package/src/pipeline.js +4 -4
- package/src/profile-extract.js +161 -0
- package/src/scope.js +30 -1
- package/src/service.js +3 -2
- package/src/settings.js +22 -14
- package/src/summarize.js +64 -3
- package/src/tools.js +240 -1
- package/src/user-settings.js +78 -0
- package/test/api-nudge-route.test.js +259 -0
- package/test/api.test.js +19 -1
- package/test/client.test.js +115 -7
- package/test/lang.test.js +11 -0
- package/test/organize-tool.test.js +232 -0
- package/test/persona-addressing.test.js +83 -0
- package/test/persona-settings.test.js +30 -8
- package/test/pipeline-nudge-auto.test.js +224 -0
- package/test/pipeline-nudge.test.js +161 -6
- package/test/profile-extract.test.js +269 -0
- package/test/scope-optional-service.test.js +110 -0
- package/test/session-usage.test.js +11 -2
- package/test/summarize.test.js +225 -0
- package/test/tools-dto-schema-matrix.test.js +48 -5
- package/test/tools.test.js +6 -1
- package/test/user-settings-preview.test.js +127 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createNudgeAutoRun } from "../src/pipeline-nudge-auto.js";
|
|
4
|
+
|
|
5
|
+
// 这组守的是**触发侧**(v0.9.3):什么时候跑、跑几次、跑挂了怎么办。
|
|
6
|
+
// 挖掘算法本身在 pipeline-nudge.test.js 里守,这里只守接线,所以 runNudge 全部用假的。
|
|
7
|
+
|
|
8
|
+
/** 一个假 ctx:只提供 session/event 的订阅面(真宿主用同一个口)。 */
|
|
9
|
+
function fakeCtx() {
|
|
10
|
+
const listeners = [];
|
|
11
|
+
return {
|
|
12
|
+
logger: { info() {}, warn() {} },
|
|
13
|
+
on(name, fn) {
|
|
14
|
+
if (name !== "session/event") throw new Error("unexpected event: " + name);
|
|
15
|
+
listeners.push(fn);
|
|
16
|
+
return () => {
|
|
17
|
+
const i = listeners.indexOf(fn);
|
|
18
|
+
if (i >= 0) listeners.splice(i, 1);
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
emit(session, event) {
|
|
22
|
+
for (const fn of listeners.slice()) fn(session, event);
|
|
23
|
+
},
|
|
24
|
+
count: () => listeners.length
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sessionAt(cwd) {
|
|
29
|
+
return { header: { cwd } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 把 setTimeout(0) 排的活跑完:本模块刻意不用同步跑(见文件头)。 */
|
|
33
|
+
function tick(ms = 5) {
|
|
34
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 等到条件成立。队列是**链式**排程(跑完一个再 defer 下一个),所以「两个项目都跑完」
|
|
39
|
+
* 至少要等两个宏任务;固定 tick(30) 在满载的 CI 上会假阴性——全量跑时其它测试文件
|
|
40
|
+
* 同时解压 zstd,一次 setTimeout(0) 被拉长就可能超过 30ms。这里只等该等的东西。
|
|
41
|
+
*/
|
|
42
|
+
async function waitFor(predicate, timeout = 2000) {
|
|
43
|
+
const started = Date.now();
|
|
44
|
+
while (!predicate()) {
|
|
45
|
+
if (Date.now() - started > timeout) return false;
|
|
46
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("自动沉淀只认 turn/end:中途事件不排队、不预跑", async () => {
|
|
52
|
+
const ctx = fakeCtx();
|
|
53
|
+
const calls = [];
|
|
54
|
+
const auto = createNudgeAutoRun({
|
|
55
|
+
ctx,
|
|
56
|
+
runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
|
|
57
|
+
now: () => 1_000_000
|
|
58
|
+
});
|
|
59
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/start" });
|
|
60
|
+
ctx.emit(sessionAt("D:/proj"), { type: "message" });
|
|
61
|
+
await tick();
|
|
62
|
+
assert.deepEqual(calls, [], "一轮没结束就不是观测窗口");
|
|
63
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
64
|
+
await tick();
|
|
65
|
+
assert.equal(calls.length, 1);
|
|
66
|
+
auto();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("自动跑永远是「只落提议、不落草稿」:materialize 恒 false", async () => {
|
|
70
|
+
const ctx = fakeCtx();
|
|
71
|
+
const calls = [];
|
|
72
|
+
const auto = createNudgeAutoRun({
|
|
73
|
+
ctx,
|
|
74
|
+
memoryDir: "D:/memory",
|
|
75
|
+
runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
|
|
76
|
+
now: () => 1_000_000
|
|
77
|
+
});
|
|
78
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
79
|
+
await tick();
|
|
80
|
+
assert.equal(calls.length, 1);
|
|
81
|
+
// 这三条是边界,不是偏好:往别人仓库里塞文件必须是人的显式动作。
|
|
82
|
+
assert.equal(calls[0].materialize, false, "自动跑不许落草稿");
|
|
83
|
+
assert.equal(calls[0].persistProposals, true, "提议要落进记忆库");
|
|
84
|
+
assert.equal(calls[0].memoryDir, "D:/memory");
|
|
85
|
+
auto();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("同一个项目在冷却期内不重跑(观测新鲜度,不是防重复写盘)", async () => {
|
|
89
|
+
const ctx = fakeCtx();
|
|
90
|
+
const calls = [];
|
|
91
|
+
let clock = 1_000_000;
|
|
92
|
+
const auto = createNudgeAutoRun({
|
|
93
|
+
ctx,
|
|
94
|
+
runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
|
|
95
|
+
now: () => clock,
|
|
96
|
+
minIntervalMs: 10_000
|
|
97
|
+
});
|
|
98
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
99
|
+
await tick();
|
|
100
|
+
assert.equal(calls.length, 1);
|
|
101
|
+
// 冷却期内:再来几轮也不跑。
|
|
102
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
103
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
104
|
+
await tick();
|
|
105
|
+
assert.equal(calls.length, 1, "十分钟内同一个项目只跑一次");
|
|
106
|
+
// 过了冷却就该跑。
|
|
107
|
+
clock += 20_000;
|
|
108
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
109
|
+
await tick();
|
|
110
|
+
assert.equal(calls.length, 2);
|
|
111
|
+
auto();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("冷却按项目分账:另一个项目不受影响,也不许并成一次", async () => {
|
|
115
|
+
const ctx = fakeCtx();
|
|
116
|
+
const roots = [];
|
|
117
|
+
const auto = createNudgeAutoRun({
|
|
118
|
+
ctx,
|
|
119
|
+
runNudge: (args) => { roots.push(args.projectRoot); return { counts: { savedProposals: 0 } }; },
|
|
120
|
+
now: () => 1_000_000,
|
|
121
|
+
minIntervalMs: 10_000
|
|
122
|
+
});
|
|
123
|
+
ctx.emit(sessionAt("D:/a"), { type: "turn/end" });
|
|
124
|
+
ctx.emit(sessionAt("D:/b"), { type: "turn/end" });
|
|
125
|
+
assert.ok(await waitFor(() => roots.length === 2), "两个项目都该被跑到");
|
|
126
|
+
assert.deepEqual(roots.sort(), ["D:/a", "D:/b"], "两个项目各跑一次,跨项目不并账");
|
|
127
|
+
auto();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("冷却键不区分大小写与尾斜杠(Windows 同一个项目别算成两个)", async () => {
|
|
131
|
+
const ctx = fakeCtx();
|
|
132
|
+
const calls = [];
|
|
133
|
+
let clock = 1_000_000;
|
|
134
|
+
const auto = createNudgeAutoRun({
|
|
135
|
+
ctx,
|
|
136
|
+
runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
|
|
137
|
+
now: () => clock,
|
|
138
|
+
minIntervalMs: 10_000
|
|
139
|
+
});
|
|
140
|
+
ctx.emit(sessionAt("D:/Proj"), { type: "turn/end" });
|
|
141
|
+
await tick();
|
|
142
|
+
ctx.emit(sessionAt("d:\\proj\\"), { type: "turn/end" });
|
|
143
|
+
await tick();
|
|
144
|
+
assert.equal(calls.length, 1, "同一个项目换个写法不该被当成新项目");
|
|
145
|
+
auto();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("挖挂了不许冒泡:session/event 那条链上还挂着摘要与续期", async () => {
|
|
149
|
+
const ctx = fakeCtx();
|
|
150
|
+
let ran = 0;
|
|
151
|
+
const auto = createNudgeAutoRun({
|
|
152
|
+
ctx,
|
|
153
|
+
logger: { info() {}, warn() {} },
|
|
154
|
+
runNudge: () => { ran += 1; throw new Error("boom"); },
|
|
155
|
+
now: () => 1_000_000
|
|
156
|
+
});
|
|
157
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
158
|
+
await assert.doesNotReject(tick().then(() => {}), "定时回调里的异常不许掀翻宿主");
|
|
159
|
+
assert.equal(ran, 1);
|
|
160
|
+
auto();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("没有 cwd 的会话不排队(归属不明就别猜)", async () => {
|
|
164
|
+
const ctx = fakeCtx();
|
|
165
|
+
const calls = [];
|
|
166
|
+
const auto = createNudgeAutoRun({
|
|
167
|
+
ctx,
|
|
168
|
+
runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
|
|
169
|
+
now: () => 1_000_000
|
|
170
|
+
});
|
|
171
|
+
ctx.emit({ header: {} }, { type: "turn/end" });
|
|
172
|
+
ctx.emit(null, { type: "turn/end" });
|
|
173
|
+
await tick();
|
|
174
|
+
assert.deepEqual(calls, []);
|
|
175
|
+
assert.equal(auto.pendingCount(), 0);
|
|
176
|
+
auto();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("dispose 之后:取消订阅、清掉排队的活,runNow 也不再跑", async () => {
|
|
180
|
+
const ctx = fakeCtx();
|
|
181
|
+
const calls = [];
|
|
182
|
+
const auto = createNudgeAutoRun({
|
|
183
|
+
ctx,
|
|
184
|
+
runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
|
|
185
|
+
now: () => 1_000_000
|
|
186
|
+
});
|
|
187
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
188
|
+
auto();
|
|
189
|
+
assert.equal(ctx.count(), 0, "订阅必须被摘掉");
|
|
190
|
+
assert.equal(auto.pendingCount(), 0, "排队的活必须被清掉");
|
|
191
|
+
await tick();
|
|
192
|
+
// 关键:dispose 之前排进 timer 的那一次也不许再跑(否则卸载后还会写盘)。
|
|
193
|
+
assert.deepEqual(calls, [], "卸载之后不许还有一次偷偷的写盘");
|
|
194
|
+
assert.equal(auto.runNow("D:/proj"), null);
|
|
195
|
+
assert.deepEqual(calls, []);
|
|
196
|
+
// 再发事件也不排队了。
|
|
197
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
198
|
+
await tick();
|
|
199
|
+
assert.deepEqual(calls, []);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("runNow 绕开冷却与 timer,手动入口可以直接驱动一次", () => {
|
|
203
|
+
const ctx = fakeCtx();
|
|
204
|
+
const calls = [];
|
|
205
|
+
const auto = createNudgeAutoRun({
|
|
206
|
+
ctx,
|
|
207
|
+
runNudge: (args) => { calls.push(args); return { counts: { savedProposals: 0 } }; },
|
|
208
|
+
now: () => 1_000_000,
|
|
209
|
+
minIntervalMs: 10_000
|
|
210
|
+
});
|
|
211
|
+
const doc = auto.runNow("D:/proj");
|
|
212
|
+
assert.ok(doc, "手动入口要有同步回执");
|
|
213
|
+
assert.equal(calls.length, 1);
|
|
214
|
+
// runNow 刻意**不走冷却**:它是人显式要的一次跑,人比定时器知道自己要什么。
|
|
215
|
+
assert.ok(auto.runNow("D:/proj"));
|
|
216
|
+
assert.equal(calls.length, 2);
|
|
217
|
+
// 但它要把时间记上——否则紧接着的自动跑会立刻再来一次。
|
|
218
|
+
assert.equal(typeof auto.lastRunAt("D:/proj"), "number");
|
|
219
|
+
ctx.emit(sessionAt("D:/proj"), { type: "turn/end" });
|
|
220
|
+
return tick().then(() => {
|
|
221
|
+
assert.equal(calls.length, 2, "runNow 之后冷却生效,自动跑不许立刻重跑");
|
|
222
|
+
auto();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -33,10 +33,12 @@ import {
|
|
|
33
33
|
loadProposals,
|
|
34
34
|
summarizeProposal,
|
|
35
35
|
retirementProposals,
|
|
36
|
-
nudgeFromSessions
|
|
36
|
+
nudgeFromSessions,
|
|
37
|
+
nudgeForProject,
|
|
38
|
+
resolveProjectSessionDir
|
|
37
39
|
} from "../src/pipeline-nudge.js";
|
|
38
40
|
|
|
39
|
-
// 这组测试守的是 M.O.
|
|
41
|
+
// 这组测试守的是 M.O. 的**沉淀链**:一条工作流能不能从真实数据里自己长出来。
|
|
40
42
|
// 三条不许越的分界线(见 src/pipeline-nudge.js 文件头),测试逐条守:
|
|
41
43
|
// 1. 机器只写它真观察到的东西(措辞里不许出现编出来的判据,也不许漏本机路径);
|
|
42
44
|
// 2. 反事实判据的结论**永远不由机器给**(verdict 必须是 null,问题必须带着选项);
|
|
@@ -210,7 +212,7 @@ test("挖掘:至少两个不同会话才算候选;同一段路出现两次
|
|
|
210
212
|
});
|
|
211
213
|
});
|
|
212
214
|
|
|
213
|
-
test("
|
|
215
|
+
test("默认只挖能力级:内置工具的通用动作不算工作流(本机 520 份日志实测那是噪音)", () => {
|
|
214
216
|
withDir((dir) => {
|
|
215
217
|
const sessions = join(dir, "sessions");
|
|
216
218
|
for (const id of ["sess-a", "sess-b", "sess-c"]) {
|
|
@@ -221,7 +223,7 @@ test("默认只挖能力级:内置工具的通用动作不算手法(本机 5
|
|
|
221
223
|
const read = readSessionObservations({ sessionDir: sessions });
|
|
222
224
|
const segments = segmentObservations(read.observations);
|
|
223
225
|
assert.deepEqual(DEFAULT_NUDGE_KINDS, ["skill", "mcp"]);
|
|
224
|
-
// 三个会话都出现了 read → pwsh
|
|
226
|
+
// 三个会话都出现了 read → pwsh,支持度足够高,但它是通用动作习惯,不是项目工作流。
|
|
225
227
|
assert.deepEqual(mineRepeatedPatterns(segments), [], "默认口径下这类序列不该冒出来");
|
|
226
228
|
// 显式开工具级才挖得到——而且要把「同一步连着走」重新折叠起来。
|
|
227
229
|
const toolLevel = mineRepeatedPatterns(segments, { kinds: null });
|
|
@@ -522,7 +524,7 @@ test("清单行:反事实覆盖率与风险下限照实投影", () => {
|
|
|
522
524
|
counterfactual: { verdict: null }
|
|
523
525
|
};
|
|
524
526
|
const row = summarizeProposal(proposal);
|
|
525
|
-
// 19/19 与 6/19
|
|
527
|
+
// 19/19 与 6/19 是两条不同的工作流,清单上就该看得出来。
|
|
526
528
|
assert.deepEqual(row.contrast, { answered: 1, total: 2 });
|
|
527
529
|
assert.equal(row.riskFloor, "R2", "整条的风险取下限里的最高档");
|
|
528
530
|
assert.equal(row.verdict, null, "判据没有结论时清单里也是 null,不显示一个假结论");
|
|
@@ -729,7 +731,7 @@ test("回归·空回答不算「已答」:一条都没接受就不许推进状
|
|
|
729
731
|
assert.equal(ok.applied.length, 1);
|
|
730
732
|
});
|
|
731
733
|
|
|
732
|
-
test("回归·跨工作区不并账:两个桶各一次不是「支持度 2
|
|
734
|
+
test("回归·跨工作区不并账:两个桶各一次不是「支持度 2 的同一工作流」", () => {
|
|
733
735
|
withDir((dir) => {
|
|
734
736
|
const sessions = join(dir, "sessions");
|
|
735
737
|
const seq = [[sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta")] })]];
|
|
@@ -1034,3 +1036,156 @@ test("回归·文件上限按排序截断,不按遍历顺序(选择偏差要
|
|
|
1034
1036
|
assert.equal(read.scannedFiles, 2);
|
|
1035
1037
|
});
|
|
1036
1038
|
});
|
|
1039
|
+
|
|
1040
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1041
|
+
// nudgeForProject:把「归属解析」和「挖掘」绑成一次调用(v0.9.3 接线的入口)。
|
|
1042
|
+
// 守两件事:没有归属就**不许跑**(不能退化成无归属普查),预览不许写盘。
|
|
1043
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1044
|
+
|
|
1045
|
+
/** 造一个假 DSH_HOME:sessions/<桶>/<会话>/session.v4.jsonl.zstd,头行带 cwd。 */
|
|
1046
|
+
function fakeDshHome(dir, { bucket = "ws-bucket", cwd = null, sessions = ["sess-a"] } = {}) {
|
|
1047
|
+
const home = join(dir, "dsh-home");
|
|
1048
|
+
for (const id of sessions) {
|
|
1049
|
+
const sub = join(home, "sessions", bucket, id);
|
|
1050
|
+
mkdirSync(sub, { recursive: true });
|
|
1051
|
+
const head = JSON.stringify({ time: 1700000000000, cwd });
|
|
1052
|
+
const body = sessionLine({ turn: 1, calls: [skill("alpha"), skill("beta"), pwsh("npm test")] });
|
|
1053
|
+
writeFileSync(join(sub, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from([head, body].join("\n"), "utf8")));
|
|
1054
|
+
}
|
|
1055
|
+
return home;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
test("nudgeForProject:归属解析不到就整跑跳过,绝不退化成「无归属普查」", () => {
|
|
1059
|
+
withDir((dir) => {
|
|
1060
|
+
const projectRoot = join(dir, "proj");
|
|
1061
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
1062
|
+
const dshHome = fakeDshHome(dir, { cwd: join(dir, "somewhere-else") });
|
|
1063
|
+
const doc = nudgeForProject({ projectRoot, dshHome, memoryDir: join(dir, "memory"), persistProposals: true });
|
|
1064
|
+
assert.equal(doc.skipped, true);
|
|
1065
|
+
assert.equal(doc.skippedReason, "no-session-attribution");
|
|
1066
|
+
assert.equal(doc.attribution.reason, "no-bucket");
|
|
1067
|
+
assert.equal(doc.counts.candidates, 0);
|
|
1068
|
+
assert.equal(doc.counts.savedProposals, 0);
|
|
1069
|
+
// 边界话术:跳过与跑完必须是同一句(同一个 NUDGE_BOUNDARIES 常量)。
|
|
1070
|
+
assert.equal(doc.boundaries.autoActivate, false);
|
|
1071
|
+
assert.equal(doc.boundaries.writesOnlyDrafts, true);
|
|
1072
|
+
assert.equal(existsSync(join(dir, "memory", "proposals")), false, "没归属就不该建提议桶");
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
test("nudgeForProject:命中归属就真跑,预览(persistProposals:false)一个文件都不写", () => {
|
|
1077
|
+
withDir((dir) => {
|
|
1078
|
+
const projectRoot = join(dir, "proj");
|
|
1079
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
1080
|
+
const memoryDir = join(dir, "memory");
|
|
1081
|
+
const dshHome = fakeDshHome(dir, { cwd: projectRoot });
|
|
1082
|
+
// 两个会话走同一段序列才够 NUDGE_MIN_SUPPORT=2(support 数的是**会话**,不是次数)。
|
|
1083
|
+
fakeDshHome(dir, { cwd: projectRoot, sessions: ["sess-b"] });
|
|
1084
|
+
|
|
1085
|
+
const preview = nudgeForProject({ projectRoot, dshHome, memoryDir, persistProposals: false });
|
|
1086
|
+
assert.equal(preview.skipped, false);
|
|
1087
|
+
assert.equal(preview.attribution.reason, "matched");
|
|
1088
|
+
assert.ok(preview.counts.candidates >= 1, "真数据里必须挖得出候选");
|
|
1089
|
+
// 这一条是接线的关键:面板每刷一次就 GET 一次,预览绝不能往 memoryDir 里塞文件。
|
|
1090
|
+
assert.equal(preview.counts.savedProposals, 0);
|
|
1091
|
+
assert.equal(existsSync(join(memoryDir, "proposals")), false, "预览不落盘");
|
|
1092
|
+
|
|
1093
|
+
const real = nudgeForProject({ projectRoot, dshHome, memoryDir, persistProposals: true });
|
|
1094
|
+
assert.equal(real.counts.savedProposals, real.counts.candidates);
|
|
1095
|
+
assert.ok(existsSync(real.proposalsDir));
|
|
1096
|
+
// 自动跑永不落草稿:materialize 默认 false,项目里不该多出 pipelines 目录。
|
|
1097
|
+
assert.equal(real.counts.draftsWritten, 0);
|
|
1098
|
+
assert.equal(existsSync(projectPipelineDir(projectRoot)), false);
|
|
1099
|
+
});
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
test("nudgeForProject:「读不到」与「确实没有」必须分开报", () => {
|
|
1103
|
+
withDir((dir) => {
|
|
1104
|
+
const projectRoot = join(dir, "proj");
|
|
1105
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
1106
|
+
// 桶在,但里面一个会话文件都没有 → 读不出任何 cwd → unreadable(故障),不是 no-bucket。
|
|
1107
|
+
const homeEmpty = join(dir, "home-empty");
|
|
1108
|
+
mkdirSync(join(homeEmpty, "sessions", "ws-bucket"), { recursive: true });
|
|
1109
|
+
assert.equal(resolveProjectSessionDir({ dshHome: homeEmpty, projectRoot }).reason, "unreadable");
|
|
1110
|
+
// 会话根根本不存在 → no-sessions-dir(新机器),同样是故障侧。
|
|
1111
|
+
const homeMissing = join(dir, "home-missing");
|
|
1112
|
+
assert.equal(resolveProjectSessionDir({ dshHome: homeMissing, projectRoot }).reason, "no-sessions-dir");
|
|
1113
|
+
});
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
test("归属解析也吃 budgetMs:预算用光报 budget-exceeded,不冒充 no-bucket", () => {
|
|
1117
|
+
// 评审抓出:resolveProjectSessionDir 最多同步扫 500 个桶 × 3 个文件,原先完全不看
|
|
1118
|
+
// budgetMs,于是 PR 正文那句「真卡就调 budgetMs」对这段路径是空话。
|
|
1119
|
+
// 这里用「截止点已经过去」来构造,不依赖真实耗时,确定性。
|
|
1120
|
+
withDir((dir) => {
|
|
1121
|
+
const projectRoot = join(dir, "proj");
|
|
1122
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
1123
|
+
const home = fakeDshHome(dir, { cwd: projectRoot });
|
|
1124
|
+
// 已经过期的截止点 → 连一个桶都不该扫。
|
|
1125
|
+
const stalled = resolveProjectSessionDir({ dshHome: home, projectRoot, deadline: Date.now() - 1 });
|
|
1126
|
+
assert.equal(stalled.reason, "budget-exceeded");
|
|
1127
|
+
assert.equal(stalled.sessionDir, null);
|
|
1128
|
+
assert.equal(stalled.filesRead, 0, "超预算时一个会话文件都不该读");
|
|
1129
|
+
// 不给 deadline = 扫到底,行为与改动前一致(测试与离线批处理要完整结果)。
|
|
1130
|
+
assert.equal(resolveProjectSessionDir({ dshHome: home, projectRoot }).reason, "matched");
|
|
1131
|
+
});
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
test("nudgeForProject:归属解析超预算时 skipped 并如实带 budget-exceeded", () => {
|
|
1135
|
+
withDir((dir) => {
|
|
1136
|
+
const projectRoot = join(dir, "proj");
|
|
1137
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
1138
|
+
const home = fakeDshHome(dir, { cwd: projectRoot });
|
|
1139
|
+
// now 往前推 10s + budget 4s → 截止点在过去,模拟「预算已经被前面的活花掉了」。
|
|
1140
|
+
const doc = nudgeForProject({
|
|
1141
|
+
projectRoot,
|
|
1142
|
+
dshHome: home,
|
|
1143
|
+
memoryDir: join(dir, "memory"),
|
|
1144
|
+
now: Date.now() - 10000,
|
|
1145
|
+
budgetMs: 4000,
|
|
1146
|
+
persistProposals: false
|
|
1147
|
+
});
|
|
1148
|
+
assert.equal(doc.skipped, true);
|
|
1149
|
+
assert.equal(doc.skippedReason, "no-session-attribution");
|
|
1150
|
+
// 关键:原因必须能区别于「这个项目确实没跑过会话」(no-bucket)——否则一次
|
|
1151
|
+
// 扫不完会被读成「这个项目很干净」。
|
|
1152
|
+
assert.equal(doc.attribution.reason, "budget-exceeded");
|
|
1153
|
+
assert.equal(doc.counts.candidates, 0);
|
|
1154
|
+
});
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
test("桶数超过 maxBuckets:先排序再截断,截断没命中报 bucket-limit 而不是 no-bucket", () => {
|
|
1158
|
+
// 评审抓出的第二个「读不到装成确实没有」:早前是边收边截(到 500 个就 break、
|
|
1159
|
+
// 然后才 sort),留下哪 500 个由文件系统遍历顺序决定;目标项目的桶一旦落在被
|
|
1160
|
+
// 截掉的那部分,就返回 no-bucket —— 一次截断被报成「这个项目没有会话」。
|
|
1161
|
+
withDir((dir) => {
|
|
1162
|
+
const projectRoot = join(dir, "proj");
|
|
1163
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
1164
|
+
const elsewhere = join(dir, "somewhere-else");
|
|
1165
|
+
mkdirSync(elsewhere, { recursive: true });
|
|
1166
|
+
// 四个桶:字母序 aaa-1, aaa-2, ws-bucket, zz-1。目标在 ws-bucket。
|
|
1167
|
+
// 无关桶里也放**真会话**(cwd 指向别处),这样 bucketsWithCwd > 0,
|
|
1168
|
+
// 唯一剩下的原因就是截断本身——否则会先命中 unreadable,测不到这条。
|
|
1169
|
+
const home = fakeDshHome(dir, { bucket: "ws-bucket", cwd: projectRoot });
|
|
1170
|
+
fakeDshHome(dir, { bucket: "aaa-1", cwd: elsewhere });
|
|
1171
|
+
fakeDshHome(dir, { bucket: "aaa-2", cwd: elsewhere });
|
|
1172
|
+
fakeDshHome(dir, { bucket: "zz-1", cwd: elsewhere });
|
|
1173
|
+
|
|
1174
|
+
// 只扫前 2 个(aaa-1、aaa-2):目标被挤出,且这不是「它没有会话」。
|
|
1175
|
+
const miss = resolveProjectSessionDir({ dshHome: home, projectRoot, maxBuckets: 2 });
|
|
1176
|
+
assert.equal(miss.reason, "bucket-limit", "被截断 ≠ 这个项目没有会话");
|
|
1177
|
+
assert.notEqual(miss.reason, "no-bucket");
|
|
1178
|
+
assert.equal(miss.bucketsTruncated, true);
|
|
1179
|
+
assert.equal(miss.bucketsScanned, 2);
|
|
1180
|
+
assert.equal(miss.bucketsTotal, 4, "总数与扫描数都要报,截断才看得见");
|
|
1181
|
+
|
|
1182
|
+
// 放宽到 3 个:字母序把 ws-bucket 包进来 → 命中。
|
|
1183
|
+
const hit = resolveProjectSessionDir({ dshHome: home, projectRoot, maxBuckets: 3 });
|
|
1184
|
+
assert.equal(hit.reason, "matched", "留下谁由字母序决定,不由遍历序决定");
|
|
1185
|
+
|
|
1186
|
+
// 不截断时依然是真答案那套,且 truncated 明确为 false。
|
|
1187
|
+
const full = resolveProjectSessionDir({ dshHome: home, projectRoot, maxBuckets: 999 });
|
|
1188
|
+
assert.equal(full.reason, "matched");
|
|
1189
|
+
assert.equal(full.bucketsTruncated, false);
|
|
1190
|
+
});
|
|
1191
|
+
});
|