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.
- package/README.md +80 -80
- package/cordis.patch.yml +5 -0
- package/lib/client.d.ts +20 -2
- package/lib/client.d.ts.map +1 -1
- package/lib/client.js +6724 -7923
- package/lib/guard-core.js +72 -1
- package/lib/guard-core.js.map +1 -1
- package/lib/guard-loop-core.js +36 -5
- package/lib/guard-loop-core.js.map +1 -1
- package/lib/guard.js +20 -4
- package/lib/guard.js.map +1 -1
- package/lib/host-face.js +1 -0
- package/lib/host-face.js.map +1 -1
- package/lib/index.js +11 -1
- package/lib/index.js.map +1 -1
- package/lib/policy-state.js +29 -1
- package/lib/policy-state.js.map +1 -1
- package/lib/policy.js +24 -2
- package/lib/policy.js.map +1 -1
- package/lib/server/call-index-remote.js +110 -0
- package/lib/server/call-index-remote.js.map +1 -0
- package/lib/server/remote.js +13 -0
- package/lib/server/remote.js.map +1 -1
- package/lib/types/guard-core.d.ts +69 -1
- package/lib/types/guard-core.d.ts.map +1 -1
- package/lib/types/guard-loop-core.d.ts +15 -0
- package/lib/types/guard-loop-core.d.ts.map +1 -1
- package/lib/types/guard.d.ts +7 -1
- package/lib/types/guard.d.ts.map +1 -1
- package/lib/types/host-face.d.ts.map +1 -1
- package/lib/types/index.d.ts +5 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/policy-state.d.ts +6 -0
- package/lib/types/policy-state.d.ts.map +1 -1
- package/lib/types/policy.d.ts +22 -3
- package/lib/types/policy.d.ts.map +1 -1
- package/lib/types/server/call-index-remote.d.ts +17 -0
- package/lib/types/server/call-index-remote.d.ts.map +1 -0
- package/lib/types/server/remote.d.ts +9 -0
- package/lib/types/server/remote.d.ts.map +1 -1
- package/lib/types/usage/call-index-core.d.ts +152 -0
- package/lib/types/usage/call-index-core.d.ts.map +1 -0
- package/lib/types/usage/call-index-host-face.d.ts +44 -0
- package/lib/types/usage/call-index-host-face.d.ts.map +1 -0
- package/lib/types/usage/call-index-host.d.ts +7 -0
- package/lib/types/usage/call-index-host.d.ts.map +1 -0
- package/lib/types/usage/call-index-query.d.ts +97 -0
- package/lib/types/usage/call-index-query.d.ts.map +1 -0
- package/lib/types/usage/call-index-runtime.d.ts +46 -0
- package/lib/types/usage/call-index-runtime.d.ts.map +1 -0
- package/lib/types/usage/call-index-scope.d.ts +59 -0
- package/lib/types/usage/call-index-scope.d.ts.map +1 -0
- package/lib/types/usage/call-index-wire.d.ts +286 -0
- package/lib/types/usage/call-index-wire.d.ts.map +1 -0
- package/lib/types/usage/session-index.d.ts +149 -0
- package/lib/types/usage/session-index.d.ts.map +1 -0
- package/lib/usage/call-index-core.js +761 -0
- package/lib/usage/call-index-core.js.map +1 -0
- package/lib/usage/call-index-host-face.js +77 -0
- package/lib/usage/call-index-host-face.js.map +1 -0
- package/lib/usage/call-index-host.js +13 -0
- package/lib/usage/call-index-host.js.map +1 -0
- package/lib/usage/call-index-query.js +669 -0
- package/lib/usage/call-index-query.js.map +1 -0
- package/lib/usage/call-index-runtime.js +376 -0
- package/lib/usage/call-index-runtime.js.map +1 -0
- package/lib/usage/call-index-scope.js +79 -0
- package/lib/usage/call-index-scope.js.map +1 -0
- package/lib/usage/call-index-wire.js +490 -0
- package/lib/usage/call-index-wire.js.map +1 -0
- package/lib/usage/session-index.js +600 -0
- package/lib/usage/session-index.js.map +1 -0
- 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
|