@modusensus/dsh-mneme 0.9.2 → 0.9.3
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 +8 -7
- package/docs/CONFIGURATION.md +3 -2
- package/docs/WORKFLOWS.md +70 -3
- package/lib/api.js +119 -1
- package/lib/capabilities.js +1 -1
- package/lib/client.js +98 -7
- package/lib/config.js +20 -2
- package/lib/index.js +15 -0
- 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/scope.js +30 -1
- package/lib/settings.js +5 -1
- package/package.json +1 -1
- package/src/api.js +119 -1
- package/src/capabilities.js +1 -1
- package/src/config.js +20 -2
- package/src/index.js +15 -0
- 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/scope.js +30 -1
- package/src/settings.js +5 -1
- package/test/api-nudge-route.test.js +259 -0
- package/test/api.test.js +6 -1
- package/test/client.test.js +39 -1
- package/test/pipeline-nudge-auto.test.js +224 -0
- package/test/pipeline-nudge.test.js +161 -6
- package/test/scope-optional-service.test.js +110 -0
- package/test/session-usage.test.js +11 -2
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createScopeResolver } from "../src/scope.js";
|
|
4
|
+
|
|
5
|
+
// issue #487:workspaceRegistry 不在 inject 声明里(src/index.js:45),而 cordis 4
|
|
6
|
+
// 的 ctx 是 Proxy——裸读未注入属性会抛 "cannot get property ... without inject",
|
|
7
|
+
// 不返回 undefined。于是 scope.js 曾经那行 `ctx?.workspaceRegistry?.list?.()` 从
|
|
8
|
+
// v0.8.0 起必然抛错走降级:canonical path 归一与成员资格校验静默失效,只剩
|
|
9
|
+
// header.cwd。
|
|
10
|
+
//
|
|
11
|
+
// 为什么既有测试没拦住:scope-storage.test.js 里三处 registry 测试都用普通对象
|
|
12
|
+
// 伪造 ctx({ workspaceRegistry: {...} }),普通对象裸读不抛错。本文件改用
|
|
13
|
+
// 「读未声明属性即抛」的 Proxy 复刻真实宿主形状,把两侧行为都钉死。
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* cordis 4 形状的 ctx:只有显式提供的属性可读,其余一律抛错(不是 undefined)。
|
|
17
|
+
* @param {object} provided 已提供的属性(reflect / logger …)
|
|
18
|
+
*/
|
|
19
|
+
function proxyCtx(provided) {
|
|
20
|
+
return new Proxy(provided, {
|
|
21
|
+
get(target, prop) {
|
|
22
|
+
if (typeof prop === "symbol" || prop === "then" || String(prop).startsWith("_")) {
|
|
23
|
+
return target[prop];
|
|
24
|
+
}
|
|
25
|
+
if (prop in target) return target[prop];
|
|
26
|
+
throw new Error(`cannot get property "${String(prop)}" without inject`);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 带 workspaceRegistry 服务的 reflect(免 inject 读取出口)。 */
|
|
32
|
+
function reflectWith(services, seen = []) {
|
|
33
|
+
return {
|
|
34
|
+
get(name) {
|
|
35
|
+
seen.push(name);
|
|
36
|
+
return services[name];
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const EXEC = { agent: { session: { id: "s1", header: { agentPreset: "coder", cwd: "D:\\raw" } } } };
|
|
42
|
+
|
|
43
|
+
function loggerSpy() {
|
|
44
|
+
const warnings = [];
|
|
45
|
+
return { warnings, logger: { warn: (m) => warnings.push(String(m)) } };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("#487: unregistered ctx property never throws out of the resolver (Proxy shape)", () => {
|
|
49
|
+
// 没有任何服务:裸读 workspaceRegistry 在真宿主里会抛。解析器必须吞掉并降级。
|
|
50
|
+
const { warnings, logger } = loggerSpy();
|
|
51
|
+
const ctx = proxyCtx({ reflect: reflectWith({}), logger });
|
|
52
|
+
const resolve = createScopeResolver({ ctx, config: { scopeEnabled: true }, logger });
|
|
53
|
+
// 不会抛;agentPreset 照常解析,workspace 落到 header.cwd。
|
|
54
|
+
assert.deepEqual(resolve(EXEC), { agent_scope: "coder", workspace_scope: "D:\\raw" });
|
|
55
|
+
// 服务缺失是合法状态(headless / 旧宿主),不是故障——不许刷 warn。
|
|
56
|
+
assert.deepEqual(warnings, [], "missing optional service must stay silent");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("#487: registry reached through reflect.get restores the canonical path", () => {
|
|
60
|
+
// 修复的收益不只是消 warning:宿主确实提供 workspaceRegistry(@deepseek-ai/
|
|
61
|
+
// dsh-workspace),走免 inject 读取能取回 realpath 归一后的 canonical path,
|
|
62
|
+
// 而不是退化成原始 cwd 字符串。
|
|
63
|
+
const registry = { list: () => [{ path: "C:\\canon", sessionIds: ["s1"] }] };
|
|
64
|
+
const seen = [];
|
|
65
|
+
const ctx = proxyCtx({ reflect: reflectWith({ workspaceRegistry: registry }, seen) });
|
|
66
|
+
const resolve = createScopeResolver({ ctx, config: { scopeEnabled: true } });
|
|
67
|
+
|
|
68
|
+
assert.deepEqual(resolve(EXEC), { agent_scope: "coder", workspace_scope: "C:\\canon" });
|
|
69
|
+
assert.ok(seen.includes("workspaceRegistry"), "registry must be read via reflect.get");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("#487: membership miss still falls back to header cwd (registry hit is id-scoped)", () => {
|
|
73
|
+
const registry = { list: () => [{ path: "C:\\canon", sessionIds: ["other"] }] };
|
|
74
|
+
const ctx = proxyCtx({ reflect: reflectWith({ workspaceRegistry: registry }) });
|
|
75
|
+
const resolve = createScopeResolver({ ctx, config: { scopeEnabled: true } });
|
|
76
|
+
|
|
77
|
+
assert.deepEqual(resolve(EXEC), { agent_scope: "coder", workspace_scope: "D:\\raw" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("#487: hosts without ctx.reflect keep working through the plain-object shape", () => {
|
|
81
|
+
// 旧宿主:ctx 是普通对象、没有 reflect.get。普通对象裸读安全,行为必须和修复前一致。
|
|
82
|
+
const ctx = { workspaceRegistry: { list: () => [{ path: "C:\\canon", sessionIds: ["s1"] }] } };
|
|
83
|
+
const resolve = createScopeResolver({ ctx, config: { scopeEnabled: true } });
|
|
84
|
+
assert.deepEqual(resolve(EXEC), { agent_scope: "coder", workspace_scope: "C:\\canon" });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("#487: registry explosion still degrades to header cwd and warns exactly once", () => {
|
|
88
|
+
// 既有语义(scope-storage.test.js 已有同款普通对象版):服务确实炸了要出声,
|
|
89
|
+
// 但每次写一个解析器实例只警告一次、绝不影响写入。
|
|
90
|
+
const { warnings, logger } = loggerSpy();
|
|
91
|
+
const registry = { list: () => { throw new Error("boom"); } };
|
|
92
|
+
const ctx = proxyCtx({ reflect: reflectWith({ workspaceRegistry: registry }), logger });
|
|
93
|
+
const resolve = createScopeResolver({ ctx, config: { scopeEnabled: true }, logger });
|
|
94
|
+
|
|
95
|
+
assert.deepEqual(resolve(EXEC), { agent_scope: "coder", workspace_scope: "D:\\raw" });
|
|
96
|
+
assert.deepEqual(resolve(EXEC), { agent_scope: "coder", workspace_scope: "D:\\raw" });
|
|
97
|
+
assert.equal(warnings.length, 1, "warn once per resolver instance, never on every write");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("#487: resolver never throws even when reflect.get itself explodes", () => {
|
|
101
|
+
const { logger } = loggerSpy();
|
|
102
|
+
const ctx = proxyCtx({
|
|
103
|
+
reflect: { get() { throw new Error("reflect boom"); } },
|
|
104
|
+
logger
|
|
105
|
+
});
|
|
106
|
+
const resolve = createScopeResolver({ ctx, config: { scopeEnabled: true }, logger });
|
|
107
|
+
assert.deepEqual(resolve(EXEC), { agent_scope: "coder", workspace_scope: "D:\\raw" });
|
|
108
|
+
// 无会话时照旧全 NULL(fail-closed 读取侧依赖这个契约)。
|
|
109
|
+
assert.deepEqual(resolve(undefined), { agent_scope: null, workspace_scope: null });
|
|
110
|
+
});
|
|
@@ -64,8 +64,17 @@ test("多帧 zstd:**每一帧**都要解出来(只解第一帧是踩过的
|
|
|
64
64
|
assert.deepEqual(seen, ["第一帧的内容", "第二帧的内容", "第三帧的内容"], "三帧都要被回调到");
|
|
65
65
|
assert.equal(stat.frames, 3);
|
|
66
66
|
assert.equal(stat.failed, 0);
|
|
67
|
-
//
|
|
68
|
-
|
|
67
|
+
// 反证:证明这个夹具真能暴露「只解第一帧」——数帧头,不依赖 Node 的 zstd 语义。
|
|
68
|
+
// 原来这行是 `zstdDecompressSync(buf)` 只解出第一段;Node 24.22.0 起整块解会吐出全部帧
|
|
69
|
+
// (nodejs/node#65865 让解码跨 chunk 边界),反证随之过期,所以改成数 magic。
|
|
70
|
+
// 注意这行锁的是「切帧判据仍是 magic」,**不是碰撞免疫**:payload 里撞上 magic 时
|
|
71
|
+
// 计数器与解码器会一起错(半片也解得出来),实测 frames 同样变成 4,这条测不出来。
|
|
72
|
+
let frameHeaders = 0;
|
|
73
|
+
for (let at = 0; (at = buf.indexOf(ZSTD_MAGIC, at)) !== -1; at += ZSTD_MAGIC.length) frameHeaders += 1;
|
|
74
|
+
assert.equal(frameHeaders, 3, "夹具必须是三帧,否则上面测不到「只解第一帧」");
|
|
75
|
+
// 帧头数 = 成功数 + 失败数,是实现的契约:`forEachFrame` 用 magic 切帧,每段要么解开要么记失败。
|
|
76
|
+
// 写成 `stat.frames` 只在 failed 恒为 0 时成立,那是巧合不是规矩。
|
|
77
|
+
assert.equal(frameHeaders, stat.frames + stat.failed, "帧头数必须交代得清:每一帧要么解开要么记失败");
|
|
69
78
|
});
|
|
70
79
|
|
|
71
80
|
test("多帧 zstd:每帧都解;截断产生的**半行**由 jsonl 层丢掉(宁可少一条,不要错一条)", () => {
|