dsh-plugin-usage-stats 0.4.0
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/LICENSE +21 -0
- package/README.md +51 -0
- package/README.zh.md +51 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +716 -0
- package/package.json +71 -0
- package/src/aggregate.ts +175 -0
- package/src/cordis.ts +72 -0
- package/src/scanner.ts +391 -0
- package/src/store.ts +152 -0
- package/tests/contract-pair.test.ts +108 -0
- package/tests/cordis.test.ts +96 -0
- package/tests/fold.test.ts +122 -0
- package/tests/realscan.test.ts +24 -0
- package/tests/scan.test.ts +168 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** 服务入口契约测试(不起真宿主):
|
|
2
|
+
* ① 原型 SRC 标记形态 = typert-protocol mark() 产物(version:1 + direct invocation,overview 单方法);
|
|
3
|
+
* ② applyCordis 经 ctx.reflect.provide 注册 usageStats 服务,typertRemote 绑定形态过 validateBinding;
|
|
4
|
+
* ③ overview(含 model/provider 过滤)在真实会话目录上出非零数据(增量解码 + 价目折算全链路)。 */
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
// W1 官方插件形(default = {name, inject, apply} 对象):函数本体走命名导入。
|
|
11
|
+
import { applyCordis, UsageStatsService } from "../src/cordis.ts";
|
|
12
|
+
|
|
13
|
+
const MARKER_KEY = "@deepseek-ai/dsh-typert-protocol/remote-methods";
|
|
14
|
+
const ROOT = join(homedir(), ".dsh", "sessions");
|
|
15
|
+
|
|
16
|
+
/** 原型标记的运行时形态(跨副本可读,测试侧本地声明,避免 import 宿主协议包)。 */
|
|
17
|
+
interface RemoteMethodsMarker {
|
|
18
|
+
version: number;
|
|
19
|
+
methods: Array<{ method: string; invocation: { kind: string } }>;
|
|
20
|
+
}
|
|
21
|
+
/** 桩宿主 ctx:真实形态由宿主决定,测试只关心被断言的子集,故宽类型为 any。 */
|
|
22
|
+
type StubCtx = any;
|
|
23
|
+
|
|
24
|
+
test("SRC 标记与 typertRemote 绑定形态符合 gateway 读取契约", () => {
|
|
25
|
+
const marker = Object.getOwnPropertyDescriptor(UsageStatsService.prototype, MARKER_KEY) as { value: RemoteMethodsMarker } | undefined;
|
|
26
|
+
assert.ok(marker, "原型必须挂字符串键 remote-methods 标记(跨副本可读)");
|
|
27
|
+
assert.equal(marker.value.version, 1);
|
|
28
|
+
assert.deepEqual(
|
|
29
|
+
marker.value.methods.map((m) => m.method),
|
|
30
|
+
["overview"],
|
|
31
|
+
"Remote 收口为 overview 单方法(会话视图宿主自带,不预留死接口)",
|
|
32
|
+
);
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
marker.value.methods.map((m) => m.invocation.kind),
|
|
35
|
+
["direct"],
|
|
36
|
+
);
|
|
37
|
+
// SRC 参数形态:复刻网关 methodParameterNames 的解析(Node type-strip 会把 `: unknown` 注解替换为等长空白,
|
|
38
|
+
// 网关按「逗号切分 + trim + 纯标识符且唯一」校验;默认值/解构/rest 会被拒绝)。
|
|
39
|
+
const srcParamNames = (fn: Function): string[] => {
|
|
40
|
+
const source = Function.prototype.toString.call(fn);
|
|
41
|
+
const open = source.indexOf("(");
|
|
42
|
+
const close = source.indexOf(")", open + 1);
|
|
43
|
+
const body = source.slice(open + 1, close).trim();
|
|
44
|
+
return body.length === 0 ? [] : body.split(",").map((p) => p.trim());
|
|
45
|
+
};
|
|
46
|
+
const proto = UsageStatsService.prototype as any;
|
|
47
|
+
const names = srcParamNames(proto.overview);
|
|
48
|
+
assert.equal(names.length, 1, "overview 必须是单一业务参数");
|
|
49
|
+
assert.match(names[0], /^[$A-Z_a-z][$\w]*$/u, "参数必须是纯标识符(无默认值/解构/rest/注解残留逗号)");
|
|
50
|
+
assert.deepEqual(names, ["filter"], "wire 参数名 = 客户端 descriptor 的 name(隐式契约)");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("applyCordis:provide 注册 + 绑定可被 validateBinding 语义接受", () => {
|
|
54
|
+
const provided: Record<string, unknown> = {};
|
|
55
|
+
const logs: string[] = [];
|
|
56
|
+
const ctx: StubCtx = {
|
|
57
|
+
reflect: {
|
|
58
|
+
provide: (name: string, value: unknown) => {
|
|
59
|
+
provided[name] = value;
|
|
60
|
+
return () => delete provided[name];
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
inject: () => {},
|
|
64
|
+
logger: { info: (msg: string) => logs.push(msg) },
|
|
65
|
+
};
|
|
66
|
+
const svc = applyCordis(ctx, {});
|
|
67
|
+
assert.equal(provided.usageStats, svc);
|
|
68
|
+
const b = svc.typertRemote;
|
|
69
|
+
assert.equal(b.service, svc, "binding.service 必须 === receiver 本体(validateBinding 比对)");
|
|
70
|
+
assert.equal(b.serviceKey, "usageStats");
|
|
71
|
+
assert.equal(b.namespace, "usageStats");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("真实链路:overview 出数 + 维度过滤 + 热缓存", { skip: existsSync(ROOT) ? false : "无会话目录" }, async () => {
|
|
75
|
+
const ctx: StubCtx = { reflect: { provide: () => () => {} }, logger: undefined };
|
|
76
|
+
const svc = applyCordis(ctx, {});
|
|
77
|
+
const o = await svc.overview({});
|
|
78
|
+
assert.ok(o.totals.requests > 0);
|
|
79
|
+
assert.equal(typeof o.hitRate, "number", "真实数据缓存字段有上报");
|
|
80
|
+
assert.ok(o.byModel.length > 0);
|
|
81
|
+
assert.equal("cost" in o, false, "v4 起 Overview 不再携带费用/按天/消息口径");
|
|
82
|
+
// 维度过滤:不存在的模型 → 空视图;存在的首行模型 → 非空且只含该键
|
|
83
|
+
const empty = await svc.overview({ model: "no/such-model-xyz" });
|
|
84
|
+
assert.equal(empty.totals.requests, 0);
|
|
85
|
+
assert.equal(empty.byModel.length, 0);
|
|
86
|
+
const first = o.byModel[0]!;
|
|
87
|
+
const one = await svc.overview({ model: first.key });
|
|
88
|
+
assert.equal(one.totals.requests, first.requests, "全键过滤应命中该模型行");
|
|
89
|
+
assert.equal(one.byModel.length, 1);
|
|
90
|
+
const prov = await svc.overview({ provider: first.provider });
|
|
91
|
+
assert.ok(prov.totals.requests >= first.requests, "provider 过滤是其模型并集");
|
|
92
|
+
// 热缓存:第二次 overview 不重新解码
|
|
93
|
+
const t0 = Date.now();
|
|
94
|
+
await svc.overview({});
|
|
95
|
+
assert.ok(Date.now() - t0 < 500, "热缓存 overview 应亚秒返回");
|
|
96
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** 折叠与聚合口径测试:fixture 驱动(含重试替换、缓存归因、命中率 null 口径、日期窗口)。
|
|
2
|
+
* v4 起聚合面收缩为纯 token 统计:消息数 / 按天 / 费用全部移出 Overview(UI 无消费者);
|
|
3
|
+
* scanner 的 SessionMeta 计数解析保留(会话画像本身仍有语义,由本文件的 foldJsonl 用例看护)。 */
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { foldJsonl, localDate } from "../src/scanner.ts";
|
|
7
|
+
import { buildOverview, hitRate, normalizeRange } from "../src/aggregate.ts";
|
|
8
|
+
|
|
9
|
+
const DAY = "2026-09-19";
|
|
10
|
+
const T = Date.parse(`${DAY}T08:00:00`); // 本地时区
|
|
11
|
+
|
|
12
|
+
function line(type: string, seq: number, extra: Record<string, unknown> = {}): string {
|
|
13
|
+
return JSON.stringify({ type, seq, time: T + seq * 1000, ...extra });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const FIXTURE = [
|
|
17
|
+
line("session", 0, { id: "s-1", cwd: "/w/测试", createdAt: T, delegationDepth: 1, origin: { parentSession: "p" } }),
|
|
18
|
+
line("session/title", 1, { data: { title: "测试会话" } }),
|
|
19
|
+
line("turn/start", 2, { data: { turn: 1 } }),
|
|
20
|
+
line("step/start", 3, { data: { turn: 1, step: 1 } }),
|
|
21
|
+
line("request/header", 4, { data: { header: { config: { provider: "buzz", model: "qwen-x" } } } }),
|
|
22
|
+
line("assistant/message", 5, { data: { usage: { inputTokens: 100, outputTokens: 20, cacheReadTokens: 800, cacheWriteTokens: 50, totalTokens: 970 } } }),
|
|
23
|
+
line("llm/retry-started", 6, { data: { retryId: "r1", turn: 1, step: 2, retry: 1 } }),
|
|
24
|
+
line("step/start", 7, { data: { turn: 1, step: 2 } }),
|
|
25
|
+
line("request/header", 8, { data: { header: { config: { provider: "buzz", model: "qwen-x" } } } }),
|
|
26
|
+
// step2 第一次样本(重试场景:step/start 重发)
|
|
27
|
+
line("assistant/message", 9, { data: { usage: { inputTokens: 111, outputTokens: 1, totalTokens: 112 } } }),
|
|
28
|
+
line("llm/retry-started", 10, { data: { retryId: "r1", turn: 1, step: 2, retry: 2 } }),
|
|
29
|
+
line("assistant/message", 11, { data: { usage: { inputTokens: 120, outputTokens: 30, cacheReadTokens: 400, totalTokens: 550 } } }),
|
|
30
|
+
// 无 header 会话片段:归 unknown
|
|
31
|
+
line("session", 12, { id: "s-2", cwd: "/w/2", createdAt: T }),
|
|
32
|
+
line("assistant/message", 13, { data: { usage: { inputTokens: 5, outputTokens: 5, totalTokens: 10 } } }),
|
|
33
|
+
].join("\n");
|
|
34
|
+
|
|
35
|
+
test("foldJsonl:折叠数量、重试取末条、unknown 模型、子代理标记", () => {
|
|
36
|
+
const { facts, meta } = foldJsonl("hint", FIXTURE);
|
|
37
|
+
assert.equal(facts.length, 3, "s1 step1 + s1 step2(末条) + s2");
|
|
38
|
+
const step2 = facts.find((f) => f.scope === "s-1:1:2");
|
|
39
|
+
assert.equal(step2?.input, 120, "重试同 scope 只留末条样本");
|
|
40
|
+
assert.equal(step2?.cacheWrite, 0);
|
|
41
|
+
const s2 = facts.find((f) => f.sessionId === "s-2");
|
|
42
|
+
assert.equal(s2?.model, "unknown");
|
|
43
|
+
assert.equal(s2?.date, localDate(T + 13000));
|
|
44
|
+
// meta 是「最后一条 session 行」——s-2;s-1 的 meta 由聚合层按 fold 处理,此处只验证解析不崩
|
|
45
|
+
assert.equal(meta?.sessionId, "s-2");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** foldJsonl 单 fold 场景(s-1 单独一份)供聚合断言。 */
|
|
49
|
+
const FOLD_S1 = foldJsonl("hint", FIXTURE.split("\n").slice(0, 12).join("\n"));
|
|
50
|
+
const FOLD_S2 = foldJsonl("hint", [FIXTURE.split("\n")[0].replace("s-1", "s-2x"), FIXTURE.split("\n")[12], FIXTURE.split("\n")[13]].join("\n"));
|
|
51
|
+
|
|
52
|
+
test("buildOverview:总计/命中率/byModel/会话数", () => {
|
|
53
|
+
const o = buildOverview([FOLD_S1], {});
|
|
54
|
+
assert.equal(o.totals.requests, 2);
|
|
55
|
+
assert.equal(o.totals.input, 220);
|
|
56
|
+
assert.equal(o.totals.output, 50);
|
|
57
|
+
assert.equal(o.totals.cacheRead, 1200);
|
|
58
|
+
assert.equal(o.totals.cacheWrite, 50);
|
|
59
|
+
assert.equal(o.totals.total, 1520);
|
|
60
|
+
assert.ok(Math.abs(o.hitRate! - 1200 / 1420) < 1e-9);
|
|
61
|
+
assert.equal(o.byModel.length, 1);
|
|
62
|
+
assert.equal(o.byModel[0].key, "buzz/qwen-x");
|
|
63
|
+
assert.equal(o.sessionCount, 1);
|
|
64
|
+
// 死字段回归锁:v4 移除的口径不得借尸还魂。
|
|
65
|
+
assert.equal("cost" in o, false);
|
|
66
|
+
assert.equal("byDay" in o, false);
|
|
67
|
+
assert.equal("messages" in o, false);
|
|
68
|
+
assert.equal("configuredPrices" in o, false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("命中率口径:缓存字段未上报 → null(UI 显示“—”),空分母 → null", () => {
|
|
72
|
+
const o = buildOverview([FOLD_S2], {});
|
|
73
|
+
// s-2x 的 usage 完全没报 cacheReadTokens/cacheWriteTokens → 无命中率口径
|
|
74
|
+
assert.equal(o.hitRate, null);
|
|
75
|
+
assert.equal(hitRate({ requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, reportsCache: false }), null);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("normalizeRange:非法值丢弃、倒挂交换、model/provider 只收非空字符串", () => {
|
|
79
|
+
assert.deepEqual(normalizeRange({ from: "2026-09-01", to: "bad" }), { from: "2026-09-01" });
|
|
80
|
+
assert.deepEqual(normalizeRange({ from: "2026-09-10", to: "2026-09-01" }), { from: "2026-09-01", to: "2026-09-10" });
|
|
81
|
+
assert.deepEqual(normalizeRange({ model: "a/b", provider: " ", n: 3 }), { model: "a/b" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("buildOverview:model/provider 过滤在 fact 层生效", () => {
|
|
85
|
+
const full = buildOverview([FOLD_S1, FOLD_COUNTS], {});
|
|
86
|
+
assert.equal(full.totals.requests, 3);
|
|
87
|
+
const byModel = buildOverview([FOLD_S1, FOLD_COUNTS], { model: "buzz/qwen-x" });
|
|
88
|
+
assert.equal(byModel.totals.requests, 3, "全键匹配 s-1 两条 + s-9 一条");
|
|
89
|
+
assert.equal(byModel.byModel.length, 1);
|
|
90
|
+
assert.equal(byModel.sessionCount, 2);
|
|
91
|
+
const bare = buildOverview([FOLD_S1, FOLD_S2], { model: "qwen-x" });
|
|
92
|
+
assert.equal(bare.totals.requests, 2, "裸 model 兜底匹配");
|
|
93
|
+
const prov = buildOverview([FOLD_S1, FOLD_S2], { provider: "unknown" });
|
|
94
|
+
assert.equal(prov.totals.requests, 1, "provider 段精确匹配 s-2x");
|
|
95
|
+
assert.equal(prov.byModel[0].key, "unknown/unknown");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/* ---------------- 会话级计数(scanner SessionMeta 画像,聚合层已不消费) ---------------- */
|
|
99
|
+
|
|
100
|
+
/** 覆盖 user/message、tool/call、无 usage 的 assistant/message 三类计数行的独立 fixture。 */
|
|
101
|
+
const FIXTURE_COUNTS = [
|
|
102
|
+
line("session", 0, { id: "s-9", cwd: "/w/c", createdAt: T }),
|
|
103
|
+
line("user/message", 1, { data: { content: "你好" } }),
|
|
104
|
+
line("turn/start", 2, { data: { turn: 1 } }),
|
|
105
|
+
line("step/start", 3, { data: { turn: 1, step: 1 } }),
|
|
106
|
+
line("request/header", 4, { data: { header: { config: { provider: "buzz", model: "qwen-x" } } } }),
|
|
107
|
+
line("assistant/message", 5, { data: { usage: { inputTokens: 10, outputTokens: 2, cacheReadTokens: 30, totalTokens: 42 } } }),
|
|
108
|
+
line("tool/call", 6, { data: { name: "bash" } }),
|
|
109
|
+
line("tool/call", 7, { data: { name: "read" } }),
|
|
110
|
+
line("user/message", 8, { data: { content: "继续" } }),
|
|
111
|
+
// 无 usage 的 assistant 行:计消息数,但不出 usage 事实
|
|
112
|
+
line("assistant/message", 9, { data: { content: "收尾" } }),
|
|
113
|
+
].join("\n");
|
|
114
|
+
|
|
115
|
+
const FOLD_COUNTS = foldJsonl("hint", FIXTURE_COUNTS);
|
|
116
|
+
|
|
117
|
+
test("foldJsonl:三计数(user/assistant/tool),无 usage 的 assistant 也计数", () => {
|
|
118
|
+
assert.equal(FOLD_COUNTS.meta?.userMessages, 2);
|
|
119
|
+
assert.equal(FOLD_COUNTS.meta?.assistantMessages, 2);
|
|
120
|
+
assert.equal(FOLD_COUNTS.meta?.toolCalls, 2);
|
|
121
|
+
assert.equal(FOLD_COUNTS.facts.length, 1, "无 usage 行不产事实");
|
|
122
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** 真实数据集成测试:全局扫描本机 DSH 会话目录(增量多帧解码),断言事实非空 + 存量持久化生效。
|
|
2
|
+
* 无会话目录时跳过;本测试与 fold.test.ts 的 fixture 测试互补(golden 口径在 fixture,链路在真数据)。 */
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { sessionsRoot } from "../src/scanner.ts";
|
|
7
|
+
import { scanFolds, buildOverview } from "../src/aggregate.ts";
|
|
8
|
+
import { FoldStore } from "../src/store.ts";
|
|
9
|
+
|
|
10
|
+
const root = sessionsRoot();
|
|
11
|
+
test("真实会话:多帧解码出非零事实,二次扫描 0 重载且 cache 落盘", { skip: existsSync(root) ? false : "无 DSH 会话目录" }, async () => {
|
|
12
|
+
const store = new FoldStore(root);
|
|
13
|
+
const t0 = Date.now();
|
|
14
|
+
const first = await scanFolds(root, store);
|
|
15
|
+
assert.ok(first.files > 0, "至少应发现一个会话文件");
|
|
16
|
+
const o = buildOverview(first.folds, {});
|
|
17
|
+
assert.ok(o.totals.requests > 0, "真实数据应折叠出非零 usage 事实(多帧解码生效)");
|
|
18
|
+
assert.ok(o.totals.input + o.totals.output > 0);
|
|
19
|
+
assert.equal("cost" in o, false, "v4 契约:真实链路输出同样不含费用字段");
|
|
20
|
+
assert.ok(Date.now() - t0 < 60_000, "首扫应远小于 60s 挂起阈值");
|
|
21
|
+
// 落盘路径断言在 scan.test.ts(tmp 目录,可控);此处不重复——真实 home 可能处于只读沙箱。
|
|
22
|
+
const second = await scanFolds(root, store);
|
|
23
|
+
assert.ok(second.reloaded <= 5, `未变文件应全复用(实况写入导致的推进 ${second.reloaded} 个,容忍 ≤5)`);
|
|
24
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** 增量扫描正确性:zstd 帧边界(RFC 8878 walker)+ foldFrom 与全量重放等价 + FoldStore 持久化/失效。
|
|
2
|
+
* 多帧 fixture 用真 zstd CLI 拼接生成(无 CLI 环境整体 skip——walker 的字节语义只有真帧能说清)。 */
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { mkdtemp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { foldFrom, foldJsonl, foldSnapshot, frameSpans, frameWatermark, newFoldState } from "../src/scanner.ts";
|
|
10
|
+
import { FoldStore, VERSION } from "../src/store.ts";
|
|
11
|
+
|
|
12
|
+
let ZSTD_OK = false;
|
|
13
|
+
try {
|
|
14
|
+
ZSTD_OK = execFileSync("zstd", ["--version"], { encoding: "utf8" }).includes("v");
|
|
15
|
+
} catch {
|
|
16
|
+
ZSTD_OK = false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SESSION_LINES = [
|
|
20
|
+
JSON.stringify({ type: "session", time: Date.now(), id: "s-inc", cwd: "/w", createdAt: Date.now() }),
|
|
21
|
+
JSON.stringify({ type: "turn/start", time: Date.now(), data: { turn: 1 } }),
|
|
22
|
+
JSON.stringify({ type: "request/header", time: Date.now(), data: { header: { config: { provider: "prov-a", model: "m-1" } } } }),
|
|
23
|
+
JSON.stringify({ type: "assistant/message", time: Date.now(), data: { usage: { inputTokens: 7, outputTokens: 9, cacheReadTokens: 3 } } }),
|
|
24
|
+
];
|
|
25
|
+
const MORE_LINES = [
|
|
26
|
+
JSON.stringify({ type: "tool/call", time: Date.now(), data: { name: "bash" } }),
|
|
27
|
+
JSON.stringify({ type: "user/message", time: Date.now(), data: { content: "go" } }),
|
|
28
|
+
JSON.stringify({ type: "turn/start", time: Date.now(), data: { turn: 2 } }),
|
|
29
|
+
JSON.stringify({ type: "request/header", time: Date.now(), data: { header: { config: { provider: "prov-a", model: "m-1" } } } }),
|
|
30
|
+
// 与第一轮同 scope 的重试样本 → last-wins
|
|
31
|
+
JSON.stringify({ type: "assistant/message", time: Date.now(), data: { usage: { inputTokens: 1, outputTokens: 2 } } }),
|
|
32
|
+
JSON.stringify({ type: "step/start", time: Date.now(), data: { turn: 2, step: 1 } }),
|
|
33
|
+
JSON.stringify({ type: "assistant/message", time: Date.now(), data: { usage: { inputTokens: 100, outputTokens: 50 } } }),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
let TMP = "";
|
|
37
|
+
function newTmpFile(): string {
|
|
38
|
+
return join(TMP, `f-${Math.random().toString(36).slice(2)}.zst`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function compressFrame(text: string): Promise<Buffer> {
|
|
42
|
+
const src = newTmpFile();
|
|
43
|
+
const dst = newTmpFile();
|
|
44
|
+
await writeFile(src, text);
|
|
45
|
+
execFileSync("zstd", ["-f", "--no-check", "-1", src, "-o", dst]);
|
|
46
|
+
return readFile(dst);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("zstd 帧 walker:多帧拼接的边界识别、半帧截断、垃圾即停", { skip: ZSTD_OK ? false : "无 zstd CLI" }, async () => {
|
|
50
|
+
TMP = await mkdtemp(join(tmpdir(), "usg-walk-"));
|
|
51
|
+
const f1 = await compressFrame(SESSION_LINES.join("\n") + "\n");
|
|
52
|
+
const f2 = await compressFrame(MORE_LINES.join("\n") + "\n");
|
|
53
|
+
const joined = Buffer.concat([f1, f2]);
|
|
54
|
+
const spans = frameSpans(joined);
|
|
55
|
+
assert.equal(spans.length, 2);
|
|
56
|
+
assert.deepEqual(spans[0], [0, f1.length]);
|
|
57
|
+
assert.deepEqual(spans[1], [f1.length, joined.length]);
|
|
58
|
+
assert.equal(frameWatermark(joined), joined.length);
|
|
59
|
+
// 截半帧:水位停在完整帧边界
|
|
60
|
+
const cut = joined.subarray(0, f1.length + Math.floor(f2.length / 2));
|
|
61
|
+
assert.equal(frameWatermark(cut), f1.length);
|
|
62
|
+
assert.equal(frameSpans(cut).length, 1);
|
|
63
|
+
// 尾追垃圾:walker 即停,不吞好帧
|
|
64
|
+
const dirty = Buffer.concat([joined, Buffer.from([1, 2, 3, 4, 5])]);
|
|
65
|
+
assert.deepEqual(frameSpans(dirty).length, 2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("foldFrom 增量重放 === 全量 foldJsonl(两帧 + 跨帧半行),逐字段等价", { skip: ZSTD_OK ? false : "无 zstd CLI" }, async () => {
|
|
69
|
+
TMP = TMP || (await mkdtemp(join(tmpdir(), "usg-inc-")));
|
|
70
|
+
// 故意把 MORE_LINES[0] 从中间劈开:前半压进帧1尾、后半作为帧2头(跨帧行必须由 tail 机制缝合)。
|
|
71
|
+
const half = Math.floor(MORE_LINES[0].length / 2);
|
|
72
|
+
const groupA = [...SESSION_LINES, MORE_LINES[0].slice(0, half)];
|
|
73
|
+
const groupB = [MORE_LINES[0].slice(half), ...MORE_LINES.slice(1)];
|
|
74
|
+
const f1 = await compressFrame(groupA.join("\n") + "\n");
|
|
75
|
+
const f2 = await compressFrame(groupB.join("\n") + "\n");
|
|
76
|
+
const file = join(TMP, "session.v3.jsonl.zstd");
|
|
77
|
+
await writeFile(file, Buffer.concat([f1, f2]));
|
|
78
|
+
|
|
79
|
+
// 全量基准
|
|
80
|
+
const full = foldJsonl("hint", [...groupA, ...groupB].join("\n") + "\n");
|
|
81
|
+
|
|
82
|
+
// 增量路径 1:从 0 开始吃两帧
|
|
83
|
+
const s1 = newFoldState("hint");
|
|
84
|
+
await foldFrom(file, s1, 0);
|
|
85
|
+
assert.deepEqual(foldSnapshot(s1), full, "增量自零 === 全量");
|
|
86
|
+
|
|
87
|
+
// 增量路径 2:只有帧1 时推进(水位=帧1尾),追加帧2 后再从水位续吃
|
|
88
|
+
const file2 = join(TMP, "session.v3.jsonl.b.zstd");
|
|
89
|
+
await writeFile(file2, f1);
|
|
90
|
+
const s2 = newFoldState("hint");
|
|
91
|
+
const wm1 = await foldFrom(file2, s2, 0);
|
|
92
|
+
assert.equal(wm1, f1.length, "单帧文件水位精确");
|
|
93
|
+
await writeFile(file2, Buffer.concat([f1, f2]));
|
|
94
|
+
const wm2 = await foldFrom(file2, s2, wm1);
|
|
95
|
+
assert.equal(wm2, f1.length + f2.length, "追加后水位推进到全帧尾");
|
|
96
|
+
assert.deepEqual(foldSnapshot(s2), full, "分段推进与一次到位等价(重试 last-wins + 计数单调)");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("FoldStore:冷启动全量 → 未变复用零解压 → 追加走增量 → 持久化重启复用", { skip: ZSTD_OK ? false : "无 zstd CLI" }, async () => {
|
|
100
|
+
TMP = TMP || (await mkdtemp(join(tmpdir(), "usg-store-")));
|
|
101
|
+
const root = join(TMP, "ws", "s-inc");
|
|
102
|
+
const file = join(root, "session.v3.jsonl.zstd");
|
|
103
|
+
await mkdir(root, { recursive: true });
|
|
104
|
+
const f1 = await compressFrame(SESSION_LINES.join("\n") + "\n");
|
|
105
|
+
const f2 = await compressFrame(MORE_LINES.join("\n") + "\n");
|
|
106
|
+
await writeFile(file, f1);
|
|
107
|
+
|
|
108
|
+
const store = new FoldStore(join(TMP, "ws"));
|
|
109
|
+
const st1 = await stat(file);
|
|
110
|
+
const foldA = await store.foldFor(file, { size: st1.size, mtimeMs: st1.mtimeMs, ino: st1.ino });
|
|
111
|
+
assert.equal(foldA.facts.length, 1);
|
|
112
|
+
assert.equal(store.rows.get(file)!.bytes, f1.length, "首轮记录真实帧水位");
|
|
113
|
+
|
|
114
|
+
// 未变:直接复用(行对象同一引用 = 0 解压)
|
|
115
|
+
const foldA2 = await store.foldFor(file, { size: st1.size, mtimeMs: st1.mtimeMs, ino: st1.ino });
|
|
116
|
+
assert.equal(foldA2.facts, foldA.facts, "未变文件复用存量 fold 引用");
|
|
117
|
+
|
|
118
|
+
// 追加第二帧 → 增量推进,等价全量
|
|
119
|
+
await writeFile(file, Buffer.concat([f1, f2]));
|
|
120
|
+
const st2 = await stat(file);
|
|
121
|
+
const foldB = await store.foldFor(file, { size: st2.size, mtimeMs: st2.mtimeMs, ino: st2.ino });
|
|
122
|
+
const expected = foldJsonl("s-inc", SESSION_LINES.join("\n") + "\n" + MORE_LINES.join("\n") + "\n");
|
|
123
|
+
assert.deepEqual(
|
|
124
|
+
foldB.facts.map((f) => `${f.scope}:${f.input}`),
|
|
125
|
+
expected.facts.map((f) => `${f.scope}:${f.input}`),
|
|
126
|
+
);
|
|
127
|
+
assert.equal(foldB.meta!.userMessages, 1);
|
|
128
|
+
assert.equal(store.rows.get(file)!.bytes, f1.length + f2.length, "增量水位推进到文件尾");
|
|
129
|
+
|
|
130
|
+
// 落盘 + 新实例(= host 重启)复用存量
|
|
131
|
+
await store.flush(new Set([file]));
|
|
132
|
+
const reopened = new FoldStore(join(TMP, "ws"));
|
|
133
|
+
await reopened.load();
|
|
134
|
+
assert.ok(reopened.rows.has(file), "持久化行恢复");
|
|
135
|
+
const foldC = await reopened.foldFor(file, { size: st2.size, mtimeMs: st2.mtimeMs, ino: st2.ino });
|
|
136
|
+
assert.equal(foldC.facts.length, expected.facts.length, "重启后未变文件 0 重解出同数事实");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("版本门:低于当前 VERSION 的存量 payload 整体作废冷重扫(unknown facts 冻结教训的保险丝)", { skip: ZSTD_OK ? false : "无 zstd CLI" }, async () => {
|
|
140
|
+
const T = await mkdtemp(join(tmpdir(), "usg-ver-")); // 独立 tmp:cache 文件不与其他用例共享
|
|
141
|
+
const ws = join(T, "ws-ver");
|
|
142
|
+
const root = join(ws, "s-ver");
|
|
143
|
+
const file = join(root, "session.v3.jsonl.zstd");
|
|
144
|
+
await mkdir(root, { recursive: true });
|
|
145
|
+
await writeFile(file, await compressFrame(SESSION_LINES.join("\n") + "\n"));
|
|
146
|
+
const store = new FoldStore(ws);
|
|
147
|
+
const st = await stat(file);
|
|
148
|
+
await store.foldFor(file, { size: st.size, mtimeMs: st.mtimeMs, ino: st.ino });
|
|
149
|
+
await store.flush(new Set([file]));
|
|
150
|
+
|
|
151
|
+
// 盘上必须是当前版本(flush 写 VERSION,不是历史常数)。constructor(root=sessions 区) → cache 落在其父级。
|
|
152
|
+
const cacheFile = join(T, "cache", "usage-stats.folds.json");
|
|
153
|
+
const payload = JSON.parse(await readFile(cacheFile, "utf8"));
|
|
154
|
+
assert.equal(payload.version, VERSION, "落盘 payload 带当前版本");
|
|
155
|
+
assert.ok(payload.version >= 2, "版本门至少为 2(v1 = 曾冻结 unknown facts 的带病存量)");
|
|
156
|
+
|
|
157
|
+
// 手动降级 = 模拟旧版折叠逻辑的存量:load 必须整体拒收,行清零。
|
|
158
|
+
payload.version = 1;
|
|
159
|
+
await writeFile(cacheFile, JSON.stringify(payload));
|
|
160
|
+
const cold = new FoldStore(ws);
|
|
161
|
+
await cold.load();
|
|
162
|
+
assert.equal(cold.rows.size, 0, "旧版本 payload 不得复用任何行");
|
|
163
|
+
// 拒收后重扫 = 全量重建并以当前版本回写。
|
|
164
|
+
const fold = await cold.foldFor(file, { size: st.size, mtimeMs: st.mtimeMs, ino: st.ino });
|
|
165
|
+
assert.equal(fold.facts.length, 1, "冷重扫产出正常事实");
|
|
166
|
+
await cold.flush(new Set([file]));
|
|
167
|
+
assert.equal(JSON.parse(await readFile(cacheFile, "utf8")).version, VERSION, "回写恢复当前版本");
|
|
168
|
+
});
|