threadwire 0.1.21 → 0.1.23
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/CHANGELOG.md +108 -0
- package/README.md +5 -3
- package/docs/container-runtime.md +2 -2
- package/docs/development-container.md +45 -47
- package/docs/evidence-artifacts.md +47 -0
- package/package.json +5 -3
- package/scripts/verify-package.js +2 -0
- package/src/cli.js +124 -23
- package/src/evidence-index.js +664 -0
- package/src/evidence-store.js +180 -44
- package/src/isolated-runtime.js +1 -1
- package/src/jsonl-record-spool.js +31 -5
- package/src/kimi-model-broker.js +18 -2
- package/src/redaction.js +50 -0
- package/src/run-worker.js +10 -3
- package/src/telegram-ingress/core.js +66 -6
|
@@ -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 */
|