threadwire 0.1.21 → 0.1.22

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.
@@ -0,0 +1,664 @@
1
+ // @ts-check
2
+
3
+ import {createHash} from "node:crypto"
4
+ import {lstatSync} from "node:fs"
5
+ import {chmod, lstat, rename, unlink} from "node:fs/promises"
6
+ import {DatabaseSync} from "node:sqlite"
7
+ import {redactText} from "./redaction.js"
8
+
9
+ const INDEX_VERSION = 1
10
+ const INDEX_SUFFIX = ".index"
11
+ const PENDING_INDEX_SUFFIX = ".index.pending"
12
+
13
+ const MAX_SUMMARY_LENGTH = 1_024
14
+ const MAX_METADATA_BYTES = 4_096
15
+ const MAX_PROVIDER_LENGTH = 64
16
+ const MAX_RUN_ID_LENGTH = 256
17
+ const MAX_DESTINATION_ID_LENGTH = 256
18
+ const MAX_ARTIFACT_ID_LENGTH = 64
19
+
20
+ const ALLOWED_EVENT_KINDS = new Set([
21
+ "prompt", "provider_stdout", "provider_stderr", "assistant", "tool",
22
+ "lifecycle", "diagnostic", "context_metrics", "provider_result"
23
+ ])
24
+
25
+ const ALLOWED_TERMINAL_STATES = new Set(["completed", "failed"])
26
+
27
+ // SQLite pages are typically 4 KiB; reserve a small allowance per event write
28
+ // and settle against the real file size so growth is bounded and accounted.
29
+ const INDEX_EVENT_ALLOWANCE_BYTES = 8 * 1024
30
+
31
+ /** @param {string} runId */
32
+ export function runHash(runId) {
33
+ return createHash("sha256").update(runId, "utf8").digest("base64url")
34
+ }
35
+
36
+ /** @param {string} name */
37
+ export function isReadyIndexName(name) {
38
+ return /^[A-Za-z0-9_-]{43}\.index$/u.test(name)
39
+ }
40
+
41
+ /** @param {string} name */
42
+ export function isPendingIndexName(name) {
43
+ return /^[A-Za-z0-9_-]{43}\.index\.pending$/u.test(name)
44
+ }
45
+
46
+ /** @param {string} name */
47
+ export function isIndexJournalName(name) {
48
+ return /^[A-Za-z0-9_-]{43}\.index-journal$/u.test(name)
49
+ || /^[A-Za-z0-9_-]{43}\.index\.pending-journal$/u.test(name)
50
+ }
51
+
52
+ /** @param {string} name @returns {string | undefined} */
53
+ export function readyIndexRunHash(name) {
54
+ const match = /^([A-Za-z0-9_-]{43})\.index$/u.exec(name)
55
+ return match?.[1]
56
+ }
57
+
58
+ export class EvidenceIndexError extends Error {
59
+ /** @param {string} message */
60
+ constructor(message) {
61
+ super(message)
62
+ this.name = "EvidenceIndexError"
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Per-run SQLite evidence index. Created in a pending state, finalized by
68
+ * rename. Uses DELETE journal mode so SQLite transient sidecars are removed
69
+ * on commit/close and can be cleaned during reconciliation if they survive
70
+ * a crash.
71
+ */
72
+ export class RunIndex {
73
+ /** @type {import("./evidence-store.js").EvidenceStore} */
74
+ #store
75
+ /** @type {object} */
76
+ #owner
77
+ /** @type {string | null} */
78
+ #provider
79
+ /** @type {string} */
80
+ #pendingPath
81
+ /** @type {string} */
82
+ #readyPath
83
+ /** @type {DatabaseSync | null} */
84
+ #db = null
85
+ /** @type {boolean} */
86
+ #finalized = false
87
+ /** @type {boolean} */
88
+ #closed = false
89
+ /** @type {number} */
90
+ #eventOrder = 0
91
+ /** @type {number} */
92
+ #createdAt
93
+ /** @type {number} */
94
+ #expiresAt
95
+ /** @type {string} */
96
+ #runId
97
+ /** @type {number} */
98
+ #reservedBytes
99
+ /** @type {string[] | undefined} */
100
+ #redactions
101
+ /** @type {{chmod: typeof chmod, stat: typeof lstat}} */
102
+ #fileSystem
103
+
104
+ /**
105
+ * @param {import("./evidence-store.js").EvidenceStore} store
106
+ * @param {object} owner
107
+ * @param {{provider?: string, createdAt?: number, expiresAt?: number, fileSystem?: {chmod: typeof chmod, stat: typeof lstat}}} [options]
108
+ * @param {number} [initialReservedBytes]
109
+ */
110
+ constructor(store, owner, options = {}, initialReservedBytes = 0) {
111
+ this.#store = store
112
+ this.#owner = owner
113
+ this.#provider = boundString(options.provider, MAX_PROVIDER_LENGTH) ?? null
114
+ this.#createdAt = options.createdAt ?? store.clock()
115
+ this.#expiresAt = options.expiresAt ?? this.#createdAt + store.limits.retentionMs
116
+ this.#runId = /** @type {{runId: string}} */ (owner).runId
117
+ this.#reservedBytes = initialReservedBytes
118
+ this.#fileSystem = options.fileSystem ?? {chmod, stat: lstat}
119
+ const hash = runHash(this.#runId)
120
+ this.#pendingPath = store.path(hash, PENDING_INDEX_SUFFIX)
121
+ this.#readyPath = store.path(hash, INDEX_SUFFIX)
122
+ }
123
+
124
+ get owner() {
125
+ return this.#owner
126
+ }
127
+
128
+ get provider() {
129
+ return this.#provider
130
+ }
131
+
132
+ set provider(value) {
133
+ if (this.#finalized || this.#closed) throw new EvidenceIndexError("Run index is already settled")
134
+ this.#provider = boundString(value, MAX_PROVIDER_LENGTH)
135
+ }
136
+
137
+ /** @param {string[]} redactions */
138
+ setRedactions(redactions) {
139
+ if (this.#finalized || this.#closed) throw new EvidenceIndexError("Run index is already settled")
140
+ if (!Array.isArray(redactions) || redactions.some((value) => typeof value !== "string")) {
141
+ throw new EvidenceIndexError("Invalid evidence redactions")
142
+ }
143
+ this.#redactions = redactions
144
+ }
145
+
146
+ get pendingPath() {
147
+ return this.#pendingPath
148
+ }
149
+
150
+ get readyPath() {
151
+ return this.#readyPath
152
+ }
153
+
154
+ get finalized() {
155
+ return this.#finalized
156
+ }
157
+
158
+ #accountSize() {
159
+ const stats = lstatSync(this.#pendingPath)
160
+ const delta = stats.size - this.#reservedBytes
161
+ if (delta !== 0) {
162
+ this.#store.reserve(this.#runId, delta, 0)
163
+ this.#reservedBytes += delta
164
+ }
165
+ }
166
+
167
+ /**
168
+ * @param {Record<string, unknown> | undefined} metadata
169
+ * @returns {Record<string, unknown> | undefined}
170
+ */
171
+ #redactMetadata(metadata) {
172
+ const redactions = this.#redactions
173
+ if (metadata === undefined || redactions === undefined || redactions.length === 0) return metadata
174
+ /** @type {(value: unknown) => unknown} */
175
+ const walk = (value) => {
176
+ if (typeof value === "string") return redactText(value, redactions).text
177
+ if (Array.isArray(value)) return value.map(walk)
178
+ if (value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
179
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, walk(entry)]))
180
+ }
181
+ return value
182
+ }
183
+ return /** @type {Record<string, unknown>} */ (walk(metadata))
184
+ }
185
+
186
+ /** @param {EvidenceIndexEvent} event */
187
+ async recordEvent(event) {
188
+ if (this.#finalized || this.#closed) throw new EvidenceIndexError("Run index is already settled")
189
+ const validated = validateEvent(event)
190
+ const timestamp = event.timestamp ?? this.#store.clock()
191
+ const artifactId = boundString(event.artifactId, MAX_ARTIFACT_ID_LENGTH)
192
+ const summary = boundString(
193
+ this.#redactions === undefined || validated.summary === undefined
194
+ ? validated.summary
195
+ : redactText(validated.summary, this.#redactions).text,
196
+ MAX_SUMMARY_LENGTH
197
+ )
198
+ const redactedMetadata = this.#redactMetadata(validated.metadata)
199
+ const metadata = redactedMetadata === undefined ? null : JSON.stringify(redactedMetadata)
200
+ if (metadata !== null && Buffer.byteLength(metadata, "utf8") > MAX_METADATA_BYTES) {
201
+ throw new EvidenceIndexError("Event metadata exceeds the bounded index limit")
202
+ }
203
+ this.#open()
204
+ this.#store.reserve(this.#runId, INDEX_EVENT_ALLOWANCE_BYTES, 0)
205
+ try {
206
+ const db = /** @type {import("node:sqlite").DatabaseSync} */ (this.#db)
207
+ const order = this.#eventOrder + 1
208
+ this.#eventOrder = order
209
+ const insert = db.prepare("INSERT INTO events (kind, timestamp, order_index, artifact_id, summary, metadata) VALUES (?, ?, ?, ?, ?, ?)")
210
+ insert.run(validated.kind, timestamp, order, artifactId, summary, metadata)
211
+ this.#accountSize()
212
+ } catch (error) {
213
+ this.#store.reserve(this.#runId, -INDEX_EVENT_ALLOWANCE_BYTES, 0)
214
+ await this.abort().catch(() => {})
215
+ throw error
216
+ }
217
+ this.#store.reserve(this.#runId, -INDEX_EVENT_ALLOWANCE_BYTES, 0)
218
+ }
219
+
220
+ /** @param {{state: "completed" | "failed", exitCode: number}} terminal */
221
+ async finalize(terminal) {
222
+ if (this.#finalized || this.#closed) throw new EvidenceIndexError("Run index is already settled")
223
+ if (!ALLOWED_TERMINAL_STATES.has(terminal.state) || !Number.isSafeInteger(terminal.exitCode) || terminal.exitCode < 0) {
224
+ throw new EvidenceIndexError("Invalid terminal run state")
225
+ }
226
+ this.#open()
227
+ this.#store.reserve(this.#runId, INDEX_EVENT_ALLOWANCE_BYTES, 0)
228
+ try {
229
+ const db = /** @type {import("node:sqlite").DatabaseSync} */ (this.#db)
230
+ const update = db.prepare("UPDATE manifest SET terminal_state = ?, terminal_exit_code = ?, finalized_at = ? WHERE version = ?")
231
+ update.run(terminal.state, terminal.exitCode, this.#store.clock(), INDEX_VERSION)
232
+ this.#accountSize()
233
+ db.close()
234
+ this.#closed = true
235
+ } catch (error) {
236
+ this.#store.reserve(this.#runId, -INDEX_EVENT_ALLOWANCE_BYTES, 0)
237
+ await this.abort().catch(() => {})
238
+ throw error
239
+ }
240
+ this.#store.reserve(this.#runId, -INDEX_EVENT_ALLOWANCE_BYTES, 0)
241
+ try {
242
+ await this.#verifyPermissions()
243
+ } catch (error) {
244
+ await this.abort().catch(() => {})
245
+ throw error
246
+ }
247
+ this.#finalized = true
248
+ await rename(this.#pendingPath, this.#readyPath)
249
+ }
250
+
251
+ async abort() {
252
+ if (this.#finalized) return
253
+ this.#closeDb()
254
+ if (this.#reservedBytes > 0) {
255
+ this.#store.reserve(this.#runId, -this.#reservedBytes, 0)
256
+ this.#reservedBytes = 0
257
+ }
258
+ await safeUnlink(this.#pendingPath)
259
+ }
260
+
261
+ releaseAccounting() {
262
+ if (this.#reservedBytes > 0) {
263
+ this.#store.reserve(this.#runId, -this.#reservedBytes, 0)
264
+ this.#reservedBytes = 0
265
+ }
266
+ }
267
+
268
+ async remove() {
269
+ this.releaseAccounting()
270
+ this.#closeDb()
271
+ await safeUnlink(this.#pendingPath)
272
+ await safeUnlink(this.#readyPath)
273
+ }
274
+
275
+ #open() {
276
+ if (this.#db !== null) return
277
+ this.#db = new DatabaseSync(this.#pendingPath)
278
+ this.#db.exec("PRAGMA journal_mode = DELETE")
279
+ this.#db.exec(`
280
+ CREATE TABLE IF NOT EXISTS manifest (
281
+ version INTEGER NOT NULL,
282
+ run_id TEXT NOT NULL,
283
+ destination_id TEXT NOT NULL,
284
+ provider TEXT,
285
+ terminal_state TEXT,
286
+ terminal_exit_code INTEGER,
287
+ created_at INTEGER NOT NULL,
288
+ expires_at INTEGER NOT NULL,
289
+ finalized_at INTEGER
290
+ )
291
+ `)
292
+ this.#db.exec(`
293
+ CREATE TABLE IF NOT EXISTS events (
294
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
295
+ kind TEXT NOT NULL,
296
+ timestamp INTEGER NOT NULL,
297
+ order_index INTEGER NOT NULL,
298
+ artifact_id TEXT,
299
+ summary TEXT,
300
+ metadata TEXT
301
+ )
302
+ `)
303
+ this.#db.exec("CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind)")
304
+ this.#db.exec("CREATE INDEX IF NOT EXISTS idx_events_order ON events(order_index)")
305
+ const count = /** @type {{count: number}} */ (
306
+ this.#db.prepare("SELECT COUNT(*) AS count FROM manifest").get()
307
+ )
308
+ if (count.count === 0) {
309
+ const insert = this.#db.prepare("INSERT INTO manifest (version, run_id, destination_id, provider, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)")
310
+ insert.run(
311
+ INDEX_VERSION,
312
+ boundString(/** @type {{runId: string}} */ (this.#owner).runId, MAX_RUN_ID_LENGTH),
313
+ boundString(/** @type {{destinationId: string}} */ (this.#owner).destinationId, MAX_DESTINATION_ID_LENGTH),
314
+ this.#provider,
315
+ this.#createdAt,
316
+ this.#expiresAt
317
+ )
318
+ }
319
+ this.#accountSize()
320
+ }
321
+
322
+ #closeDb() {
323
+ if (this.#db === null) return
324
+ try {
325
+ this.#db.close()
326
+ } catch {
327
+ /* Ignore close errors during abort. */
328
+ }
329
+ this.#closed = true
330
+ this.#db = null
331
+ }
332
+
333
+ async #verifyPermissions() {
334
+ const {chmod: chmodFile, stat: lstatFile} = this.#fileSystem
335
+ await chmodFile(this.#pendingPath, 0o600)
336
+ const stats = await lstatFile(this.#pendingPath)
337
+ if ((stats.mode & 0o777) !== 0o600) {
338
+ throw new EvidenceIndexError("Run index permissions are not restricted to owner")
339
+ }
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Inspect a finalized per-run index at the given ready path.
345
+ * @param {string} readyPath
346
+ * @param {EvidenceInspectRequest} request
347
+ * @param {{clock: () => number}} store
348
+ * @returns {EvidenceInspectResult}
349
+ */
350
+ export function inspectIndex(readyPath, request, store) {
351
+ const db = new DatabaseSync(readyPath)
352
+ try {
353
+ const manifest = readManifest(db)
354
+ if (manifest.expiresAt <= store.clock()) {
355
+ throw new EvidenceAccessErrorForIndex()
356
+ }
357
+ if (request.summary) {
358
+ return summarize(db, manifest)
359
+ }
360
+ if (request.events) {
361
+ return queryEvents(db, request.events)
362
+ }
363
+ if (request.failures) {
364
+ return queryFailures(db, request.failures)
365
+ }
366
+ throw new EvidenceIndexError("Invalid inspect selector")
367
+ } finally {
368
+ db.close()
369
+ }
370
+ }
371
+
372
+ class EvidenceAccessErrorForIndex extends Error {
373
+ constructor() {
374
+ super("Evidence index is unavailable")
375
+ this.name = "EvidenceAccessError"
376
+ }
377
+ }
378
+
379
+ /** @param {import("node:sqlite").DatabaseSync} db */
380
+ function readManifest(db) {
381
+ const row = /** @type {ManifestRow | undefined} */ (
382
+ db.prepare("SELECT * FROM manifest LIMIT 1").get()
383
+ )
384
+ if (row === undefined) throw new EvidenceAccessErrorForIndex()
385
+ return {
386
+ version: row.version,
387
+ runId: row.run_id,
388
+ destinationId: row.destination_id,
389
+ provider: row.provider,
390
+ terminalState: row.terminal_state,
391
+ terminalExitCode: row.terminal_exit_code,
392
+ createdAt: row.created_at,
393
+ expiresAt: row.expires_at,
394
+ finalizedAt: row.finalized_at
395
+ }
396
+ }
397
+
398
+ /**
399
+ * @param {import("node:sqlite").DatabaseSync} db
400
+ * @param {ReturnType<typeof readManifest>} _manifest
401
+ */
402
+ function summarize(db, _manifest) {
403
+ const counts = /** @type {Array<{kind: string, count: number}>} */ (
404
+ db.prepare("SELECT kind, COUNT(*) AS count FROM events GROUP BY kind").all()
405
+ )
406
+ const eventCounts = Object.fromEntries(counts.map((row) => [row.kind, row.count]))
407
+ const total = /** @type {{total: number}} */ (
408
+ db.prepare("SELECT COUNT(*) AS total FROM events").get()
409
+ )
410
+ return {
411
+ version: 1,
412
+ provider: _manifest.provider,
413
+ terminal: _manifest.terminalState === null ? null : {state: /** @type {"completed" | "failed"} */ (_manifest.terminalState), exitCode: _manifest.terminalExitCode ?? 0},
414
+ createdAt: _manifest.createdAt,
415
+ expiresAt: _manifest.expiresAt,
416
+ finalizedAt: _manifest.finalizedAt,
417
+ totalEvents: total.total,
418
+ eventCounts
419
+ }
420
+ }
421
+
422
+ /**
423
+ * @param {import("node:sqlite").DatabaseSync} db
424
+ * @param {NonNullable<EvidenceInspectRequest["events"]>} options
425
+ */
426
+ function queryEvents(db, options) {
427
+ const limit = Math.min(Math.max(1, options.limit ?? 100), 1_000)
428
+ const conditions = ["1 = 1"]
429
+ const params = []
430
+ if (options.kind !== undefined) {
431
+ conditions.push("kind = ?")
432
+ params.push(options.kind)
433
+ }
434
+ if (options.after !== undefined) {
435
+ conditions.push("order_index > ?")
436
+ params.push(options.after)
437
+ }
438
+ if (options.before !== undefined) {
439
+ conditions.push("order_index < ?")
440
+ params.push(options.before)
441
+ }
442
+ const sql = `SELECT kind, timestamp, order_index, artifact_id, summary, metadata FROM events WHERE ${conditions.join(" AND ")} ORDER BY order_index ASC LIMIT ?`
443
+ const rows = /** @type {EventRow[]} */ (db.prepare(sql).all(...params, limit + 1))
444
+ const truncated = rows.length > limit
445
+ const selected = rows.slice(0, limit)
446
+ return {
447
+ events: selected.map((row) => ({
448
+ kind: row.kind,
449
+ timestamp: row.timestamp,
450
+ order: row.order_index,
451
+ artifactId: row.artifact_id,
452
+ summary: row.summary,
453
+ metadata: row.metadata === null ? undefined : JSON.parse(row.metadata)
454
+ })),
455
+ truncated,
456
+ ...(selected.length > 0 && truncated ? {nextOrder: selected[selected.length - 1]?.order_index} : {})
457
+ }
458
+ }
459
+
460
+ /**
461
+ * @param {import("node:sqlite").DatabaseSync} db
462
+ * @param {NonNullable<EvidenceInspectRequest["failures"]>} options
463
+ */
464
+ function queryFailures(db, options) {
465
+ const limit = Math.min(Math.max(1, options.limit ?? 100), 1_000)
466
+ const params = []
467
+ const afterClause = options.after !== undefined ? (params.push(options.after), "AND order_index > ?") : ""
468
+ const sql = `
469
+ SELECT kind, timestamp, order_index, artifact_id, summary, metadata FROM events
470
+ WHERE (
471
+ (kind = 'diagnostic' AND json_extract(metadata, '$.level') = 'error')
472
+ OR (kind = 'tool' AND json_extract(metadata, '$.phase') = 'finished')
473
+ ) ${afterClause}
474
+ ORDER BY order_index ASC
475
+ LIMIT ?
476
+ `
477
+ const rows = /** @type {EventRow[]} */ (db.prepare(sql).all(...params, limit + 1))
478
+ const selected = rows.slice(0, limit)
479
+ return {
480
+ failures: selected.map((row) => ({
481
+ kind: row.kind,
482
+ timestamp: row.timestamp,
483
+ order: row.order_index,
484
+ artifactId: row.artifact_id,
485
+ summary: row.summary,
486
+ metadata: row.metadata === null ? undefined : JSON.parse(row.metadata)
487
+ })),
488
+ truncated: rows.length > limit
489
+ }
490
+ }
491
+
492
+ /** @param {string} path */
493
+ async function safeUnlink(path) {
494
+ try {
495
+ await unlink(path)
496
+ } catch (error) {
497
+ if (/** @type {NodeJS.ErrnoException} */ (error).code !== "ENOENT") throw error
498
+ }
499
+ }
500
+
501
+ /**
502
+ * @param {unknown} value
503
+ * @param {number} limit
504
+ * @returns {string | null}
505
+ */
506
+ function boundString(value, limit) {
507
+ if (value === undefined || value === null) return null
508
+ if (typeof value !== "string") throw new EvidenceIndexError("Expected a string value")
509
+ if (value.length > limit) throw new EvidenceIndexError("String value exceeds bounded length")
510
+ return value
511
+ }
512
+
513
+ /** @param {unknown} event @returns {EvidenceIndexEvent} */
514
+ function validateEvent(event) {
515
+ if (!plainObject(event)) throw new EvidenceIndexError("Invalid evidence index event")
516
+ const record = /** @type {Record<string, unknown>} */ (event)
517
+ if (typeof record.kind !== "string" || !ALLOWED_EVENT_KINDS.has(record.kind)) {
518
+ throw new EvidenceIndexError("Invalid evidence index event kind")
519
+ }
520
+ const kind = /** @type {EvidenceIndexEvent["kind"]} */ (record.kind)
521
+ if (record.timestamp !== undefined && record.timestamp !== null && (!Number.isSafeInteger(record.timestamp) || /** @type {number} */ (record.timestamp) < 0)) {
522
+ throw new EvidenceIndexError("Invalid evidence index event timestamp")
523
+ }
524
+ if (record.artifactId !== undefined && (typeof record.artifactId !== "string" || record.artifactId.length > MAX_ARTIFACT_ID_LENGTH)) {
525
+ throw new EvidenceIndexError("Invalid evidence index event artifactId")
526
+ }
527
+ let summary
528
+ if (record.summary !== undefined) {
529
+ if (typeof record.summary !== "string" || record.summary.length > MAX_SUMMARY_LENGTH) {
530
+ throw new EvidenceIndexError("Invalid evidence index event summary")
531
+ }
532
+ summary = record.summary
533
+ }
534
+ let metadata
535
+ if (record.metadata !== undefined) {
536
+ if (!plainObject(record.metadata)) throw new EvidenceIndexError("Invalid evidence index event metadata")
537
+ metadata = record.metadata
538
+ }
539
+ return {kind, summary, metadata}
540
+ }
541
+
542
+ /** @param {unknown} request @returns {EvidenceInspectRequest} */
543
+ export function validateInspectRequest(request) {
544
+ if (!plainObject(request)) throw new EvidenceIndexError("Invalid evidence inspect request")
545
+ const record = /** @type {Record<string, unknown>} */ (request)
546
+ const handleDescriptor = Object.getOwnPropertyDescriptor(record, "handle")
547
+ if (
548
+ !handleDescriptor || handleDescriptor.get !== undefined || handleDescriptor.set !== undefined
549
+ || typeof handleDescriptor.value !== "string" || !/^evidence_[A-Za-z0-9_-]{43}$/u.test(handleDescriptor.value)
550
+ ) {
551
+ throw new EvidenceIndexError("Invalid evidence handle")
552
+ }
553
+ const selectors = ["summary", "events", "failures"].filter((field) => Object.prototype.hasOwnProperty.call(record, field))
554
+ if (selectors.length !== 1) throw new EvidenceIndexError("Evidence inspect requires exactly one fixed selector")
555
+ if (record.summary !== undefined && record.summary !== true) {
556
+ throw new EvidenceIndexError("Invalid summary selector")
557
+ }
558
+ if (record.events !== undefined) {
559
+ if (!plainObject(record.events)) throw new EvidenceIndexError("Invalid events selector")
560
+ const events = /** @type {Record<string, unknown>} */ (record.events)
561
+ if (events.kind !== undefined && typeof events.kind !== "string") {
562
+ throw new EvidenceIndexError("Invalid events kind filter")
563
+ }
564
+ if (events.kind !== undefined && !ALLOWED_EVENT_KINDS.has(events.kind)) {
565
+ throw new EvidenceIndexError("Invalid events kind filter")
566
+ }
567
+ for (const bound of ["after", "before"]) {
568
+ const value = events[bound]
569
+ if (value !== undefined && value !== null && (!Number.isSafeInteger(value) || /** @type {number} */ (value) < 0)) {
570
+ throw new EvidenceIndexError(`Invalid events ${bound} bound`)
571
+ }
572
+ }
573
+ if (events.limit !== undefined && events.limit !== null && (!Number.isSafeInteger(events.limit) || /** @type {number} */ (events.limit) < 1)) {
574
+ throw new EvidenceIndexError("Invalid events limit")
575
+ }
576
+ }
577
+ if (record.failures !== undefined) {
578
+ if (!plainObject(record.failures)) throw new EvidenceIndexError("Invalid failures selector")
579
+ const failures = /** @type {Record<string, unknown>} */ (record.failures)
580
+ if (failures.after !== undefined && failures.after !== null && (!Number.isSafeInteger(failures.after) || /** @type {number} */ (failures.after) < 0)) {
581
+ throw new EvidenceIndexError("Invalid failures after bound")
582
+ }
583
+ if (failures.limit !== undefined && failures.limit !== null && (!Number.isSafeInteger(failures.limit) || /** @type {number} */ (failures.limit) < 1)) {
584
+ throw new EvidenceIndexError("Invalid failures limit")
585
+ }
586
+ }
587
+ return /** @type {EvidenceInspectRequest} */ (request)
588
+ }
589
+
590
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
591
+ function plainObject(value) {
592
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
593
+ const prototype = Object.getPrototypeOf(value)
594
+ return prototype === Object.prototype || prototype === null
595
+ }
596
+
597
+ /**
598
+ * @typedef {{
599
+ * kind: "prompt" | "provider_stdout" | "provider_stderr" | "assistant" | "tool" | "lifecycle" | "diagnostic" | "context_metrics" | "provider_result",
600
+ * provider?: string,
601
+ * artifactId?: string,
602
+ * timestamp?: number,
603
+ * summary?: string | undefined,
604
+ * metadata?: Record<string, unknown> | undefined
605
+ * }} EvidenceIndexEvent
606
+ */
607
+
608
+ /**
609
+ * @typedef {{
610
+ * handle: string,
611
+ * summary?: true,
612
+ * events?: {kind?: string, after?: number, before?: number, limit?: number},
613
+ * failures?: {after?: number, limit?: number}
614
+ * }} EvidenceInspectRequest
615
+ */
616
+
617
+ /**
618
+ * @typedef {{
619
+ * version?: number,
620
+ * provider?: string | null,
621
+ * terminal?: {state: "completed" | "failed", exitCode: number} | null,
622
+ * createdAt?: number,
623
+ * expiresAt?: number,
624
+ * finalizedAt?: number | null,
625
+ * totalEvents?: number,
626
+ * eventCounts?: Record<string, number>,
627
+ * events?: Array<{kind: string, timestamp: number, order: number, artifactId: string | null, summary: string | null, metadata?: Record<string, unknown> | undefined}>,
628
+ * truncated?: boolean,
629
+ * nextOrder?: number | undefined,
630
+ * failures?: Array<{kind: string, timestamp: number, order: number, artifactId: string | null, summary: string | null, metadata?: Record<string, unknown> | undefined}>
631
+ * }} EvidenceInspectResult
632
+ */
633
+
634
+ /**
635
+ * Convert a normalized worker event into a bounded evidence-index event.
636
+ * @param {import("./types.js").WorkerEvent} event
637
+ * @param {string} provider
638
+ * @returns {EvidenceIndexEvent}
639
+ */
640
+ export function workerEventToIndexEvent(event, provider) {
641
+ if (event.type === "text-delta") {
642
+ return {kind: "assistant", provider, metadata: {streamId: event.streamId, length: event.text.length}}
643
+ }
644
+ if (event.type === "tool") {
645
+ return {
646
+ kind: "tool",
647
+ provider,
648
+ metadata: {
649
+ phase: event.phase,
650
+ name: event.name,
651
+ key: event.key,
652
+ ...(event.detail === undefined ? {} : {detail: event.detail.slice(0, 256)}),
653
+ ...(event.output === undefined ? {} : {outputLength: event.output.length})
654
+ }
655
+ }
656
+ }
657
+ if (event.type === "lifecycle") {
658
+ return {kind: "lifecycle", provider, metadata: {phase: event.phase, summary: event.summary.slice(0, 256)}}
659
+ }
660
+ return {kind: "diagnostic", provider, metadata: {level: event.level, summary: event.summary.slice(0, 256)}}
661
+ }
662
+
663
+ /** @typedef {{version: number, run_id: string, destination_id: string, provider: string | null, terminal_state: string | null, terminal_exit_code: number | null, created_at: number, expires_at: number, finalized_at: number | null}} ManifestRow */
664
+ /** @typedef {{kind: string, timestamp: number, order_index: number, artifact_id: string | null, summary: string | null, metadata: string | null}} EventRow */