@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +47 -3
  4. package/dist/capabilities/anything.js +26 -0
  5. package/dist/capabilities/doctor.js +11 -4
  6. package/dist/capabilities/ground-transfer.js +168 -1
  7. package/dist/capabilities/provider-detail-sanitize.js +64 -0
  8. package/dist/capabilities/route-provider-conformance.js +341 -0
  9. package/dist/scripts/anything-path-measure-tests.js +133 -0
  10. package/dist/scripts/anything-path-measure.js +150 -0
  11. package/dist/scripts/anything-tests.js +136 -2
  12. package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
  13. package/dist/scripts/bootstrap-tests.js +71 -1
  14. package/dist/scripts/city-scenario-tier-tests.js +234 -0
  15. package/dist/scripts/doctor-tests.js +37 -1
  16. package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
  17. package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
  18. package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
  19. package/dist/scripts/dsh-llm-tests.js +437 -0
  20. package/dist/scripts/g5-guard-tests.js +20 -1
  21. package/dist/scripts/g5-guard.js +5 -1
  22. package/dist/scripts/m3-cohort-tests.js +714 -0
  23. package/dist/scripts/m3-cohort.js +310 -0
  24. package/dist/scripts/nightly-evidence-tests.js +44 -2
  25. package/dist/scripts/nightly-evidence.js +21 -4
  26. package/dist/scripts/outcome-projection-tests.js +427 -0
  27. package/dist/scripts/persona-sim-tests.js +603 -0
  28. package/dist/scripts/persona-sim.js +1081 -0
  29. package/dist/scripts/replay-async.js +2 -0
  30. package/dist/scripts/replay.js +2 -0
  31. package/dist/scripts/route-provider-conformance-tests.js +391 -0
  32. package/dist/scripts/session-zone-crash.js +49 -0
  33. package/dist/scripts/session-zone-ledger-tests.js +432 -0
  34. package/dist/scripts/session-zone-observation-tests.js +492 -0
  35. package/dist/scripts/session-zone-observe.js +135 -0
  36. package/dist/scripts/session-zone-wiring-tests.js +566 -0
  37. package/dist/scripts/session-zones-tests.js +17 -1
  38. package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
  39. package/dist/scripts/state-cli.js +16 -0
  40. package/dist/scripts/time-eval-tests.js +2 -0
  41. package/dist/scripts/unified-tests.js +37 -1
  42. package/dist/scripts/z3-race-tests.js +6 -1
  43. package/dist/src/city-scenario-tier.js +378 -0
  44. package/dist/src/dsh-llm.js +84 -14
  45. package/dist/src/index.js +160 -1
  46. package/dist/src/loop.js +16 -5
  47. package/dist/src/m3-cohort.js +1577 -0
  48. package/dist/src/outcome-projection.js +746 -0
  49. package/dist/src/session-zone-ledger.js +312 -0
  50. package/dist/src/session-zone-observation.js +354 -0
  51. package/dist/src/session-zone-wiring.js +658 -0
  52. package/dist/src/session-zones.js +7 -4
  53. package/dist/src/state-ledger.js +293 -40
  54. package/dist/src/unified.js +69 -5
  55. package/extension/manifest.json +2 -2
  56. package/package.json +1 -1
  57. package/ts/capabilities/anything.ts +27 -0
  58. package/ts/scripts/state-cli.ts +18 -0
  59. package/ts/src/contracts.ts +4 -0
  60. package/ts/src/dsh-llm.ts +111 -9
  61. package/ts/src/index.ts +175 -2
  62. package/ts/src/loop.ts +15 -5
  63. package/ts/src/state-ledger.ts +294 -40
  64. package/ts/src/unified.ts +91 -18
