dsh-thinking-guard 0.1.19 → 0.1.21

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 (73) hide show
  1. package/README.md +80 -80
  2. package/cordis.patch.yml +5 -0
  3. package/lib/client.d.ts +20 -2
  4. package/lib/client.d.ts.map +1 -1
  5. package/lib/client.js +6724 -7923
  6. package/lib/guard-core.js +72 -1
  7. package/lib/guard-core.js.map +1 -1
  8. package/lib/guard-loop-core.js +36 -5
  9. package/lib/guard-loop-core.js.map +1 -1
  10. package/lib/guard.js +20 -4
  11. package/lib/guard.js.map +1 -1
  12. package/lib/host-face.js +1 -0
  13. package/lib/host-face.js.map +1 -1
  14. package/lib/index.js +11 -1
  15. package/lib/index.js.map +1 -1
  16. package/lib/policy-state.js +29 -1
  17. package/lib/policy-state.js.map +1 -1
  18. package/lib/policy.js +24 -2
  19. package/lib/policy.js.map +1 -1
  20. package/lib/server/call-index-remote.js +110 -0
  21. package/lib/server/call-index-remote.js.map +1 -0
  22. package/lib/server/remote.js +13 -0
  23. package/lib/server/remote.js.map +1 -1
  24. package/lib/types/guard-core.d.ts +69 -1
  25. package/lib/types/guard-core.d.ts.map +1 -1
  26. package/lib/types/guard-loop-core.d.ts +15 -0
  27. package/lib/types/guard-loop-core.d.ts.map +1 -1
  28. package/lib/types/guard.d.ts +7 -1
  29. package/lib/types/guard.d.ts.map +1 -1
  30. package/lib/types/host-face.d.ts.map +1 -1
  31. package/lib/types/index.d.ts +5 -0
  32. package/lib/types/index.d.ts.map +1 -1
  33. package/lib/types/policy-state.d.ts +6 -0
  34. package/lib/types/policy-state.d.ts.map +1 -1
  35. package/lib/types/policy.d.ts +22 -3
  36. package/lib/types/policy.d.ts.map +1 -1
  37. package/lib/types/server/call-index-remote.d.ts +17 -0
  38. package/lib/types/server/call-index-remote.d.ts.map +1 -0
  39. package/lib/types/server/remote.d.ts +9 -0
  40. package/lib/types/server/remote.d.ts.map +1 -1
  41. package/lib/types/usage/call-index-core.d.ts +152 -0
  42. package/lib/types/usage/call-index-core.d.ts.map +1 -0
  43. package/lib/types/usage/call-index-host-face.d.ts +44 -0
  44. package/lib/types/usage/call-index-host-face.d.ts.map +1 -0
  45. package/lib/types/usage/call-index-host.d.ts +7 -0
  46. package/lib/types/usage/call-index-host.d.ts.map +1 -0
  47. package/lib/types/usage/call-index-query.d.ts +97 -0
  48. package/lib/types/usage/call-index-query.d.ts.map +1 -0
  49. package/lib/types/usage/call-index-runtime.d.ts +46 -0
  50. package/lib/types/usage/call-index-runtime.d.ts.map +1 -0
  51. package/lib/types/usage/call-index-scope.d.ts +59 -0
  52. package/lib/types/usage/call-index-scope.d.ts.map +1 -0
  53. package/lib/types/usage/call-index-wire.d.ts +286 -0
  54. package/lib/types/usage/call-index-wire.d.ts.map +1 -0
  55. package/lib/types/usage/session-index.d.ts +149 -0
  56. package/lib/types/usage/session-index.d.ts.map +1 -0
  57. package/lib/usage/call-index-core.js +761 -0
  58. package/lib/usage/call-index-core.js.map +1 -0
  59. package/lib/usage/call-index-host-face.js +77 -0
  60. package/lib/usage/call-index-host-face.js.map +1 -0
  61. package/lib/usage/call-index-host.js +13 -0
  62. package/lib/usage/call-index-host.js.map +1 -0
  63. package/lib/usage/call-index-query.js +669 -0
  64. package/lib/usage/call-index-query.js.map +1 -0
  65. package/lib/usage/call-index-runtime.js +376 -0
  66. package/lib/usage/call-index-runtime.js.map +1 -0
  67. package/lib/usage/call-index-scope.js +79 -0
  68. package/lib/usage/call-index-scope.js.map +1 -0
  69. package/lib/usage/call-index-wire.js +490 -0
  70. package/lib/usage/call-index-wire.js.map +1 -0
  71. package/lib/usage/session-index.js +600 -0
  72. package/lib/usage/session-index.js.map +1 -0
  73. package/package.json +144 -144
