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,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavioural query layer for the N22 call index (D-026, N23 host/Remote).
|
|
3
|
+
*
|
|
4
|
+
* `SessionCallIndex` (session-index.ts) is the scanner: it owns enumeration,
|
|
5
|
+
* live observation and freshness. This file is the read-model in front of it:
|
|
6
|
+
* it captures ONE immutable projection per query snapshot, serves summary and
|
|
7
|
+
* stable pagination from that frozen copy, and only re-reads `stamp()` (cheap)
|
|
8
|
+
* on later pages. It never re-reads the whole snapshot mid-pagination.
|
|
9
|
+
*
|
|
10
|
+
* Trust model: a `snapshot()` row is Host data, not a wire object. Every field
|
|
11
|
+
* is copied individually through an allowlist (never spread), so raw tool
|
|
12
|
+
* arguments, result content and error text cannot leak into a reply. `detail`
|
|
13
|
+
* additionally re-checks `isSessionVisible` and the authorization stamp after
|
|
14
|
+
* the async lookups, so a stale or escalated id cannot be read.
|
|
15
|
+
*
|
|
16
|
+
* Pagination uses opaque random UUID cursors bound to (view, kind, filter);
|
|
17
|
+
* there is no client-supplied offset or base64 "signature". Repeating a cursor
|
|
18
|
+
* yields the same page. A cursor for the wrong kind or a filter mismatch is
|
|
19
|
+
* rejected rather than silently jumping.
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-thinking-guard/usage/call-index-query
|
|
22
|
+
*/
|
|
23
|
+
import { Buffer } from 'node:buffer';
|
|
24
|
+
import { randomUUID } from 'node:crypto';
|
|
25
|
+
import { CALL_INDEX_DEFAULT_LIMIT, CALL_INDEX_DEFAULT_MAX_BYTES, CALL_INDEX_DEFAULT_MAX_VIEWS, CALL_INDEX_DEFAULT_TTL_MS, CALL_INDEX_MAX_DAY_BUCKETS, CALL_INDEX_MAX_REPLY_BYTES, CALL_INDEX_OUTCOMES, CALL_INDEX_SOURCE_KINDS, callIndexDetailRequestSchema, callIndexError, callIndexListRequestSchema, callIndexSummaryRequestSchema, callIndexWireRowSchema, callIndexWireSessionSchema, callIndexReply, } from "./call-index-wire.js";
|
|
26
|
+
const OUTCOME_SET = new Set(CALL_INDEX_OUTCOMES);
|
|
27
|
+
const SOURCE_SET = new Set(CALL_INDEX_SOURCE_KINDS);
|
|
28
|
+
function isRecord(value) {
|
|
29
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
/** Read one field without letting a hostile getter throw into the query layer. */
|
|
32
|
+
function read(value, key) {
|
|
33
|
+
if (typeof value !== 'object' || value === null)
|
|
34
|
+
return undefined;
|
|
35
|
+
try {
|
|
36
|
+
return value[key];
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const isInt = (value) => typeof value === 'number' && Number.isSafeInteger(value);
|
|
43
|
+
const isNonNegInt = (value) => isInt(value) && value >= 0;
|
|
44
|
+
const isNum = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
45
|
+
const isStr = (value) => typeof value === 'string';
|
|
46
|
+
function requestObject(value) {
|
|
47
|
+
return value === undefined ? {} : value;
|
|
48
|
+
}
|
|
49
|
+
function projectRow(raw) {
|
|
50
|
+
const sessionId = read(raw, 'sessionId');
|
|
51
|
+
const callSeq = read(raw, 'callSeq');
|
|
52
|
+
const callId = read(raw, 'callId');
|
|
53
|
+
const toolName = read(raw, 'toolName');
|
|
54
|
+
const startedAt = read(raw, 'startedAt');
|
|
55
|
+
const outcome = read(raw, 'outcome');
|
|
56
|
+
const inherited = read(raw, 'inherited');
|
|
57
|
+
const sourceKind = read(raw, 'sourceKind');
|
|
58
|
+
const layer = read(raw, 'layer');
|
|
59
|
+
if (!isStr(sessionId) || !isNonNegInt(callSeq) || !isStr(callId) || !isStr(toolName)
|
|
60
|
+
|| !isNum(startedAt) || !isStr(outcome) || !OUTCOME_SET.has(outcome)
|
|
61
|
+
|| typeof inherited !== 'boolean' || !isStr(sourceKind) || !SOURCE_SET.has(sourceKind) || !isStr(layer))
|
|
62
|
+
return undefined;
|
|
63
|
+
const row = {
|
|
64
|
+
sessionId, callSeq, callId, toolName, startedAt,
|
|
65
|
+
outcome: outcome, inherited, sourceKind: sourceKind, layer,
|
|
66
|
+
};
|
|
67
|
+
const turn = read(raw, 'turn');
|
|
68
|
+
if (isInt(turn))
|
|
69
|
+
row.turn = turn;
|
|
70
|
+
const step = read(raw, 'step');
|
|
71
|
+
if (isInt(step))
|
|
72
|
+
row.step = step;
|
|
73
|
+
const endedAt = read(raw, 'endedAt');
|
|
74
|
+
if (isNum(endedAt))
|
|
75
|
+
row.endedAt = endedAt;
|
|
76
|
+
const resultSeq = read(raw, 'resultSeq');
|
|
77
|
+
if (isInt(resultSeq))
|
|
78
|
+
row.resultSeq = resultSeq;
|
|
79
|
+
const durationMs = read(raw, 'durationMs');
|
|
80
|
+
if (isNum(durationMs))
|
|
81
|
+
row.durationMs = durationMs;
|
|
82
|
+
const parsed = callIndexWireRowSchema.safeParse(row);
|
|
83
|
+
return parsed.success ? parsed.data : undefined;
|
|
84
|
+
}
|
|
85
|
+
function projectSession(raw) {
|
|
86
|
+
const sessionId = read(raw, 'sessionId');
|
|
87
|
+
const revision = read(raw, 'revision');
|
|
88
|
+
const cut = read(raw, 'cut');
|
|
89
|
+
const coverage = read(raw, 'coverage');
|
|
90
|
+
const stale = read(raw, 'stale');
|
|
91
|
+
const reasons = read(raw, 'reasons');
|
|
92
|
+
if (!isStr(sessionId) || !isStr(revision) || !isNonNegInt(cut)
|
|
93
|
+
|| (coverage !== 'complete' && coverage !== 'partial') || typeof stale !== 'boolean'
|
|
94
|
+
|| !Array.isArray(reasons) || !reasons.every(isStr))
|
|
95
|
+
return undefined;
|
|
96
|
+
const meta = { sessionId, revision, cut, coverage, stale, reasons: reasons };
|
|
97
|
+
const cwd = read(raw, 'cwd');
|
|
98
|
+
if (isStr(cwd))
|
|
99
|
+
meta.cwd = cwd;
|
|
100
|
+
const parsed = callIndexWireSessionSchema.safeParse(meta);
|
|
101
|
+
return parsed.success ? parsed.data : undefined;
|
|
102
|
+
}
|
|
103
|
+
function summarize(rows) {
|
|
104
|
+
const summary = { total: 0, success: 0, error: 0, cancelled: 0, unresolved: 0, unknown: 0, running: 0, inherited: 0 };
|
|
105
|
+
for (const row of rows) {
|
|
106
|
+
summary.total += 1;
|
|
107
|
+
summary[row.outcome] += 1;
|
|
108
|
+
if (row.inherited)
|
|
109
|
+
summary.inherited += 1;
|
|
110
|
+
}
|
|
111
|
+
return summary;
|
|
112
|
+
}
|
|
113
|
+
/** `YYYY-MM-DD` for an instant, shifted by the caller's local-day offset. */
|
|
114
|
+
function localDayLabel(epochMs, offsetMinutes) {
|
|
115
|
+
const shifted = epochMs + offsetMinutes * 60_000;
|
|
116
|
+
const date = new Date(shifted);
|
|
117
|
+
const time = date.getTime();
|
|
118
|
+
if (!Number.isFinite(time))
|
|
119
|
+
return undefined;
|
|
120
|
+
const year = date.getUTCFullYear();
|
|
121
|
+
const month = date.getUTCMonth() + 1;
|
|
122
|
+
const day = date.getUTCDate();
|
|
123
|
+
const pad = (value) => (value < 10 ? `0${value}` : `${value}`);
|
|
124
|
+
return `${year}-${pad(month)}-${pad(day)}`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Overview aggregates over the filtered set, computed from the SAME frozen
|
|
128
|
+
* snapshot and filter as `summary`, so the two can never disagree.
|
|
129
|
+
*
|
|
130
|
+
* `bySource` always emits all five keys. `unknown` is a real answer here — the
|
|
131
|
+
* Host cannot prove a plugin owner (R-009) — so a caller must render "not
|
|
132
|
+
* provable", not "unused". Returns `undefined` when the requested span would
|
|
133
|
+
* exceed {@link CALL_INDEX_MAX_DAY_BUCKETS}, which the caller answers with
|
|
134
|
+
* `reply-too-large` rather than a silently truncated trend.
|
|
135
|
+
*/
|
|
136
|
+
function aggregateRows(rows, dayOffsetMinutes) {
|
|
137
|
+
const bySource = { mcp: 0, 'plugin-tool': 0, native: 0, skill: 0, unknown: 0 };
|
|
138
|
+
const tools = new Set();
|
|
139
|
+
const days = new Map();
|
|
140
|
+
for (const row of rows) {
|
|
141
|
+
bySource[row.sourceKind] += 1;
|
|
142
|
+
tools.add(row.toolName);
|
|
143
|
+
const day = localDayLabel(row.startedAt, dayOffsetMinutes);
|
|
144
|
+
if (day === undefined)
|
|
145
|
+
continue;
|
|
146
|
+
let bucket = days.get(day);
|
|
147
|
+
if (bucket === undefined) {
|
|
148
|
+
if (days.size >= CALL_INDEX_MAX_DAY_BUCKETS)
|
|
149
|
+
return undefined;
|
|
150
|
+
bucket = { day, calls: 0, success: 0, error: 0, cancelled: 0, unresolved: 0, unknown: 0, running: 0 };
|
|
151
|
+
days.set(day, bucket);
|
|
152
|
+
}
|
|
153
|
+
bucket.calls += 1;
|
|
154
|
+
bucket[row.outcome] += 1;
|
|
155
|
+
}
|
|
156
|
+
const aggregates = { distinctTools: tools.size, bySource, dayOffsetMinutes };
|
|
157
|
+
if (days.size > 0)
|
|
158
|
+
aggregates.byDay = [...days.values()].sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
|
159
|
+
return aggregates;
|
|
160
|
+
}
|
|
161
|
+
function projectSnapshot(raw) {
|
|
162
|
+
if (!isRecord(raw))
|
|
163
|
+
return { kind: 'invalid' };
|
|
164
|
+
const scope = read(raw, 'scope');
|
|
165
|
+
const status = read(scope, 'status');
|
|
166
|
+
if (status === 'disabled')
|
|
167
|
+
return { kind: 'scope-disabled' };
|
|
168
|
+
if (status === 'unavailable')
|
|
169
|
+
return { kind: 'persistence-unavailable' };
|
|
170
|
+
if (status !== 'ready')
|
|
171
|
+
return { kind: 'invalid' };
|
|
172
|
+
const generation = read(raw, 'generation');
|
|
173
|
+
if (!isNonNegInt(generation))
|
|
174
|
+
return { kind: 'invalid' };
|
|
175
|
+
const coverageRaw = read(raw, 'coverage');
|
|
176
|
+
const coverage = coverageRaw === 'complete' || coverageRaw === 'partial' || coverageRaw === 'unknown' ? coverageRaw : 'unknown';
|
|
177
|
+
const sessionsRaw = read(raw, 'sessions');
|
|
178
|
+
const rowsRaw = read(raw, 'rows');
|
|
179
|
+
const sessions = [];
|
|
180
|
+
if (!Array.isArray(sessionsRaw) || !Array.isArray(rowsRaw))
|
|
181
|
+
return { kind: 'invalid' };
|
|
182
|
+
if (Array.isArray(sessionsRaw)) {
|
|
183
|
+
for (const entry of sessionsRaw) {
|
|
184
|
+
const meta = projectSession(entry);
|
|
185
|
+
if (!meta)
|
|
186
|
+
return { kind: 'invalid' };
|
|
187
|
+
sessions.push(meta);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const rows = [];
|
|
191
|
+
if (Array.isArray(rowsRaw)) {
|
|
192
|
+
for (const entry of rowsRaw) {
|
|
193
|
+
const row = projectRow(entry);
|
|
194
|
+
if (!row)
|
|
195
|
+
return { kind: 'invalid' };
|
|
196
|
+
rows.push(row);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const cwdBySession = new Map();
|
|
200
|
+
for (const session of sessions)
|
|
201
|
+
cwdBySession.set(session.sessionId, session.cwd);
|
|
202
|
+
const semantic = read(raw, 'semanticCoverage');
|
|
203
|
+
const semanticCoverage = { source: read(semantic, 'source') === 'verified' ? 'verified' : 'unknown', ptc: read(semantic, 'ptc') === 'verified' ? 'verified' : 'unknown', skillExecution: read(semantic, 'skillExecution') === 'verified' ? 'verified' : 'unavailable', denied: read(semantic, 'denied') === 'verified' ? 'verified' : 'unavailable' };
|
|
204
|
+
const data = { snapshotId: randomUUID(), generation, coverage, sessions, rows, totals: summarize(rows), semanticCoverage, cwdBySession };
|
|
205
|
+
const asOf = read(raw, 'asOf');
|
|
206
|
+
if (isNum(asOf))
|
|
207
|
+
data.asOf = asOf;
|
|
208
|
+
return { kind: 'ok', data };
|
|
209
|
+
}
|
|
210
|
+
function normalizeQuery(value) {
|
|
211
|
+
const record = isRecord(value) ? value : {};
|
|
212
|
+
const query = { includeInherited: record.includeInherited === true };
|
|
213
|
+
if (isStr(record.sessionId))
|
|
214
|
+
query.sessionId = record.sessionId;
|
|
215
|
+
if (isStr(record.toolName) && record.toolName.length > 0)
|
|
216
|
+
query.toolName = record.toolName.toLowerCase();
|
|
217
|
+
if (isStr(record.cwd))
|
|
218
|
+
query.cwd = record.cwd;
|
|
219
|
+
if (isStr(record.outcome))
|
|
220
|
+
query.outcome = record.outcome;
|
|
221
|
+
if (isStr(record.sourceKind))
|
|
222
|
+
query.sourceKind = record.sourceKind;
|
|
223
|
+
if (isNum(record.fromMs))
|
|
224
|
+
query.fromMs = record.fromMs;
|
|
225
|
+
if (isNum(record.toMs))
|
|
226
|
+
query.toMs = record.toMs;
|
|
227
|
+
return query;
|
|
228
|
+
}
|
|
229
|
+
function filterKey(query) {
|
|
230
|
+
return JSON.stringify({
|
|
231
|
+
sessionId: query.sessionId ?? null,
|
|
232
|
+
toolName: query.toolName ?? null,
|
|
233
|
+
cwd: query.cwd ?? null,
|
|
234
|
+
outcome: query.outcome ?? null,
|
|
235
|
+
sourceKind: query.sourceKind ?? null,
|
|
236
|
+
fromMs: query.fromMs ?? null,
|
|
237
|
+
toMs: query.toMs ?? null,
|
|
238
|
+
includeInherited: query.includeInherited,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function matches(row, query, view) {
|
|
242
|
+
if (!query.includeInherited && row.inherited)
|
|
243
|
+
return false;
|
|
244
|
+
if (query.sessionId !== undefined && row.sessionId !== query.sessionId)
|
|
245
|
+
return false;
|
|
246
|
+
if (query.toolName !== undefined && query.toolName.length > 0
|
|
247
|
+
&& !row.toolName.toLowerCase().includes(query.toolName.toLowerCase()))
|
|
248
|
+
return false;
|
|
249
|
+
if (query.outcome !== undefined && row.outcome !== query.outcome)
|
|
250
|
+
return false;
|
|
251
|
+
if (query.sourceKind !== undefined && row.sourceKind !== query.sourceKind)
|
|
252
|
+
return false;
|
|
253
|
+
if (query.cwd !== undefined && view.cwdBySession.get(row.sessionId) !== query.cwd)
|
|
254
|
+
return false;
|
|
255
|
+
if (query.fromMs !== undefined && row.startedAt < query.fromMs)
|
|
256
|
+
return false;
|
|
257
|
+
if (query.toMs !== undefined && row.startedAt >= query.toMs)
|
|
258
|
+
return false;
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
/** Stable desc-by-start sort, then sessionId, then callSeq. */
|
|
262
|
+
function sortRows(rows) {
|
|
263
|
+
return [...rows].sort((a, b) => b.startedAt - a.startedAt
|
|
264
|
+
|| (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0)
|
|
265
|
+
|| a.callSeq - b.callSeq);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Immutable, bounded query views behind the `callIndex` namespace.
|
|
269
|
+
*
|
|
270
|
+
* One instance is owned by the Host runtime. Views are frozen captures of a
|
|
271
|
+
* single `snapshot()`; cursors point at offsets inside one view.
|
|
272
|
+
*/
|
|
273
|
+
export class CallIndexQueries {
|
|
274
|
+
source;
|
|
275
|
+
now;
|
|
276
|
+
ttlMs;
|
|
277
|
+
maxViews;
|
|
278
|
+
maxBytes;
|
|
279
|
+
maxReplyBytes;
|
|
280
|
+
views = new Map();
|
|
281
|
+
cursors = new Map();
|
|
282
|
+
cursorKeys = new Map();
|
|
283
|
+
totalBytes = 0;
|
|
284
|
+
constructor(source, options = {}) {
|
|
285
|
+
this.source = source;
|
|
286
|
+
this.now = options.now ?? Date.now;
|
|
287
|
+
this.ttlMs = options.ttlMs ?? CALL_INDEX_DEFAULT_TTL_MS;
|
|
288
|
+
this.maxViews = options.maxViews ?? CALL_INDEX_DEFAULT_MAX_VIEWS;
|
|
289
|
+
this.maxBytes = options.maxBytes ?? CALL_INDEX_DEFAULT_MAX_BYTES;
|
|
290
|
+
this.maxReplyBytes = Math.min(options.maxReplyBytes ?? CALL_INDEX_MAX_REPLY_BYTES, CALL_INDEX_MAX_REPLY_BYTES);
|
|
291
|
+
for (const value of [this.ttlMs, this.maxViews, this.maxBytes, this.maxReplyBytes])
|
|
292
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
293
|
+
throw Error('invalid call-index query limit');
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Wire envelope shared by every page.
|
|
297
|
+
*
|
|
298
|
+
* `totalExact` means "this number is the whole truth for the filter". The two
|
|
299
|
+
* kinds of provenance filter are NOT symmetric, and the asymmetry decides the
|
|
300
|
+
* flag:
|
|
301
|
+
*
|
|
302
|
+
* - `sourceKind: 'mcp' | 'native' | 'plugin-tool' | 'skill'` — the predicate
|
|
303
|
+
* means "the real source IS mcp", but the stored label only means "the
|
|
304
|
+
* catalog resolved it to mcp". A call from a since-removed plugin is
|
|
305
|
+
* labelled `unknown` although it really was MCP, so this count is a LOWER
|
|
306
|
+
* BOUND and must not claim exact.
|
|
307
|
+
* - `sourceKind: 'unknown'` — the predicate is the label itself. Every row
|
|
308
|
+
* carries exactly one label (`buildRow` always writes it, and
|
|
309
|
+
* `projectRow` drops a row with an invalid one), so "rows matching this
|
|
310
|
+
* filter" equals "rows labelled unknown" by definition: nothing can be
|
|
311
|
+
* missed and nothing counted twice. That exactness is definitional, not
|
|
312
|
+
* evidential, which is why it survives.
|
|
313
|
+
*
|
|
314
|
+
* What `unknown` must NOT be read as is "calls whose real source is unknown":
|
|
315
|
+
* it also holds calls whose source is known but whose tool left the catalog.
|
|
316
|
+
* It is a superset of the truly-unresolved, so the UI has to say
|
|
317
|
+
* "provenance unresolved", never "未知来源的真实总数".
|
|
318
|
+
*/
|
|
319
|
+
meta(view, filter) {
|
|
320
|
+
const provenanceFilterExact = filter.sourceKind === undefined || filter.sourceKind === 'unknown';
|
|
321
|
+
return { corpus: 'persisted-plus-observed-live', snapshotId: view.snapshotId, generation: view.generation, coverage: view.coverage,
|
|
322
|
+
totalExact: view.coverage === 'complete' && provenanceFilterExact,
|
|
323
|
+
semanticCoverage: { ...view.semanticCoverage } };
|
|
324
|
+
}
|
|
325
|
+
bounded(reply) {
|
|
326
|
+
try {
|
|
327
|
+
const projected = callIndexReply(reply);
|
|
328
|
+
return Buffer.byteLength(JSON.stringify(projected), 'utf8') <= this.maxReplyBytes ? projected : callIndexError('reply-too-large');
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return callIndexError('internal-error');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/** Drop every cached view and cursor (explicit rescan / disable / dispose). */
|
|
335
|
+
invalidate() {
|
|
336
|
+
this.views.clear();
|
|
337
|
+
this.cursors.clear();
|
|
338
|
+
this.cursorKeys.clear();
|
|
339
|
+
this.totalBytes = 0;
|
|
340
|
+
}
|
|
341
|
+
estimateBytes(data) {
|
|
342
|
+
return Buffer.byteLength(JSON.stringify({ generation: data.generation, coverage: data.coverage, sessions: data.sessions, rows: data.rows }), 'utf8');
|
|
343
|
+
}
|
|
344
|
+
dropView(id) {
|
|
345
|
+
const view = this.views.get(id);
|
|
346
|
+
if (view === undefined)
|
|
347
|
+
return;
|
|
348
|
+
this.views.delete(id);
|
|
349
|
+
this.totalBytes = Math.max(0, this.totalBytes - view.bytes);
|
|
350
|
+
for (const [cursorId, cursor] of this.cursors) {
|
|
351
|
+
if (cursor.viewId !== id)
|
|
352
|
+
continue;
|
|
353
|
+
this.cursors.delete(cursorId);
|
|
354
|
+
this.cursorKeys.delete(this.cursorKey(id, cursor.kind, cursor.filterKey, cursor.offset));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
evictExpired() {
|
|
358
|
+
const now = this.now();
|
|
359
|
+
for (const [id, view] of this.views)
|
|
360
|
+
if (now - view.capturedAt > this.ttlMs)
|
|
361
|
+
this.dropView(id);
|
|
362
|
+
}
|
|
363
|
+
evictOldest() {
|
|
364
|
+
let oldest;
|
|
365
|
+
for (const view of this.views.values())
|
|
366
|
+
if (oldest === undefined || view.capturedAt < oldest.capturedAt)
|
|
367
|
+
oldest = view;
|
|
368
|
+
if (oldest !== undefined)
|
|
369
|
+
this.dropView(oldest.snapshotId);
|
|
370
|
+
}
|
|
371
|
+
capture(filter) {
|
|
372
|
+
let projection;
|
|
373
|
+
let before;
|
|
374
|
+
try {
|
|
375
|
+
before = this.source.stamp();
|
|
376
|
+
projection = projectSnapshot(this.source.snapshot());
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return { kind: 'error', reason: 'internal-error' };
|
|
380
|
+
}
|
|
381
|
+
if (projection.kind === 'scope-disabled')
|
|
382
|
+
return { kind: 'error', reason: 'scope-disabled' };
|
|
383
|
+
if (projection.kind === 'persistence-unavailable')
|
|
384
|
+
return { kind: 'error', reason: 'persistence-unavailable' };
|
|
385
|
+
if (projection.kind === 'invalid')
|
|
386
|
+
return { kind: 'error', reason: 'internal-error' };
|
|
387
|
+
const bytes = this.estimateBytes(projection.data) + 2 * Buffer.byteLength(JSON.stringify(filter), 'utf8') + 256;
|
|
388
|
+
if (bytes > this.maxBytes)
|
|
389
|
+
return { kind: 'error', reason: 'query-capacity' };
|
|
390
|
+
let stamp;
|
|
391
|
+
try {
|
|
392
|
+
stamp = this.source.stamp();
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return { kind: 'error', reason: 'internal-error' };
|
|
396
|
+
}
|
|
397
|
+
if (before !== stamp)
|
|
398
|
+
return { kind: 'error', reason: 'cursor-stale' };
|
|
399
|
+
this.evictExpired();
|
|
400
|
+
while ((this.views.size >= this.maxViews || this.totalBytes + bytes > this.maxBytes) && this.views.size > 0)
|
|
401
|
+
this.evictOldest();
|
|
402
|
+
const view = { ...projection.data, stamp, filter, filterKey: filterKey(filter), capturedAt: this.now(), bytes };
|
|
403
|
+
this.views.set(view.snapshotId, view);
|
|
404
|
+
this.totalBytes += bytes;
|
|
405
|
+
return { kind: 'ok', view };
|
|
406
|
+
}
|
|
407
|
+
resolveView(snapshotId, filter) {
|
|
408
|
+
this.evictExpired();
|
|
409
|
+
if (snapshotId === undefined)
|
|
410
|
+
return this.capture(filter ?? normalizeQuery(undefined));
|
|
411
|
+
const view = this.views.get(snapshotId);
|
|
412
|
+
if (view === undefined)
|
|
413
|
+
return { kind: 'error', reason: 'cursor-stale' };
|
|
414
|
+
if (filter !== undefined && filterKey(filter) !== view.filterKey)
|
|
415
|
+
return { kind: 'error', reason: 'invalid-request' };
|
|
416
|
+
let stamp;
|
|
417
|
+
try {
|
|
418
|
+
stamp = this.source.stamp();
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return { kind: 'error', reason: 'internal-error' };
|
|
422
|
+
}
|
|
423
|
+
if (stamp !== view.stamp) {
|
|
424
|
+
this.dropView(snapshotId);
|
|
425
|
+
return { kind: 'error', reason: 'cursor-stale' };
|
|
426
|
+
}
|
|
427
|
+
return { kind: 'ok', view };
|
|
428
|
+
}
|
|
429
|
+
hasUpdates(view) {
|
|
430
|
+
if (this.source.generation === undefined)
|
|
431
|
+
return false;
|
|
432
|
+
try {
|
|
433
|
+
const generation = this.source.generation();
|
|
434
|
+
return isInt(generation) && generation !== view.generation;
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
cursorKey(viewId, kind, _key, offset) {
|
|
441
|
+
// The filter is already bound to the view; avoid copying a long filter per cursor.
|
|
442
|
+
return `${viewId}\u0000${kind}\u0000${offset}`;
|
|
443
|
+
}
|
|
444
|
+
/** Reuse the cursor id for an identical (view, kind, filter, offset) position. */
|
|
445
|
+
newCursor(view, kind, filter, offset) {
|
|
446
|
+
const key = this.cursorKey(view.snapshotId, kind, filterKey(filter), offset);
|
|
447
|
+
const existing = this.cursorKeys.get(key);
|
|
448
|
+
if (existing !== undefined && this.cursors.has(existing))
|
|
449
|
+
return existing;
|
|
450
|
+
while (this.cursors.size >= 256) {
|
|
451
|
+
const oldest = this.cursors.keys().next().value;
|
|
452
|
+
if (oldest === undefined)
|
|
453
|
+
break;
|
|
454
|
+
const entry = this.cursors.get(oldest);
|
|
455
|
+
this.cursors.delete(oldest);
|
|
456
|
+
this.cursorKeys.delete(this.cursorKey(entry.viewId, entry.kind, entry.filterKey, entry.offset));
|
|
457
|
+
}
|
|
458
|
+
const id = randomUUID();
|
|
459
|
+
this.cursors.set(id, { viewId: view.snapshotId, kind, filter, filterKey: view.filterKey, offset });
|
|
460
|
+
this.cursorKeys.set(key, id);
|
|
461
|
+
return id;
|
|
462
|
+
}
|
|
463
|
+
fitCount(count, encodeBytes) {
|
|
464
|
+
if (count <= 0)
|
|
465
|
+
return 0;
|
|
466
|
+
if (encodeBytes(count) <= this.maxReplyBytes)
|
|
467
|
+
return count;
|
|
468
|
+
let low = 1;
|
|
469
|
+
let high = count;
|
|
470
|
+
let best = 0;
|
|
471
|
+
while (low <= high) {
|
|
472
|
+
const mid = (low + high) >> 1;
|
|
473
|
+
if (encodeBytes(mid) <= this.maxReplyBytes) {
|
|
474
|
+
best = mid;
|
|
475
|
+
low = mid + 1;
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
high = mid - 1;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return best;
|
|
482
|
+
}
|
|
483
|
+
listContext(request, kind) {
|
|
484
|
+
this.evictExpired();
|
|
485
|
+
let cursorEntry;
|
|
486
|
+
if (request.cursor !== undefined) {
|
|
487
|
+
cursorEntry = this.cursors.get(request.cursor);
|
|
488
|
+
if (cursorEntry === undefined || cursorEntry.kind !== kind)
|
|
489
|
+
return { ok: false, reply: callIndexError('cursor-stale') };
|
|
490
|
+
if (request.snapshotId !== undefined && request.snapshotId !== cursorEntry.viewId) {
|
|
491
|
+
return { ok: false, reply: callIndexError('invalid-request') };
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const resolved = this.resolveView(request.snapshotId ?? cursorEntry?.viewId, request.query === undefined ? undefined : normalizeQuery(request.query));
|
|
495
|
+
if (resolved.kind === 'error')
|
|
496
|
+
return { ok: false, reply: callIndexError(resolved.reason) };
|
|
497
|
+
const view = resolved.view;
|
|
498
|
+
if (cursorEntry !== undefined && cursorEntry.viewId !== view.snapshotId) {
|
|
499
|
+
return { ok: false, reply: callIndexError('cursor-stale') };
|
|
500
|
+
}
|
|
501
|
+
if (cursorEntry !== undefined) {
|
|
502
|
+
if (request.query !== undefined && filterKey(normalizeQuery(request.query)) !== cursorEntry.filterKey) {
|
|
503
|
+
return { ok: false, reply: callIndexError('invalid-request') };
|
|
504
|
+
}
|
|
505
|
+
return { ok: true, view, filter: cursorEntry.filter, offset: cursorEntry.offset };
|
|
506
|
+
}
|
|
507
|
+
return { ok: true, view, filter: view.filter, offset: 0 };
|
|
508
|
+
}
|
|
509
|
+
sessionsFor(view, filter) {
|
|
510
|
+
const callLevel = filter.toolName !== undefined || filter.outcome !== undefined
|
|
511
|
+
|| filter.sourceKind !== undefined || filter.fromMs !== undefined || filter.toMs !== undefined;
|
|
512
|
+
const out = [];
|
|
513
|
+
for (const session of view.sessions) {
|
|
514
|
+
if (filter.sessionId !== undefined && session.sessionId !== filter.sessionId)
|
|
515
|
+
continue;
|
|
516
|
+
if (filter.cwd !== undefined && session.cwd !== filter.cwd)
|
|
517
|
+
continue;
|
|
518
|
+
if (callLevel && !view.rows.some(row => row.sessionId === session.sessionId && matches(row, filter, view)))
|
|
519
|
+
continue;
|
|
520
|
+
out.push(session);
|
|
521
|
+
}
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
/** Summarise the whole filtered set (not the current page). */
|
|
525
|
+
getSummary(request) {
|
|
526
|
+
const parsed = callIndexSummaryRequestSchema.safeParse(requestObject(request));
|
|
527
|
+
if (!parsed.success)
|
|
528
|
+
return callIndexError('invalid-request');
|
|
529
|
+
const resolved = this.resolveView(parsed.data.snapshotId, parsed.data.query === undefined ? undefined : normalizeQuery(parsed.data.query));
|
|
530
|
+
if (resolved.kind === 'error')
|
|
531
|
+
return callIndexError(resolved.reason);
|
|
532
|
+
const view = resolved.view;
|
|
533
|
+
const filter = view.filter;
|
|
534
|
+
const filtered = view.rows.filter(row => matches(row, filter, view));
|
|
535
|
+
const dayOffsetMinutes = parsed.data.dayOffsetMinutes ?? 0;
|
|
536
|
+
const aggregates = aggregateRows(filtered, dayOffsetMinutes);
|
|
537
|
+
if (aggregates === undefined)
|
|
538
|
+
return callIndexError('reply-too-large');
|
|
539
|
+
const data = {
|
|
540
|
+
...this.meta(view, filter),
|
|
541
|
+
summary: summarize(filtered),
|
|
542
|
+
total: filtered.length,
|
|
543
|
+
hasUpdates: this.hasUpdates(view),
|
|
544
|
+
aggregates,
|
|
545
|
+
};
|
|
546
|
+
if (view.asOf !== undefined)
|
|
547
|
+
data.asOf = view.asOf;
|
|
548
|
+
return this.bounded({ schemaVersion: 1, ok: true, kind: 'summary', data });
|
|
549
|
+
}
|
|
550
|
+
/** One page of Session metadata; zero-call Sessions are included when unfiltered. */
|
|
551
|
+
listSessions(request) {
|
|
552
|
+
const parsed = callIndexListRequestSchema.safeParse(requestObject(request));
|
|
553
|
+
if (!parsed.success)
|
|
554
|
+
return callIndexError('invalid-request');
|
|
555
|
+
const context = this.listContext(parsed.data, 'sessions');
|
|
556
|
+
if (!context.ok)
|
|
557
|
+
return context.reply;
|
|
558
|
+
const { view, filter, offset } = context;
|
|
559
|
+
const ordered = this.sessionsFor(view, filter).sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
|
|
560
|
+
if (offset > ordered.length)
|
|
561
|
+
return callIndexError('cursor-stale');
|
|
562
|
+
const limit = parsed.data.limit ?? CALL_INDEX_DEFAULT_LIMIT;
|
|
563
|
+
const summary = summarize(view.rows.filter(row => matches(row, filter, view)));
|
|
564
|
+
const placeholder = 'x'.repeat(36);
|
|
565
|
+
const make = (count, nextCursor) => {
|
|
566
|
+
const data = {
|
|
567
|
+
...this.meta(view, filter),
|
|
568
|
+
summary,
|
|
569
|
+
total: ordered.length,
|
|
570
|
+
items: ordered.slice(offset, offset + count),
|
|
571
|
+
hasUpdates: this.hasUpdates(view),
|
|
572
|
+
};
|
|
573
|
+
if (view.asOf !== undefined)
|
|
574
|
+
data.asOf = view.asOf;
|
|
575
|
+
if (nextCursor !== undefined)
|
|
576
|
+
data.nextCursor = nextCursor;
|
|
577
|
+
return { schemaVersion: 1, ok: true, kind: 'sessions', data };
|
|
578
|
+
};
|
|
579
|
+
const room = Math.min(limit, ordered.length - offset);
|
|
580
|
+
const count = this.fitCount(room, (n) => Buffer.byteLength(JSON.stringify(make(n, offset + n < ordered.length ? placeholder : undefined)), 'utf8'));
|
|
581
|
+
if (room > 0 && count === 0)
|
|
582
|
+
return callIndexError('reply-too-large');
|
|
583
|
+
const nextCursor = offset + count < ordered.length ? this.newCursor(view, 'sessions', filter, offset + count) : undefined;
|
|
584
|
+
return this.bounded(make(count, nextCursor));
|
|
585
|
+
}
|
|
586
|
+
/** One page of call rows in stable `(startedAt DESC, sessionId, callSeq)` order. */
|
|
587
|
+
listCalls(request) {
|
|
588
|
+
const parsed = callIndexListRequestSchema.safeParse(requestObject(request));
|
|
589
|
+
if (!parsed.success)
|
|
590
|
+
return callIndexError('invalid-request');
|
|
591
|
+
const context = this.listContext(parsed.data, 'calls');
|
|
592
|
+
if (!context.ok)
|
|
593
|
+
return context.reply;
|
|
594
|
+
const { view, filter, offset } = context;
|
|
595
|
+
const filtered = sortRows(view.rows.filter(row => matches(row, filter, view)));
|
|
596
|
+
if (offset > filtered.length)
|
|
597
|
+
return callIndexError('cursor-stale');
|
|
598
|
+
const limit = parsed.data.limit ?? CALL_INDEX_DEFAULT_LIMIT;
|
|
599
|
+
const summary = summarize(filtered);
|
|
600
|
+
const placeholder = 'x'.repeat(36);
|
|
601
|
+
const make = (count, nextCursor) => {
|
|
602
|
+
const data = {
|
|
603
|
+
...this.meta(view, filter),
|
|
604
|
+
summary,
|
|
605
|
+
total: filtered.length,
|
|
606
|
+
items: filtered.slice(offset, offset + count),
|
|
607
|
+
hasUpdates: this.hasUpdates(view),
|
|
608
|
+
};
|
|
609
|
+
if (view.asOf !== undefined)
|
|
610
|
+
data.asOf = view.asOf;
|
|
611
|
+
if (nextCursor !== undefined)
|
|
612
|
+
data.nextCursor = nextCursor;
|
|
613
|
+
return { schemaVersion: 1, ok: true, kind: 'calls', data };
|
|
614
|
+
};
|
|
615
|
+
const room = Math.min(limit, filtered.length - offset);
|
|
616
|
+
const count = this.fitCount(room, (n) => Buffer.byteLength(JSON.stringify(make(n, offset + n < filtered.length ? placeholder : undefined)), 'utf8'));
|
|
617
|
+
if (room > 0 && count === 0)
|
|
618
|
+
return callIndexError('reply-too-large');
|
|
619
|
+
const nextCursor = offset + count < filtered.length ? this.newCursor(view, 'calls', filter, offset + count) : undefined;
|
|
620
|
+
return this.bounded(make(count, nextCursor));
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Safely read one call's metadata. The row must be in the frozen view, then
|
|
624
|
+
* the Session must still be visible, then the authorization stamp must still
|
|
625
|
+
* match — a detail read never trusts a browser-supplied id by itself.
|
|
626
|
+
*/
|
|
627
|
+
async getCallDetail(request) {
|
|
628
|
+
const parsed = callIndexDetailRequestSchema.safeParse(requestObject(request));
|
|
629
|
+
if (!parsed.success)
|
|
630
|
+
return callIndexError('invalid-request');
|
|
631
|
+
const resolved = this.resolveView(parsed.data.snapshotId);
|
|
632
|
+
if (resolved.kind === 'error')
|
|
633
|
+
return callIndexError(resolved.reason);
|
|
634
|
+
const view = resolved.view;
|
|
635
|
+
const row = view.rows.find(item => item.sessionId === parsed.data.sessionId && item.callSeq === parsed.data.callSeq && matches(item, view.filter, view));
|
|
636
|
+
if (row === undefined)
|
|
637
|
+
return callIndexError('not-visible');
|
|
638
|
+
let visible = false;
|
|
639
|
+
try {
|
|
640
|
+
visible = await this.source.isSessionVisible(parsed.data.sessionId);
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
visible = false;
|
|
644
|
+
}
|
|
645
|
+
if (!visible)
|
|
646
|
+
return callIndexError('not-visible');
|
|
647
|
+
let stamp;
|
|
648
|
+
try {
|
|
649
|
+
stamp = this.source.stamp();
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
return callIndexError('internal-error');
|
|
653
|
+
}
|
|
654
|
+
if (stamp !== view.stamp || this.views.get(view.snapshotId) !== view)
|
|
655
|
+
return callIndexError('cursor-stale');
|
|
656
|
+
if (this.now() - view.capturedAt > this.ttlMs) {
|
|
657
|
+
this.dropView(view.snapshotId);
|
|
658
|
+
return callIndexError('cursor-stale');
|
|
659
|
+
}
|
|
660
|
+
const data = {
|
|
661
|
+
...this.meta(view, view.filter),
|
|
662
|
+
detail: row,
|
|
663
|
+
};
|
|
664
|
+
if (view.asOf !== undefined)
|
|
665
|
+
data.asOf = view.asOf;
|
|
666
|
+
return this.bounded({ schemaVersion: 1, ok: true, kind: 'detail', data });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
//# sourceMappingURL=call-index-query.js.map
|