@@ -0,0 +1,432 @@
1
+ /**
2
+ * 会话双区记忆账本落点单测(P4-2,design/session-dual-zone-memory-design.md §5):
3
+ * - 六个 kind 以日志类事件落在**既有** events 表(零新表/零 schema 迁移);
4
+ * - 单事务{fold 读 rev;守门闸;INSERT}:stale_rev / 负面清单 / 闭集在插入前拒绝,零新行;
5
+ * - 同 rev 重放 = 物理 no-op(守门层 + UNIQUE 索引两道);drop→重捕获不被幂等键吞;
6
+ * - kill -9 中途 = 要么全有要么全无(真实子进程 SIGKILL,事务未提交即零行);
7
+ * - forget 物理硬删分区事件 + 恰一行审计;会话级多主体仍只一行审计;
8
+ * - 导出视图 == fold 输出(逐字节),且导出零新事件(视图永不是写路径);
9
+ * - 日志触读上界 → log_truncated fail-closed;坏行/伪造文档确定性跳过不投影;
10
+ * - 分区事件对既有投影零影响(日志类事件在 foldEvent default 分支无投影)。
11
+ *
12
+ * 全离线:mkdtemp 隔离 stateRoot(绝不碰 ts/dsh-runtime/gotry-state),零网络,
13
+ * 零真实 LLM;子进程有界 timeout;临时目录在 finally 清理。
14
+ * 运行:cd ts && npx tsx scripts/session-zone-ledger-tests.ts
15
+ */
16
+ import assert from 'node:assert/strict';
17
+ import { spawnSync } from 'node:child_process';
18
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { ensureLedger, openLedgerIfExists } from '../src/state-ledger.js';
22
+ import { HOT_ZONE_EVENT_KINDS, NOTEBOOK_ZONE_EVENT_KINDS, ZONE_EXPORT_HOT_FILE, ZONE_EXPORT_NOTEBOOK_FILE, appendZoneWrite, appendZoneWrites, forgetZoneSession, forgetZoneSubjects, readZoneExportViews, readZoneLog, renderZoneExportViews, } from '../src/session-zone-ledger.js';
23
+ import { ZONE_EVENT_KINDS, foldZoneEvents, readWorkingZone, zoneIdemKey } from '../src/session-zones.js';
24
+ let n = 0;
25
+ function pass(name, body) {
26
+ body();
27
+ console.log(` ${++n}. ${name} OK`);
28
+ }
29
+ const T0 = '2026-10-04T08:00:00.000Z';
30
+ const MIN = 60_000;
31
+ const at = (ms) => new Date(Date.parse(T0) + ms).toISOString();
32
+ const SESS_A = 'sess-zone-ledger-a';
33
+ const SESS_B = 'sess-zone-ledger-b';
34
+ const QUOTE = '我以后出门都不坐红眼航班';
35
+ const roots = [];
36
+ function freshRoot(tag) {
37
+ const root = mkdtempSync(join(tmpdir(), `gotry-zone-${tag}-`));
38
+ roots.push(root);
39
+ return root;
40
+ }
41
+ function freshLedger(tag) {
42
+ const root = freshRoot(tag);
43
+ return { root, ledger: ensureLedger(root) };
44
+ }
45
+ function captureReq(over = {}) {
46
+ const session_ref = over.session_ref ?? SESS_A;
47
+ return {
48
+ op: 'capture',
49
+ input: {
50
+ session_ref,
51
+ tier: over.tier ?? 'intent',
52
+ kind: over.kind ?? 'destination',
53
+ payload: over.payload ?? { city: '大理' },
54
+ evidence_ref: { session_ref, turn: over.turn ?? 1 },
55
+ ts: over.ts ?? T0,
56
+ },
57
+ };
58
+ }
59
+ function ok(r) {
60
+ assert.ok(r.ok, `写门必须接受:${JSON.stringify(r)}`);
61
+ return r;
62
+ }
63
+ function zoneEventCount(ledger) {
64
+ return ZONE_EVENT_KINDS.reduce((sum, k) => sum + ledger.readEvents(k, 10_000).length, 0);
65
+ }
66
+ try {
67
+ // 1. 落账形状:事件 kind/subject/idem_key/actor + 读回 fold == 直读
68
+ pass('六 kind 落既有 events 表:事件行形状 + readZoneLog fold == 直读投影', () => {
69
+ const { ledger } = freshLedger('shape');
70
+ const r = ok(appendZoneWrite(ledger, captureReq(), { actor: 'tool:gotry_session_zone_note' }));
71
+ assert.equal(r.appended, true);
72
+ const rows = ledger.readEvents('hotctx.note.captured', 10);
73
+ assert.equal(rows.length, 1, '恰一行事件');
74
+ const row = rows[0];
75
+ assert.equal(row.kind, 'hotctx.note.captured');
76
+ assert.equal(row.actor, 'tool:gotry_session_zone_note');
77
+ assert.equal(row.subject_id, r.event.kind === 'hotctx.note.captured' ? r.event.note.note_id : '');
78
+ assert.equal(row.idem_key, zoneIdemKey(r.event));
79
+ assert.equal(row.ts, T0, '事件 ts 原样落账(drop 幂等键与 TTL 基点都依赖它)');
80
+ const payload = JSON.parse(row.payload);
81
+ assert.equal(payload.note?.zone, 'hot_context', '全量文档随事件走(trip.logged 同形)');
82
+ const read = readZoneLog(ledger);
83
+ assert.equal(read.truncated, false);
84
+ assert.equal(read.malformed, 0);
85
+ assert.deepEqual(read.state, foldZoneEvents(read.events), '重建 == 直读');
86
+ assert.equal(Object.keys(read.state.hot).length, 1);
87
+ // 零新表:schema 仍只有既有表
88
+ const tables = ledger.db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all().map(t => t.name);
89
+ assert.deepEqual(tables.filter(t => /zone|hot|notebook/i.test(t)), [], '零新表:分区不得引入任何专表');
90
+ });
91
+ // 2. 同 rev 重放 = 物理 no-op
92
+ pass('幂等:同 rev 同内容重放零新行(守门层 appended:false,countEvents 不变)', () => {
93
+ const { ledger } = freshLedger('idem');
94
+ ok(appendZoneWrite(ledger, captureReq()));
95
+ const before = ledger.countEvents();
96
+ const replay = ok(appendZoneWrite(ledger, captureReq()));
97
+ assert.equal(replay.appended, false, '重放不得产生事件');
98
+ assert.equal(ledger.countEvents(), before, '物理 no-op:事件总数不变');
99
+ // 修订后重放修订(父 rev 旧值 + 同内容)同样是 no-op
100
+ const noteId = Object.keys(readZoneLog(ledger).state.hot)[0];
101
+ ok(appendZoneWrite(ledger, { op: 'revise', input: { note_id: noteId, expected_rev: 1, payload: { city: '丽江' }, ts: at(10 * MIN) } }));
102
+ const afterRevise = ledger.countEvents();
103
+ const replayRevise = ok(appendZoneWrite(ledger, { op: 'revise', input: { note_id: noteId, expected_rev: 1, payload: { city: '丽江' }, ts: at(10 * MIN) } }));
104
+ assert.equal(replayRevise.appended, false);
105
+ assert.equal(ledger.countEvents(), afterRevise);
106
+ assert.equal(readZoneLog(ledger).state.hot[noteId].rev, 2, '重放不得推进 rev');
107
+ });
108
+ // 3. UNIQUE 索引这一道:绕过守门的同语义事件也只落一行
109
+ pass('幂等第二道:UNIQUE 索引让同幂等键的重复 INSERT 物理 no-op', () => {
110
+ const { ledger } = freshLedger('unique');
111
+ const r = ok(appendZoneWrite(ledger, captureReq()));
112
+ const before = ledger.countEvents();
113
+ const seq = ledger.insertEvent({
114
+ actor: 'test:bypass',
115
+ kind: 'hotctx.note.captured',
116
+ subjectId: 'x',
117
+ payload: { note: { forged: true } },
118
+ idemKey: r.idemKey,
119
+ ts: T0,
120
+ });
121
+ assert.equal(seq, null, '同幂等键的 INSERT 返回 null(物理 no-op)');
122
+ assert.equal(ledger.countEvents(), before);
123
+ });
124
+ // 4. drop→重捕获在账本上真的复活(幂等键生代修复的回归锚)
125
+ pass('drop→重捕获:同 id 同 rev 的新生命周期必须落账(不被幂等键吞)', () => {
126
+ const { ledger } = freshLedger('relife');
127
+ const first = ok(appendZoneWrite(ledger, captureReq()));
128
+ const noteId = first.event.kind === 'hotctx.note.captured' ? first.event.note.note_id : '';
129
+ ok(appendZoneWrite(ledger, { op: 'drop', note_id: noteId, ts: at(5 * MIN) }));
130
+ assert.equal(Object.keys(readZoneLog(ledger).state.hot).length, 0, 'drop 后出状态');
131
+ const reborn = ok(appendZoneWrite(ledger, captureReq({ ts: at(6 * MIN) })));
132
+ assert.equal(reborn.appended, true, '更正后仍真:重捕获必须落账');
133
+ const state = readZoneLog(ledger).state;
134
+ assert.equal(state.hot[noteId].created_at, at(6 * MIN), '新生命周期自新写入起算');
135
+ assert.notEqual(reborn.idemKey, first.idemKey);
136
+ });
137
+ // 5. 插入前 fail-closed:stale_rev / 闭集 / 负面清单 / 未知主体,一律零新行
138
+ pass('fail-closed:stale_rev / 负面清单 / 闭集 / 未知主体在 INSERT 前拒绝,账本零新行', () => {
139
+ const { ledger } = freshLedger('failclosed');
140
+ ok(appendZoneWrite(ledger, captureReq()));
141
+ const noteId = Object.keys(readZoneLog(ledger).state.hot)[0];
142
+ const before = ledger.countEvents();
143
+ const stale = appendZoneWrite(ledger, { op: 'revise', input: { note_id: noteId, expected_rev: 7, payload: { city: '香格里拉' }, ts: at(MIN) } });
144
+ assert.ok(!stale.ok && stale.code === 'stale_rev', `应 stale_rev:${JSON.stringify(stale)}`);
145
+ const neg = appendZoneWrite(ledger, captureReq({ payload: { city: '大理', note: '联系 13800138000' }, ts: at(2 * MIN) }));
146
+ assert.ok(!neg.ok && neg.code === 'negative_list', `应 negative_list:${JSON.stringify(neg)}`);
147
+ const url = appendZoneWrite(ledger, captureReq({ payload: { city: '大理', src: 'https://example.com/x' }, ts: at(3 * MIN) }));
148
+ assert.ok(!url.ok && url.code === 'negative_list');
149
+ const closed = appendZoneWrite(ledger, { op: 'capture', input: { ...captureReq().input, kind: 'nope', ts: at(4 * MIN) } });
150
+ assert.ok(!closed.ok && closed.code === 'closed_set');
151
+ const unknown = appendZoneWrite(ledger, { op: 'drop', note_id: 'hn|nope', ts: at(5 * MIN) });
152
+ assert.ok(!unknown.ok && unknown.code === 'unknown_subject');
153
+ const noQuote = appendZoneWrite(ledger, { op: 'promote', input: { note_id: noteId, kind: 'preference', owner_confirm: { surface: 'user_reply', quote: '' }, ts: at(6 * MIN) } });
154
+ assert.ok(!noQuote.ok && noQuote.code === 'owner_confirm', '缺 owner 引用的晋升必须拒收');
155
+ assert.equal(ledger.countEvents(), before, '全部拒绝路径零新行(拒绝即回滚,账本无痕)');
156
+ });
157
+ // 6. 批量单事务:任一被拒 → 全批回滚
158
+ pass('批量单事务:合法+非法混批 → 整批回滚,零新行', () => {
159
+ const { ledger } = freshLedger('batch');
160
+ const before = ledger.countEvents();
161
+ const results = appendZoneWrites(ledger, [
162
+ captureReq({ payload: { city: '清迈' } }),
163
+ captureReq({ payload: { city: '清迈', phone: '13900139000' }, ts: at(MIN) }),
164
+ ]);
165
+ assert.equal(results.length, 2);
166
+ assert.ok(results[0].ok && results[0].appended, '首项在事务内被接受');
167
+ assert.ok(!results[1].ok && results[1].code === 'negative_list');
168
+ assert.equal(ledger.countEvents(), before, '全有或全无:首项也被回滚');
169
+ assert.equal(Object.keys(readZoneLog(ledger).state.hot).length, 0);
170
+ // 批内自洽:同事务链式 CAS 成立(第二项的父 rev 来自同事务内第一项的结果)
171
+ ok(appendZoneWrite(ledger, captureReq({ payload: { city: '清迈' } })));
172
+ const noteId = Object.keys(readZoneLog(ledger).state.hot)[0];
173
+ const chain = appendZoneWrites(ledger, [
174
+ { op: 'revise', input: { note_id: noteId, expected_rev: 1, payload: { city: '拜县' }, ts: at(3 * MIN) } },
175
+ { op: 'revise', input: { note_id: noteId, expected_rev: 2, payload: { city: '湄宏顺' }, ts: at(4 * MIN) } },
176
+ ]);
177
+ assert.ok(chain.every(r => r.ok && r.appended), `同事务链式 CAS 应全部接受:${JSON.stringify(chain)}`);
178
+ assert.equal(readZoneLog(ledger).state.hot[noteId].rev, 3);
179
+ });
180
+ // 7. kill -9:要么全有要么全无
181
+ pass('崩溃注入:提交前 SIGKILL → 账本零分区事件;同两次追加正常提交 → 恰两行', () => {
182
+ const crashRoot = freshRoot('crash');
183
+ const crash = spawnSync('npx', ['tsx', 'scripts/session-zone-crash.ts', crashRoot, 'pre-commit'], {
184
+ encoding: 'utf-8',
185
+ timeout: 120_000,
186
+ });
187
+ // npx 外壳会把子进程的致命信号折算成 status 137(128+9);两种形态都算 kill -9
188
+ assert.ok(crash.signal === 'SIGKILL' || crash.status === 137, `探针必须被 SIGKILL(signal=SIGKILL 或 status=137):${crash.status}/${crash.signal}/${crash.stderr?.slice(0, 300)}`);
189
+ assert.equal(crash.stdout?.includes('committed'), false, '崩溃路径不得走到提交后的输出');
190
+ const crashed = openLedgerIfExists(crashRoot);
191
+ assert.ok(crashed, '崩溃后账本文件应存在');
192
+ assert.equal(zoneEventCount(crashed), 0, '未提交事务:零分区事件(全无)');
193
+ assert.equal(Object.keys(readZoneLog(crashed).state.hot).length, 0);
194
+ const okRoot = freshRoot('crash-ok');
195
+ const good = spawnSync('npx', ['tsx', 'scripts/session-zone-crash.ts', okRoot, 'committed'], {
196
+ encoding: 'utf-8',
197
+ timeout: 120_000,
198
+ });
199
+ assert.equal(good.status, 0, `正常提交探针应 exit 0:${good.stderr?.slice(0, 300)}`);
200
+ const committed = openLedgerIfExists(okRoot);
201
+ assert.equal(zoneEventCount(committed), 2, '正常提交:恰两行(全有)');
202
+ assert.equal(Object.keys(readZoneLog(committed).state.hot).length, 2);
203
+ });
204
+ // 8. 晋升链 + 笔记本修订/删除全链落账
205
+ pass('晋升→修订→移除全链落账:owner 引用随事件走,血缘保留', () => {
206
+ const { ledger } = freshLedger('promote');
207
+ ok(appendZoneWrite(ledger, captureReq()));
208
+ const noteId = Object.keys(readZoneLog(ledger).state.hot)[0];
209
+ const promoted = ok(appendZoneWrite(ledger, {
210
+ op: 'promote',
211
+ input: { note_id: noteId, kind: 'preference', owner_confirm: { surface: 'user_reply', quote: QUOTE }, ts: at(MIN) },
212
+ }));
213
+ const entryId = promoted.event.kind === 'notebook.entry.promoted' ? promoted.event.entry.entry_id : '';
214
+ const afterPromote = readZoneLog(ledger).state;
215
+ assert.equal(afterPromote.notebook[entryId].owner_confirm.quote, QUOTE);
216
+ assert.deepEqual(afterPromote.notebook[entryId].origin, { session_ref: SESS_A, note_id: noteId }, '血缘保留');
217
+ assert.ok(afterPromote.hot[noteId], '晋升不移除工作区笔记');
218
+ ok(appendZoneWrite(ledger, {
219
+ op: 'notebook_revise',
220
+ input: { entry_id: entryId, expected_rev: 1, payload: { redeye: 'never', since: '2026' }, owner_confirm: { surface: 'approval_card', quote: QUOTE }, ts: at(2 * MIN) },
221
+ }));
222
+ assert.equal(readZoneLog(ledger).state.notebook[entryId].rev, 2);
223
+ ok(appendZoneWrite(ledger, { op: 'notebook_drop', entry_id: entryId, ts: at(3 * MIN) }));
224
+ assert.equal(readZoneLog(ledger).state.notebook[entryId], undefined, 'drop 后出状态');
225
+ assert.equal(ledger.readEvents('notebook.entry.dropped', 10).length, 1, '审计行仍在事件日志');
226
+ });
227
+ // 9. forget:物理硬删 + 恰一行审计
228
+ pass('forget 主体级:物理硬删该主体分区事件 + 恰一行审计;其他主体不受影响', () => {
229
+ const { ledger } = freshLedger('forget');
230
+ ok(appendZoneWrite(ledger, captureReq()));
231
+ ok(appendZoneWrite(ledger, captureReq({ payload: { city: '清迈' }, ts: at(MIN) })));
232
+ const ids = Object.keys(readZoneLog(ledger).state.hot).sort();
233
+ const target = ids[0];
234
+ const auditBefore = ledger.readEvents('forget.executed', 100).length;
235
+ const r = forgetZoneSubjects(ledger, [{ zone: 'hot_context', id: target }]);
236
+ assert.equal(r.deleted, 1, '物理删除该主体的 1 条事件');
237
+ const after = readZoneLog(ledger);
238
+ assert.equal(after.state.hot[target], undefined, '被遗忘主体彻底消失');
239
+ assert.equal(Object.keys(after.state.hot).length, 1, '其他主体不受影响');
240
+ assert.equal(ledger.readEvents('forget.executed', 100).length, auditBefore + 1, '删除本身恰留一行审计');
241
+ const audit = JSON.parse(ledger.readEvents('forget.executed', 1)[0].payload);
242
+ assert.deepEqual(audit.subjects[0].kinds, [...HOT_ZONE_EVENT_KINDS], '审计行记录被删的 kind 集合');
243
+ });
244
+ pass('forget 会话级:多主体一次调用 → 仍恰一行审计;笔记本血缘同会话一并删除', () => {
245
+ const { ledger } = freshLedger('forget-session');
246
+ ok(appendZoneWrite(ledger, captureReq()));
247
+ ok(appendZoneWrite(ledger, captureReq({ payload: { city: '清迈' }, ts: at(MIN) })));
248
+ ok(appendZoneWrite(ledger, captureReq({ session_ref: SESS_B, payload: { city: '东京' }, ts: at(2 * MIN) })));
249
+ const noteId = Object.keys(readZoneLog(ledger).state.hot).find(id => id.includes(SESS_A));
250
+ ok(appendZoneWrite(ledger, {
251
+ op: 'promote',
252
+ input: { note_id: noteId, kind: 'preference', owner_confirm: { surface: 'user_reply', quote: QUOTE }, ts: at(3 * MIN) },
253
+ }));
254
+ const auditBefore = ledger.readEvents('forget.executed', 100).length;
255
+ const r = forgetZoneSession(ledger, SESS_A);
256
+ assert.ok(r.ok, JSON.stringify(r));
257
+ assert.equal(r.subjects, 3, '2 条笔记 + 1 条笔记本条目');
258
+ assert.equal(r.deleted, 3);
259
+ assert.equal(ledger.readEvents('forget.executed', 100).length, auditBefore + 1, '多主体仍只一行审计');
260
+ const sessionAudit = JSON.parse(ledger.readEvents('forget.executed', 1)[0].payload);
261
+ assert.ok(sessionAudit.subjects.some(s => s.kinds.join(',') === [...NOTEBOOK_ZONE_EVENT_KINDS].join(',')), '审计行覆盖笔记本三 kind');
262
+ const after = readZoneLog(ledger).state;
263
+ assert.equal(Object.keys(after.notebook).length, 0);
264
+ assert.deepEqual(Object.values(after.hot).map(h => h.session_ref), [SESS_B], '另一会话不受影响');
265
+ const nobody = forgetZoneSession(ledger, 'sess-nobody');
266
+ assert.ok(nobody.ok);
267
+ assert.equal(nobody.deleted, 0, '无主体时零删除零审计');
268
+ assert.equal(ledger.readEvents('forget.executed', 100).length, auditBefore + 1);
269
+ });
270
+ // 10b. 截断日志上的遗忘/导出一律拒绝(红线 6「可删除」不得 fail-open)
271
+ pass('截断即拒:遗忘与导出都拒绝在不完整 fold 上执行(readEvents 丢的是最老事件)', () => {
272
+ const { root, ledger } = freshLedger('truncated-forget');
273
+ ok(appendZoneWrite(ledger, captureReq()));
274
+ ok(appendZoneWrite(ledger, captureReq({ payload: { city: '清迈' }, ts: at(MIN) })));
275
+ ok(appendZoneWrite(ledger, captureReq({ payload: { city: '东京' }, ts: at(2 * MIN) })));
276
+ const auditBefore = ledger.readEvents('forget.executed', 100).length;
277
+ const forget = forgetZoneSession(ledger, SESS_A, 'system:test', { limit: 2 });
278
+ assert.equal(forget.ok, false, '截断时遗忘必须拒绝(否则静默漏删主体)');
279
+ assert.equal(forget.code, 'log_truncated');
280
+ assert.equal(ledger.readEvents('forget.executed', 100).length, auditBefore, '拒绝路径零审计行零删除');
281
+ assert.equal(Object.keys(readZoneLog(ledger).state.hot).length, 3, '主体一个都没被删');
282
+ const views = readZoneExportViews(ledger, { limit: 2 });
283
+ assert.equal(views.ok, false, '截断时导出必须拒绝(残缺视图会被当全量)');
284
+ assert.equal(views.code, 'log_truncated');
285
+ const full = readZoneExportViews(ledger);
286
+ assert.ok(full.ok);
287
+ assert.equal(full.views.hotContextJsonl.split('\n').filter(Boolean).length, 3);
288
+ void root;
289
+ });
290
+ // 10. 导出视图 == fold 输出,且导出零新事件
291
+ pass('state-cli export:两个派生视图逐字节 == fold 输出,且导出零新事件', () => {
292
+ const { root, ledger } = freshLedger('export');
293
+ ok(appendZoneWrite(ledger, captureReq()));
294
+ ok(appendZoneWrite(ledger, captureReq({ tier: 'resource', kind: 'price_band', payload: { low: 400, high: 900 }, ts: at(MIN) })));
295
+ const noteId = Object.keys(readZoneLog(ledger).state.hot).find(id => id.includes('destination'));
296
+ ok(appendZoneWrite(ledger, {
297
+ op: 'promote',
298
+ input: { note_id: noteId, kind: 'preference', owner_confirm: { surface: 'user_reply', quote: QUOTE }, ts: at(2 * MIN) },
299
+ }));
300
+ const expected = renderZoneExportViews(readZoneLog(ledger).state);
301
+ const before = ledger.countEvents();
302
+ ledger.close();
303
+ const run = spawnSync('npx', ['tsx', 'scripts/state-cli.ts', 'export', root], { encoding: 'utf-8', timeout: 120_000 });
304
+ assert.equal(run.status, 0, `export 应 exit 0:${run.stderr?.slice(0, 400)}`);
305
+ const dir = join(root, 'gotry-state');
306
+ assert.equal(readFileSync(join(dir, ZONE_EXPORT_HOT_FILE), 'utf-8'), expected.hotContextJsonl, 'hot 视图 == fold 输出');
307
+ assert.equal(readFileSync(join(dir, ZONE_EXPORT_NOTEBOOK_FILE), 'utf-8'), expected.notebookJson, 'notebook 视图 == fold 输出');
308
+ const reopened = openLedgerIfExists(root);
309
+ assert.equal(reopened.countEvents(), before, '导出是视图,不是写路径:零新事件');
310
+ // 空分区不产生空文件(与既有 legacy 视图同纪律)
311
+ const { root: emptyRoot } = freshLedger('export-empty');
312
+ const empty = spawnSync('npx', ['tsx', 'scripts/state-cli.ts', 'export', emptyRoot], { encoding: 'utf-8', timeout: 120_000 });
313
+ assert.equal(empty.status, 0);
314
+ assert.equal(existsSync(join(emptyRoot, 'gotry-state', ZONE_EXPORT_HOT_FILE)), false);
315
+ assert.equal(existsSync(join(emptyRoot, 'gotry-state', ZONE_EXPORT_NOTEBOOK_FILE)), false);
316
+ });
317
+ // 10c. capture_or_touch:过期后重捕获 / 同一检索重复出现都必须续命
318
+ pass('capture_or_touch:过期笔记续命复活、重复同形观察续 TTL;纯 capture 仍 stale_rev', () => {
319
+ const { ledger } = freshLedger('touch');
320
+ const birth = ok(appendZoneWrite(ledger, { op: 'capture_or_touch', input: captureReq({ tier: 'resource', kind: 'availability', payload: { tool: 'gotry_flyai_search', verdict: 'hit', option_count: 2 } }).input }));
321
+ assert.equal(birth.appended, true);
322
+ const noteId = Object.keys(readZoneLog(ledger).state.hot)[0];
323
+ const first = readZoneLog(ledger).state.hot[noteId];
324
+ assert.equal(first.rev, 1);
325
+ assert.equal(first.ttl_expires_at, new Date(Date.parse(T0) + 30 * MIN).toISOString());
326
+ // 纯 capture:同 id 不同时刻 → stale_rev(未修复时观察缝就卡在这里)
327
+ const plain = appendZoneWrite(ledger, { op: 'capture', input: { ...captureReq({ tier: 'resource', kind: 'availability', payload: { tool: 'gotry_flyai_search', verdict: 'hit', option_count: 2 }, ts: at(40 * MIN) }).input } });
328
+ assert.ok(!plain.ok && plain.code === 'stale_rev', `纯 capture 仍应 stale_rev:${JSON.stringify(plain)}`);
329
+ // 40 分钟后(已过期)同形观察:续命 → rev 2 且到期时刻推到 40min+30min
330
+ const touched = ok(appendZoneWrite(ledger, { op: 'capture_or_touch', input: captureReq({ tier: 'resource', kind: 'availability', payload: { tool: 'gotry_flyai_search', verdict: 'hit', option_count: 2 }, ts: at(40 * MIN) }).input }));
331
+ assert.equal(touched.appended, true, '过期笔记必须能被续命(否则工作区永久停供该形态)');
332
+ assert.equal(touched.event?.kind, 'hotctx.note.revised');
333
+ const revived = readZoneLog(ledger).state.hot[noteId];
334
+ assert.equal(revived.rev, 2);
335
+ assert.equal(revived.created_at, T0, '续命保留诞生时刻(血缘不漂移)');
336
+ assert.equal(revived.last_touched_at, at(40 * MIN));
337
+ assert.equal(revived.ttl_expires_at, new Date(Date.parse(T0) + 70 * MIN).toISOString(), 'TTL 自最后一次写入起算');
338
+ assert.deepEqual(readWorkingZone(readZoneLog(ledger).state, { now: at(41 * MIN), session_ref: SESS_A }).map(x => x.rev), [2], '续命后重新进入读视图');
339
+ // 同 ts 同内容重放仍是幂等 no-op(不白记一条)
340
+ const replay = ok(appendZoneWrite(ledger, { op: 'capture_or_touch', input: captureReq({ tier: 'resource', kind: 'availability', payload: { tool: 'gotry_flyai_search', verdict: 'hit', option_count: 2 }, ts: at(40 * MIN) }).input }));
341
+ assert.equal(replay.appended, false);
342
+ // 其他拒收(负面清单)不得被 capture_or_touch 放宽
343
+ const neg = appendZoneWrite(ledger, { op: 'capture_or_touch', input: captureReq({ payload: { city: '大理', phone: '13800138000' }, ts: at(41 * MIN) }).input });
344
+ assert.ok(!neg.ok && neg.code === 'negative_list');
345
+ });
346
+ // 10d. 生代碰撞:显式报错,不静默吞
347
+ pass('生代碰撞:同毫秒 capture→drop→重捕获 → idem_collision 显式失败(不报假幂等)', () => {
348
+ const { ledger } = freshLedger('collision');
349
+ const first = ok(appendZoneWrite(ledger, captureReq()));
350
+ const noteId = Object.keys(readZoneLog(ledger).state.hot)[0];
351
+ ok(appendZoneWrite(ledger, { op: 'drop', note_id: noteId, ts: at(MIN) }));
352
+ const before = ledger.countEvents();
353
+ const same = appendZoneWrite(ledger, captureReq()); // 与诞生同一毫秒 → 同一幂等键
354
+ assert.equal(same.ok, false, '必须显式失败而不是报 appended:false');
355
+ assert.equal(same.code, 'idem_collision');
356
+ assert.equal(ledger.countEvents(), before, '失败路径零新行');
357
+ assert.equal(first.idemKey, `hotctx:${noteId}:${T0}:1`);
358
+ // 换一个写入时刻即可成功(生代不同)
359
+ const later = ok(appendZoneWrite(ledger, captureReq({ ts: at(2 * MIN) })));
360
+ assert.equal(later.appended, true);
361
+ });
362
+ // 11. 读上界 fail-closed
363
+ pass('日志触读上界 → log_truncated fail-closed:零写入,不在不全日志上猜 rev', () => {
364
+ const { ledger } = freshLedger('truncate');
365
+ ok(appendZoneWrite(ledger, captureReq()));
366
+ ok(appendZoneWrite(ledger, captureReq({ payload: { city: '清迈' }, ts: at(MIN) })));
367
+ const read = readZoneLog(ledger, { limit: 2 });
368
+ assert.equal(read.truncated, true, '触顶即标记');
369
+ const before = ledger.countEvents();
370
+ const r = appendZoneWrite(ledger, captureReq({ payload: { city: '东京' }, ts: at(2 * MIN) }), { limit: 2 });
371
+ assert.ok(!r.ok && r.code === 'log_truncated', `应 log_truncated:${JSON.stringify(r)}`);
372
+ assert.equal(ledger.countEvents(), before);
373
+ });
374
+ // 12. 坏行/伪造文档:确定性跳过,不投影
375
+ pass('坏行与伪造文档:坏 JSON/错形状/闭集外文档确定性跳过,fold 不投影', () => {
376
+ const { ledger } = freshLedger('malformed');
377
+ ok(appendZoneWrite(ledger, captureReq()));
378
+ ledger.insertEvent({ actor: 'test:junk', kind: 'hotctx.note.captured', subjectId: 'junk-1', payload: 'not-json-object', idemKey: 'junk:1', ts: at(MIN) });
379
+ ledger.insertEvent({ actor: 'test:junk', kind: 'hotctx.note.captured', subjectId: 'junk-2', payload: { note: 'string-not-doc' }, idemKey: 'junk:2', ts: at(2 * MIN) });
380
+ ledger.insertEvent({ actor: 'test:junk', kind: 'hotctx.note.dropped', subjectId: 'junk-3', payload: {}, idemKey: 'junk:3', ts: at(3 * MIN) });
381
+ ledger.insertEvent({
382
+ actor: 'test:junk',
383
+ kind: 'hotctx.note.captured',
384
+ subjectId: 'junk-4',
385
+ payload: { note: { zone: 'hot_context', tier: 'forever', kind: 'destination', note_id: 'junk-4', rev: 1 } },
386
+ idemKey: 'junk:4',
387
+ ts: at(4 * MIN),
388
+ });
389
+ const read = readZoneLog(ledger);
390
+ assert.equal(read.malformed, 3, '三条形状不合法的行被跳过(坏 JSON/错类型/缺字段)');
391
+ assert.equal(Object.keys(read.state.hot).length, 1, '闭集外 tier 的伪造文档不被投影');
392
+ assert.deepEqual(readZoneLog(ledger).state, read.state, '同一日志两次读结果一致(确定性)');
393
+ });
394
+ // 13. 与既有面互不干扰
395
+ pass('与既有账本面互不干扰:分区事件无投影;rebuild 后分区读回不变;租户隔离', () => {
396
+ const { root, ledger } = freshLedger('coexist');
397
+ ledger.appendWish({ name: '冰岛极光', conditions: { days: 9 } });
398
+ ledger.appendMotivationPatch({ weights: { escape_rest: 0.7 }, evidence: ['想躺平'] });
399
+ ok(appendZoneWrite(ledger, captureReq()));
400
+ const zoneBefore = readZoneLog(ledger).state;
401
+ const rebuilt = ledger.rebuildProjections();
402
+ assert.equal(rebuilt.wishes, 1, '既有投影照常重建');
403
+ assert.ok(ledger.readMotivation());
404
+ assert.deepEqual(readZoneLog(ledger).state, zoneBefore, 'rebuild 不影响分区读回(日志类事件无投影)');
405
+ const projections = ledger.db.prepare('SELECT COUNT(*) AS n FROM projection_items').get();
406
+ assert.equal(projections.n, 1, '分区事件不写 projection_items(只有 wish 一条)');
407
+ const other = ensureLedger(root, 'tenant-x');
408
+ assert.equal(zoneEventCount(other), 0, '租户隔离:另一租户读不到本租户分区事件');
409
+ assert.equal(Object.keys(readZoneLog(other).state.hot).length, 0);
410
+ });
411
+ // 14. 过期是读时视图(账本里没有过期事件)
412
+ pass('过期是读时视图:账本零过期事件;resource 30min 过期后不入读视图,事件仍在', () => {
413
+ const { ledger } = freshLedger('expiry');
414
+ ok(appendZoneWrite(ledger, captureReq({ tier: 'resource', kind: 'availability', payload: { rooms: 3 } })));
415
+ ok(appendZoneWrite(ledger, captureReq({ tier: 'intent', payload: { city: '大理' }, ts: at(MIN) })));
416
+ const state = readZoneLog(ledger).state;
417
+ assert.equal(readWorkingZone(state, { now: at(10 * MIN), session_ref: SESS_A }).length, 2);
418
+ const later = readWorkingZone(state, { now: at(31 * MIN), session_ref: SESS_A });
419
+ assert.deepEqual(later.map(x => x.tier), ['intent'], 'resource 层 30min 后出读视图');
420
+ assert.equal(readWorkingZone(state, { now: at(25 * 60 * MIN), session_ref: SESS_A }).length, 0, 'intent 层 24h 后出读视图');
421
+ assert.equal(Object.keys(state.hot).length, 2, '过期不删事件:fold 状态仍有两条');
422
+ const kinds = new Set(ledger.readEvents(undefined, 100).map(e => e.kind));
423
+ for (const k of kinds) {
424
+ assert.ok(!/expir|sweep|ttl/i.test(k), `账本不得出现过期/清扫类事件:${k}`);
425
+ }
426
+ });
427
+ console.log(`\nSESSION ZONE LEDGER TESTS: ${n}/18 OK(P4-2 账本落点:六 kind 落既有表/单事务守门 fail-closed/双道幂等/kill -9 全有或全无/forget 一行审计/导出视图==fold;隔离 stateRoot,全离线)`);
428
+ }
429
+ finally {
430
+ for (const root of roots)
431
+ rmSync(root, { recursive: true, force: true });
432
+ }