@@ -0,0 +1,600 @@
1
+ /** New, unmounted call-index scanner. Public read handles only; no disk state. */
2
+ import { SessionId } from '@deepseek-ai/dsh-session';
3
+ import { CallIndexPartition, projectCallIndexEvent } from "./call-index-core.js";
4
+ import { CallIndexScopeController, describeCallIndexScope } from "./call-index-scope.js";
5
+ const integer = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
6
+ const text = (value) => typeof value === 'string' && value.length > 0 && value.length <= 512;
7
+ const positive = (value, fallback) => {
8
+ if (value === undefined)
9
+ return fallback;
10
+ if (!integer(value) || value < 1)
11
+ throw Error('invalid call-index limit');
12
+ return value;
13
+ };
14
+ class ScopeChanged extends Error {
15
+ }
16
+ /**
17
+ * Map a thrown read failure to a bounded reason code.
18
+ *
19
+ * Reads the error's `name`/`code` ONLY. Messages are never inspected: a backend
20
+ * message may quote file content, and this value travels to the browser.
21
+ *
22
+ * Why bother: `session-read-failed` alone made "the corpus is empty" and "every
23
+ * read threw" indistinguishable in the UI, so an operator could not tell a
24
+ * compressed-artifact problem from a permissions problem from a real absence.
25
+ */
26
+ function readFailureReason(error) {
27
+ const name = typeof error === 'object' && error !== null && typeof error.name === 'string'
28
+ ? error.name
29
+ : '';
30
+ const code = typeof error === 'object' && error !== null && typeof error.code === 'string'
31
+ ? error.code
32
+ : '';
33
+ const tag = `${name} ${code}`.toLowerCase();
34
+ if (tag.includes('notfound') || tag.includes('not_found') || tag.includes('enoent'))
35
+ return 'session-missing';
36
+ if (tag.includes('zstd') || tag.includes('decompress') || tag.includes('decode'))
37
+ return 'session-decode-failed';
38
+ if (tag.includes('migration') || tag.includes('version') || tag.includes('format'))
39
+ return 'session-format-mismatch';
40
+ if (tag.includes('perm') || tag.includes('eacces') || tag.includes('eperm'))
41
+ return 'session-unreadable';
42
+ return 'session-read-failed';
43
+ }
44
+ /**
45
+ * In-memory engine for N22. It is deliberately not mounted in the old Usage runtime.
46
+ * Scope is freshly resolved before AND after asynchronous service operations.
47
+ * Memory limits bound estimated retained index metadata, not backend decoding/RSS.
48
+ */
49
+ export class SessionCallIndex {
50
+ options;
51
+ scopes = new CallIndexScopeController();
52
+ lastScope = { status: 'disabled', mode: 'disabled', reason: 'not-enabled' };
53
+ token;
54
+ ledger = { used: 0 };
55
+ published = new Map();
56
+ stages = new Map();
57
+ live = new Map();
58
+ observedThrough = new Map();
59
+ visible = new Set();
60
+ pending = new Set();
61
+ failures = new Set();
62
+ reasons = new Set();
63
+ task;
64
+ aborter;
65
+ fairCursor = 0;
66
+ generation = 0;
67
+ censusGeneration = 0;
68
+ asOf;
69
+ enumerated = false;
70
+ enumerationCurrent = false;
71
+ listedCount = 0;
72
+ phase = 'idle';
73
+ liveCount = 0;
74
+ disposed = false;
75
+ limits;
76
+ constructor(options) {
77
+ this.options = options;
78
+ this.limits = {
79
+ // 128 was slow on a real corpus: 9802 events needed ~77 round trips and
80
+ // the index took long enough that the operator called it 「太慢」. Each
81
+ // read is a bounded page from a local file, so a larger page is strictly
82
+ // cheaper per event; 512 keeps one page well inside the byte budget.
83
+ pageSize: positive(options.pageSize, 512), maxBytes: positive(options.maxBytes, 64 * 1024 * 1024),
84
+ maxCalls: positive(options.maxCalls, 100_000), maxSessions: positive(options.maxSessions, 5_000),
85
+ maxEvents: positive(options.maxEventsPerSession, 1_000_000), maxOrphans: positive(options.maxOrphans, 2_048),
86
+ maxLive: positive(options.maxLiveQueue, 1_024),
87
+ };
88
+ }
89
+ gap(reason) { if (this.reasons.size < 32)
90
+ this.reasons.add(reason); }
91
+ syncScope() {
92
+ let scope;
93
+ try {
94
+ const mode = this.disposed ? 'disabled' : this.options.getMode();
95
+ scope = this.scopes.resolve(mode, mode === 'host-visible' ? this.options.getBackend() : undefined);
96
+ }
97
+ catch {
98
+ scope = this.scopes.resolve('host-visible');
99
+ }
100
+ this.lastScope = scope;
101
+ const token = scope.status === 'ready' ? scope.token : undefined;
102
+ if (this.token !== token) {
103
+ this.token = token;
104
+ // Replace the ledger object: old in-flight cleanup cannot subtract new-epoch data.
105
+ this.ledger = { used: 0 };
106
+ this.published.clear();
107
+ this.stages.clear();
108
+ this.live.clear();
109
+ this.observedThrough.clear();
110
+ this.visible.clear();
111
+ this.pending.clear();
112
+ this.failures.clear();
113
+ this.reasons.clear();
114
+ this.liveCount = 0;
115
+ this.enumerated = false;
116
+ this.enumerationCurrent = false;
117
+ this.listedCount = 0;
118
+ this.asOf = undefined;
119
+ this.generation += 1;
120
+ }
121
+ return scope.status === 'ready' ? scope : undefined;
122
+ }
123
+ guard(scope, signal) {
124
+ const current = this.syncScope();
125
+ if (!current || current.token !== scope.token || !this.scopes.isCurrent(scope))
126
+ throw new ScopeChanged();
127
+ signal?.throwIfAborted();
128
+ }
129
+ reserve(ledger, bytes) {
130
+ if (ledger !== this.ledger || bytes < 0 || ledger.used + bytes > this.limits.maxBytes) {
131
+ this.gap('index-capacity');
132
+ return false;
133
+ }
134
+ ledger.used += bytes;
135
+ return true;
136
+ }
137
+ discard(stage) {
138
+ stage.ledger.used = Math.max(0, stage.ledger.used - stage.part.usage().bytes);
139
+ if (this.stages.get(stage.target.id) === stage)
140
+ this.stages.delete(stage.target.id);
141
+ }
142
+ /** Immediate projection keeps raw events out of the bounded live queue. */
143
+ observeLive(sessionId, raw, inheritedEventCount) {
144
+ if (!this.syncScope())
145
+ return false;
146
+ if (!text(sessionId) || !integer(inheritedEventCount)) {
147
+ this.gap('lineage-unverified');
148
+ return false;
149
+ }
150
+ const projection = projectCallIndexEvent(raw);
151
+ for (const reason of projection.reasons)
152
+ this.gap(reason);
153
+ if (!projection.event)
154
+ return false;
155
+ if (!this.observedThrough.has(sessionId) && this.observedThrough.size >= this.limits.maxSessions) {
156
+ this.gap('session-capacity');
157
+ return false;
158
+ }
159
+ this.observedThrough.set(sessionId, Math.max(this.observedThrough.get(sessionId) ?? 0, projection.event.seq + 1));
160
+ const bytes = 2 * Buffer.byteLength(JSON.stringify(projection.event), 'utf8') + 1024;
161
+ if (this.liveCount >= this.limits.maxLive || !this.reserve(this.ledger, bytes)) {
162
+ this.gap('live-queue-overflow');
163
+ return false;
164
+ }
165
+ const queue = this.live.get(sessionId) ?? [];
166
+ queue.push({ event: projection.event, bytes, inherited: inheritedEventCount });
167
+ this.live.set(sessionId, queue);
168
+ this.liveCount += 1;
169
+ return true;
170
+ }
171
+ /** One active task, including concurrent callers. Cancellation is not a second task. */
172
+ start(options = {}) {
173
+ if (this.task)
174
+ return this.task;
175
+ const maxEvents = positive(options.maxEvents, 400_000);
176
+ this.aborter = new AbortController();
177
+ const signal = options.signal ? AbortSignal.any([options.signal, this.aborter.signal]) : this.aborter.signal;
178
+ const task = this.run(maxEvents, signal, options.full === true).finally(() => {
179
+ if (this.task === task) {
180
+ this.task = undefined;
181
+ this.aborter = undefined;
182
+ }
183
+ });
184
+ this.task = task;
185
+ return task;
186
+ }
187
+ /** Host observation failures are fixed codes, never exception messages or raw payloads. */
188
+ markGap(reason) {
189
+ if (this.syncScope()) {
190
+ this.gap(reason);
191
+ this.generation += 1;
192
+ }
193
+ }
194
+ /** Publish observed metadata only; never enumerate/open historical storage here. */
195
+ flushLive() {
196
+ const scope = this.syncScope();
197
+ if (!scope || this.task)
198
+ return this.snapshot(false);
199
+ let changed = false;
200
+ for (const [id, queue] of this.live) {
201
+ this.guard(scope);
202
+ const old = this.published.get(id);
203
+ const stage = old
204
+ ? { target: old.target, part: old.part, next: old.value.cut, ledger: old.ledger, inherited: old.inherited }
205
+ : this.stage({ id, revision: 'observed-live', seeded: queue[0].inherited > 0 }, queue[0].inherited);
206
+ this.visible.add(id);
207
+ this.pending.add(id);
208
+ this.mergeLive(stage);
209
+ this.publish(stage);
210
+ // Data is current through an observed cut; corpus enumeration still needs reconciliation.
211
+ this.pending.add(id);
212
+ changed = true;
213
+ }
214
+ if (changed) {
215
+ this.enumerationCurrent = false;
216
+ this.generation += 1;
217
+ }
218
+ return this.snapshot(false);
219
+ }
220
+ /** Cheap authorization/data stamp; resolving it also notices live backend replacement. */
221
+ stamp() {
222
+ this.syncScope();
223
+ return { ...(this.lastScope.status === 'ready' ? { epoch: this.lastScope.epoch } : {}), generation: this.generation };
224
+ }
225
+ cancel() { this.aborter?.abort(); }
226
+ dispose() { this.disposed = true; this.cancel(); this.scopes.invalidate(); this.token = undefined; this.published.clear(); this.stages.clear(); this.live.clear(); this.observedThrough.clear(); this.visible.clear(); this.pending.clear(); this.failures.clear(); this.reasons.clear(); this.ledger = { used: 0 }; this.liveCount = 0; this.enumerated = false; this.enumerationCurrent = false; this.listedCount = 0; this.asOf = undefined; this.phase = 'idle'; }
227
+ stage(target, inherited) {
228
+ const prior = this.stages.get(target.id);
229
+ if (prior && prior.target.revision === target.revision && prior.inherited === inherited)
230
+ return prior;
231
+ if (prior)
232
+ this.discard(prior);
233
+ const ledger = this.ledger;
234
+ const part = new CallIndexPartition({
235
+ sessionId: target.id, revision: target.revision, inheritedEventCount: inherited,
236
+ maxEvents: this.limits.maxEvents, maxCalls: this.limits.maxCalls,
237
+ maxBytes: this.limits.maxBytes, maxOrphans: this.limits.maxOrphans,
238
+ reserve: bytes => this.reserve(ledger, bytes),
239
+ ...(this.options.resolveSource === undefined ? {} : { resolveSource: this.options.resolveSource }),
240
+ });
241
+ const stage = { target, part, next: 0, ledger, inherited };
242
+ this.stages.set(target.id, stage);
243
+ return stage;
244
+ }
245
+ mergeLive(stage) {
246
+ const queue = this.live.get(stage.target.id);
247
+ if (!queue)
248
+ return;
249
+ this.live.delete(stage.target.id);
250
+ this.liveCount -= queue.length;
251
+ // Math.max(0, NaN) is NaN, not 0 — so a single non-finite operand would
252
+ // silently poison the ledger for the rest of the generation.
253
+ const released = queue.reduce((n, item) => n + (Number.isFinite(item.bytes) ? item.bytes : 0), 0);
254
+ const before = Number.isFinite(stage.ledger.used) ? stage.ledger.used : 0;
255
+ stage.ledger.used = Math.max(0, before - released);
256
+ queue.sort((a, b) => a.event.seq - b.event.seq);
257
+ for (const item of queue) {
258
+ stage.part.ingest([item.event]);
259
+ stage.next = Math.max(stage.next, item.event.seq + 1);
260
+ }
261
+ }
262
+ publish(stage) {
263
+ if (stage.inherited > stage.next) {
264
+ stage.part.addGap('lineage-unverified');
265
+ this.gap('lineage-unverified');
266
+ }
267
+ const snapshot = stage.part.snapshot(stage.next);
268
+ if (snapshot.reasons.includes('index-capacity'))
269
+ this.gap('index-capacity');
270
+ // Surface the backend's under-report at index level too: a per-session gap
271
+ // would otherwise stay buried in that session's row and the page-level
272
+ // "扫描提示" line — the one place an operator actually looks — would omit it.
273
+ if (snapshot.reasons.includes('event-count-underreported'))
274
+ this.gap('event-count-underreported');
275
+ const old = this.published.get(stage.target.id);
276
+ const totalCalls = [...this.published.values()].reduce((n, entry) => n + entry.value.rows.length, 0);
277
+ if (totalCalls - (old?.value.rows.length ?? 0) + snapshot.rows.length > this.limits.maxCalls) {
278
+ this.gap('index-capacity');
279
+ return;
280
+ }
281
+ // On corrupt/incomplete rebuild retain the last readable partition instead of mixing versions.
282
+ if (snapshot.coverage === 'partial' && old) {
283
+ this.gap('session-partial');
284
+ if (old.part === stage.part)
285
+ old.bytes = stage.part.usage().bytes;
286
+ else
287
+ this.discard(stage);
288
+ // The watermark must SURVIVE: it records "live events were seen past this
289
+ // cut", and dropping it would claim history had caught up when it had not
290
+ // (see the live-tail test). What must not survive is the PENDING mark —
291
+ // this Session was read in this pass, so leaving it pending made the scan
292
+ // re-queue it forever and report 「待处理 65 个」 permanently. It becomes
293
+ // pending again only when a NEW live event arrives (observeLive).
294
+ this.pending.delete(stage.target.id);
295
+ return;
296
+ }
297
+ if (old && old.part !== stage.part)
298
+ old.ledger.used = Math.max(0, old.ledger.used - old.bytes);
299
+ this.published.set(stage.target.id, { value: snapshot, part: stage.part, target: stage.target, inherited: stage.inherited, ledger: stage.ledger, bytes: stage.part.usage().bytes, ...(stage.target.cwd === undefined ? {} : { cwd: stage.target.cwd }) });
300
+ this.stages.delete(stage.target.id);
301
+ if (snapshot.coverage === 'complete' && snapshot.cut >= (this.observedThrough.get(stage.target.id) ?? 0))
302
+ this.observedThrough.delete(stage.target.id);
303
+ if (this.observedThrough.has(stage.target.id))
304
+ this.pending.add(stage.target.id);
305
+ else
306
+ this.pending.delete(stage.target.id);
307
+ this.generation += 1;
308
+ }
309
+ async run(maxEvents, signal, full) {
310
+ const scope = this.syncScope();
311
+ if (!scope)
312
+ return this.snapshot();
313
+ this.phase = 'running';
314
+ let readCount = 0;
315
+ try {
316
+ this.guard(scope, signal);
317
+ this.enumerationCurrent = false;
318
+ const listed = await scope.backend.list({ signal });
319
+ this.guard(scope, signal);
320
+ for (const reason of ['scan-pending', 'scan-cancelled', 'session-list-or-stat-failed', 'session-read-failed', 'handle-close-failed', 'revision-changed', 'session-disappeared', 'session-partial', 'observed-live-only', 'live-membership-unverified'])
321
+ this.reasons.delete(reason);
322
+ this.failures.clear();
323
+ this.visible.clear();
324
+ this.pending.clear();
325
+ this.enumerated = true;
326
+ this.enumerationCurrent = true;
327
+ this.censusGeneration += 1;
328
+ this.asOf = (this.options.now ?? Date.now)();
329
+ const targets = [];
330
+ const ids = new Set();
331
+ for (const row of listed) {
332
+ const id = row.header.id;
333
+ if (!text(id) || !text(row.revision) || typeof row.header.isSeeded !== 'boolean'
334
+ || (row.eventCount !== undefined && !integer(row.eventCount)) || ids.has(id)) {
335
+ this.gap('invalid-session-metadata');
336
+ continue;
337
+ }
338
+ ids.add(id);
339
+ if (targets.length >= this.limits.maxSessions) {
340
+ this.gap('session-capacity');
341
+ continue;
342
+ }
343
+ const target = { id, revision: row.revision, seeded: row.header.isSeeded, ...(typeof row.header.cwd === 'string' && row.header.cwd.length <= 4096 ? { cwd: row.header.cwd } : {}), ...(row.eventCount === undefined ? {} : { eventCount: row.eventCount }) };
344
+ targets.push(target);
345
+ this.visible.add(id);
346
+ const old = this.published.get(id);
347
+ if (full || !old || old.value.revision !== target.revision || old.value.coverage !== 'complete' || this.live.has(id) || this.observedThrough.has(id) || this.stages.has(id))
348
+ this.pending.add(id);
349
+ if (full) {
350
+ const stage = this.stages.get(id);
351
+ if (stage)
352
+ this.discard(stage);
353
+ }
354
+ }
355
+ // A verified observed live-only Session must not disappear just because it is not persisted.
356
+ for (const id of this.published.keys())
357
+ if (!this.visible.has(id) && this.options.isLiveSession) {
358
+ try {
359
+ if (this.options.isLiveSession(id)) {
360
+ this.visible.add(id);
361
+ this.pending.add(id);
362
+ this.gap('observed-live-only');
363
+ }
364
+ }
365
+ catch {
366
+ this.gap('live-membership-unverified');
367
+ }
368
+ }
369
+ // Stale cursors are service/Session/revision bound, never reused after a changed log observation.
370
+ for (const stage of this.stages.values())
371
+ if (!this.visible.has(stage.target.id))
372
+ this.discard(stage);
373
+ for (const [id, entry] of this.published)
374
+ if (!this.visible.has(id) && !this.live.has(id)) {
375
+ entry.ledger.used = Math.max(0, entry.ledger.used - entry.bytes);
376
+ this.published.delete(id); // Evict a derived cache entry; do not infer that user history was deleted.
377
+ }
378
+ this.listedCount = targets.length;
379
+ const rotated = targets.length ? [...targets.slice(this.fairCursor % targets.length), ...targets.slice(0, this.fairCursor % targets.length)] : [];
380
+ const queue = rotated.filter(target => this.pending.has(target.id));
381
+ while (queue.length && readCount < maxEvents) {
382
+ this.guard(scope, signal);
383
+ const target = queue.shift();
384
+ let stage = this.stages.get(target.id);
385
+ if (stage?.target.revision !== target.revision)
386
+ stage = undefined;
387
+ let handle;
388
+ let done = false;
389
+ let failed = false;
390
+ try {
391
+ handle = await scope.backend.open(SessionId(target.id), 'read', { signal });
392
+ this.guard(scope, signal);
393
+ const inherited = handle.inheritedEventCount;
394
+ if (handle.id !== target.id || handle.access !== 'read' || !integer(inherited)
395
+ || (!target.seeded && inherited !== 0) || (target.eventCount !== undefined && inherited > target.eventCount))
396
+ throw Error('invalid handle metadata');
397
+ stage = this.stage(target, inherited);
398
+ // `eventCount` is a HINT, never an EOF proof.
399
+ //
400
+ // The jsonl backend counts the header record of a multi-frame zstd
401
+ // artifact and reports `eventCount: 1` for a log holding thousands of
402
+ // events. Treating that as authoritative made the scan stop before the
403
+ // first read, so a full corpus rendered as "0 calls" while reporting a
404
+ // healthy, complete-looking run (D-030).
405
+ //
406
+ // Two consequences, both deliberate:
407
+ // - a declared count of 0 no longer short-circuits the session; we
408
+ // probe once and let an empty page prove emptiness;
409
+ // - `remaining` only CAPS the page size, it never decides `done`.
410
+ // EOF comes from an empty page (below), which is the backend's own
411
+ // statement that the logical tail was reached.
412
+ const declaredRemaining = target.eventCount === undefined ? undefined : target.eventCount - stage.next;
413
+ // We have already read MORE than the backend claims exists. Record it
414
+ // once (this is the multi-frame under-report) and keep reading: the
415
+ // declared count must never truncate the scan or stall the loop.
416
+ if (declaredRemaining !== undefined && declaredRemaining < 0) {
417
+ stage.part.addGap('event-count-underreported');
418
+ }
419
+ {
420
+ // A declared count only ever CAPS a page; it never caps the scan.
421
+ // A non-positive declaration (the multi-frame case reports 1, and a
422
+ // broken backend may report 0) carries no information, so it falls
423
+ // back to the normal page size instead of throttling the read to one
424
+ // event per round trip.
425
+ const cap = declaredRemaining !== undefined && declaredRemaining > 0
426
+ ? declaredRemaining
427
+ : this.limits.pageSize;
428
+ const length = Math.min(this.limits.pageSize, maxEvents - readCount, cap);
429
+ const page = await handle.read(stage.next, length, { signal });
430
+ this.guard(scope, signal);
431
+ if (page.events.length > length)
432
+ throw Error('oversized event page');
433
+ const projected = [];
434
+ let contiguous = true;
435
+ for (const [index, raw] of page.events.entries()) {
436
+ const value = projectCallIndexEvent(raw);
437
+ for (const reason of value.reasons)
438
+ stage.part.addGap(reason);
439
+ if (!value.event || value.event.seq !== stage.next + index)
440
+ contiguous = false;
441
+ if (value.event)
442
+ projected.push(value.event);
443
+ }
444
+ if (!contiguous)
445
+ stage.part.addGap('sequence-gap');
446
+ stage.part.ingest(projected);
447
+ stage.next += page.events.length;
448
+ readCount += page.events.length;
449
+ // Check the under-report BEFORE the empty-page branch: a backend
450
+ // that declares 1 event for a long log serves the whole log on the
451
+ // first probe and then an empty page, so the "we read past the
452
+ // declaration" fact must be recorded while we still know it.
453
+ if (declaredRemaining !== undefined && target.eventCount !== undefined && stage.next > target.eventCount) {
454
+ stage.part.addGap('event-count-underreported');
455
+ }
456
+ if (page.events.length === 0) {
457
+ // The only trustworthy EOF: the backend returned nothing for this
458
+ // offset. If a declared count said there was more, that count was
459
+ // wrong and the gap must be visible.
460
+ done = true;
461
+ if (declaredRemaining !== undefined && declaredRemaining > 0)
462
+ stage.part.addGap('short-page');
463
+ }
464
+ if (stage.next > this.limits.maxEvents) {
465
+ stage.part.addGap('index-capacity');
466
+ this.gap('index-capacity');
467
+ done = true;
468
+ }
469
+ }
470
+ }
471
+ catch (error) {
472
+ if (error instanceof ScopeChanged || signal.aborted)
473
+ throw error;
474
+ failed = true;
475
+ this.failures.add(target.id);
476
+ // The reason code used to be a flat `session-read-failed`, which told
477
+ // the operator nothing: a zstd decode failure, a missing artifact and
478
+ // a corrupt tail all looked identical. Classify by the error's own
479
+ // name/code (never its message — that can carry payload text) so the
480
+ // page can say WHICH stage broke. Unknown shapes stay the flat code.
481
+ this.gap(readFailureReason(error));
482
+ }
483
+ finally {
484
+ if (handle)
485
+ try {
486
+ await handle.close();
487
+ }
488
+ catch {
489
+ failed = true;
490
+ this.failures.add(target.id);
491
+ this.gap('handle-close-failed');
492
+ }
493
+ }
494
+ this.guard(scope, signal);
495
+ if (done && !failed && stage) {
496
+ const after = await scope.backend.stat(SessionId(target.id), { signal });
497
+ this.guard(scope, signal);
498
+ if (!after) {
499
+ this.failures.add(target.id);
500
+ this.gap('session-disappeared');
501
+ this.visible.delete(target.id);
502
+ this.discard(stage);
503
+ }
504
+ else {
505
+ if (after.revision !== target.revision) {
506
+ stage.part.addGap('revision-changed');
507
+ this.gap('revision-changed');
508
+ }
509
+ this.mergeLive(stage);
510
+ this.publish(stage);
511
+ }
512
+ }
513
+ else if (!failed)
514
+ queue.push(target);
515
+ this.fairCursor = targets.length ? (targets.findIndex(item => item.id === target.id) + 1) % targets.length : 0;
516
+ if (this.reasons.has('index-capacity'))
517
+ break;
518
+ // Yield between Sessions, not between every page: `setTimeout(0)` has a
519
+ // ~15ms floor on Windows, so yielding per page turned a fast local read
520
+ // into a visibly slow scan.
521
+ await (this.options.yield ?? (() => new Promise(resolve => setTimeout(resolve, 0))))();
522
+ }
523
+ // Live-only records remain explicitly partial, never a substitute for corpus enumeration.
524
+ for (const [id, queue] of this.live) {
525
+ this.guard(scope, signal);
526
+ if (this.pending.has(id))
527
+ continue;
528
+ this.visible.add(id);
529
+ const old = this.published.get(id);
530
+ if (old) {
531
+ this.pending.add(id);
532
+ continue;
533
+ }
534
+ const stage = this.stage({ id, revision: 'observed-live', seeded: queue[0].inherited > 0 }, queue[0].inherited);
535
+ // Rebuilds must obtain the history again; a tail-only buffer cannot attest old prefixes.
536
+ stage.part.addGap('observed-live');
537
+ this.mergeLive(stage);
538
+ this.publish(stage);
539
+ }
540
+ // `complete` is a terminal state, not a return to `idle`: the scan really
541
+ // ran and the index is current. Collapsing it into `idle` made a finished
542
+ // scan look identical to one that never started.
543
+ this.phase = 'complete';
544
+ if (this.pending.size)
545
+ this.gap('scan-pending');
546
+ }
547
+ catch (error) {
548
+ if (error instanceof ScopeChanged) {
549
+ this.syncScope();
550
+ this.phase = 'idle';
551
+ }
552
+ else if (signal.aborted) {
553
+ this.phase = 'cancelled';
554
+ this.gap('scan-cancelled');
555
+ }
556
+ else {
557
+ this.phase = 'failed';
558
+ this.gap('session-list-or-stat-failed');
559
+ }
560
+ }
561
+ return this.snapshot();
562
+ }
563
+ snapshot(includeRows = true) {
564
+ this.syncScope();
565
+ const entries = [...this.published].filter(([id]) => this.visible.has(id));
566
+ const sessions = entries.map(([id, entry]) => ({ sessionId: id, ...(entry.cwd === undefined ? {} : { cwd: entry.cwd }), revision: entry.value.revision, cut: entry.value.cut, coverage: entry.value.coverage, stale: this.pending.has(id) || this.failures.has(id), reasons: [...entry.value.reasons] }));
567
+ const retained = entries.flatMap(([, entry]) => entry.value.rows);
568
+ const rows = includeRows ? retained.map(row => ({ ...row })) : [];
569
+ const counted = retained.filter(row => !row.inherited);
570
+ const complete = this.lastScope.status === 'ready' && this.enumerated && this.enumerationCurrent && this.pending.size === 0 && this.failures.size === 0 && this.reasons.size === 0 && sessions.every(session => session.coverage === 'complete');
571
+ const coverage = complete ? 'complete' : this.enumerated || this.visible.size > 0 ? 'partial' : 'unknown';
572
+ // Every numeric field in this snapshot reaches the wire, where a single
573
+ // non-finite value (NaN / Infinity) makes the zod allowlist reject the WHOLE
574
+ // status reply as internal-error. That is not hypothetical: the ledger and
575
+ // the declared/read event sums are accumulated from per-entry arithmetic, and
576
+ // one missing operand poisons the sum with NaN. The page then shows
577
+ // 「尚未收到索引状态」 and disables the scan trigger — a total, silent loss of
578
+ // the entire status surface caused by one bad number. Coerce at the boundary
579
+ // so a bad counter degrades to 0 instead of erasing the page.
580
+ const finite = (value) => (value !== undefined && Number.isFinite(value) ? value : 0);
581
+ return {
582
+ corpus: 'persisted-plus-observed-live',
583
+ scope: describeCallIndexScope(this.lastScope), generation: this.generation, censusGeneration: this.censusGeneration,
584
+ asOf: this.asOf, phase: this.phase, coverage, eventCoverage: coverage,
585
+ enumerationCoverage: !this.enumerationCurrent ? 'unknown' : this.reasons.has('session-capacity') || this.reasons.has('invalid-session-metadata') ? 'partial' : 'complete',
586
+ semanticCoverage: { source: this.options.resolveSource === undefined ? 'unknown' : 'verified', ptc: 'unknown', skillExecution: 'unavailable', denied: 'unavailable' },
587
+ listedSessions: finite(this.listedCount), visibleSessions: finite(this.visible.size), completedSessions: sessions.filter(session => session.coverage === 'complete' && !session.stale).length,
588
+ pendingSessions: finite(this.pending.size), failedSessions: finite(this.failures.size), pendingLive: finite(this.liveCount),
589
+ // Diagnostic pair: what the backend CLAIMED per session vs what we actually
590
+ // read. Equal values mean the scan saw the whole log; a large gap means the
591
+ // backend under-reports (multi-frame artifacts) and the index is silently
592
+ // starving. Both are plain integers, no session content.
593
+ declaredEvents: finite(entries.reduce((sum, [, entry]) => sum + finite(entry.value.cut), 0)),
594
+ readEvents: finite(entries.reduce((sum, [, entry]) => sum + finite(entry.part.usage().events), 0)),
595
+ estimatedBytes: finite(this.ledger.used), reasons: [...this.reasons], sessions, rows,
596
+ totals: { calls: counted.length, succeeded: counted.filter(row => row.outcome === 'success').length, failed: counted.filter(row => row.outcome === 'error').length, unresolved: counted.filter(row => row.outcome === 'unresolved').length, unknown: counted.filter(row => row.outcome === 'unknown').length, cancelled: counted.filter(row => row.outcome === 'cancelled').length, running: counted.filter(row => row.outcome === 'running').length, inherited: retained.length - counted.length },
597
+ };
598
+ }
599
+ }
600
+ //# sourceMappingURL=session-index.js.map