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.
@@ -2,10 +2,23 @@
2
2
 
3
3
  import {createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual} from "node:crypto"
4
4
  import {spawnSync} from "node:child_process"
5
- import {constants as fsConstants} from "node:fs"
5
+ import {constants as fsConstants, unlinkSync} from "node:fs"
6
6
  import {mkdir, open, readdir, rename, unlink} from "node:fs/promises"
7
7
  import {basename, isAbsolute, join, parse, relative} from "node:path"
8
8
  import {StringDecoder} from "node:string_decoder"
9
+ import {applyRedactionRanges, redactionRanges, redactText} from "./redaction.js"
10
+ import {
11
+ isIndexJournalName,
12
+ isPendingIndexName,
13
+ isReadyIndexName,
14
+ readyIndexRunHash,
15
+ runHash,
16
+ RunIndex,
17
+ validateInspectRequest,
18
+ inspectIndex
19
+ } from "./evidence-index.js"
20
+
21
+ export {redactText}
9
22
 
10
23
  const HANDLE_PATTERN = /^evidence_([A-Za-z0-9_-]{43})$/u
11
24
  const META_SUFFIX = ".meta.json"
@@ -92,6 +105,12 @@ export class EvidenceStore {
92
105
  this.maintenance = Promise.resolve()
93
106
  /** @type {Map<string, {bytes: number, events: number}>} */
94
107
  this.runs = new Map()
108
+ /** @type {Map<string, RunIndex>} */
109
+ this.runIndices = new Map()
110
+ /** @type {Map<string, number>} */
111
+ this.runArtifactCounts = new Map()
112
+ /** @type {Map<string, number>} */
113
+ this.runIndexBytes = new Map()
95
114
  }
96
115
 
97
116
  /**
@@ -150,6 +169,9 @@ export class EvidenceStore {
150
169
  async finishClose() {
151
170
  await this.maintenance
152
171
  if (this.activeOperations > 0) await new Promise((resolve) => this.operationWaiters.push(() => resolve(undefined)))
172
+ for (const index of this.runIndices.values()) {
173
+ if (!index.finalized) await index.abort().catch(() => {})
174
+ }
153
175
  await this.assertPinnedRoot()
154
176
  await this.lease.close()
155
177
  this.closed = true
@@ -214,6 +236,8 @@ export class EvidenceStore {
214
236
  // JavaScript runs through here without yielding: reserve the slot before
215
237
  // the first await so concurrent creations observe this pending artifact.
216
238
  this.artifactSlots += 1
239
+ const runId = metadata.owner.runId
240
+ this.runArtifactCounts.set(runId, (this.runArtifactCounts.get(runId) ?? 0) + 1)
217
241
  /** @type {import("node:fs/promises").FileHandle | undefined} */
218
242
  let file
219
243
  try {
@@ -227,6 +251,7 @@ export class EvidenceStore {
227
251
  await safeUnlink(this.path(id, META_SUFFIX))
228
252
  this.reserve(metadata.owner.runId, -overheadBytes, 0)
229
253
  this.artifactSlots -= 1
254
+ this.decrementRunArtifactCount(metadata.owner.runId)
230
255
  finishOperation()
231
256
  throw error
232
257
  }
@@ -321,6 +346,100 @@ export class EvidenceStore {
321
346
  }
322
347
  }
323
348
 
349
+ /**
350
+ * @param {object} owner
351
+ * @param {{provider?: string}} [options]
352
+ * @returns {RunIndex}
353
+ */
354
+ ensureRunIndex(owner, options = {}) {
355
+ this.assertScope(owner)
356
+ if (this.closed || this.closing) throw new Error("Evidence store is closed")
357
+ const runId = /** @type {{runId: string}} */ (owner).runId
358
+ let index = this.runIndices.get(runId)
359
+ if (index) {
360
+ if (options.provider && !index.provider) index.provider = options.provider
361
+ return index
362
+ }
363
+ const initialBytes = this.runIndexBytes.get(runId) ?? 0
364
+ index = new RunIndex(this, owner, options, initialBytes)
365
+ this.runIndices.set(runId, index)
366
+ return index
367
+ }
368
+
369
+ /** @param {object} owner @param {string[]} redactions */
370
+ setRunIndexRedactions(owner, redactions) {
371
+ this.assertScope(owner)
372
+ if (this.closed || this.closing) throw new Error("Evidence store is closed")
373
+ const index = this.ensureRunIndex(owner, {})
374
+ index.setRedactions([...this.runtimeRedactions, ...validateRedactions(redactions)])
375
+ }
376
+
377
+ /** @param {object} owner @param {import("./evidence-index.js").EvidenceIndexEvent} event */
378
+ async recordEvent(owner, event) {
379
+ this.assertScope(owner)
380
+ if (this.closed || this.closing) throw new Error("Evidence store is closed")
381
+ const index = this.ensureRunIndex(owner, event.provider ? {provider: event.provider} : {})
382
+ await index.recordEvent(event)
383
+ }
384
+
385
+ /** @param {object} owner @param {{state: "completed" | "failed", exitCode: number}} terminal */
386
+ async finalizeRunIndex(owner, terminal) {
387
+ this.assertScope(owner)
388
+ if (this.closed || this.closing) throw new Error("Evidence store is closed")
389
+ const index = this.runIndices.get(/** @type {{runId: string}} */ (owner).runId)
390
+ if (index) await index.finalize(terminal)
391
+ }
392
+
393
+ /** @param {object} owner @param {unknown} request @returns {Promise<import("./evidence-index.js").EvidenceInspectResult>} */
394
+ async inspect(owner, request) {
395
+ this.assertScope(owner)
396
+ const validated = validateInspectRequest(request)
397
+ const finishOperation = this.beginOperation()
398
+ try {
399
+ return await this.inspectLocked(owner, validated)
400
+ } finally {
401
+ finishOperation()
402
+ }
403
+ }
404
+
405
+ /** @param {object} owner @param {import("./evidence-index.js").EvidenceInspectRequest} validated */
406
+ async inspectLocked(owner, validated) {
407
+ const id = this.lookupCapability(validated.handle)
408
+ if (!id) throw new EvidenceAccessError()
409
+ const metadata = this.artifacts.get(id)
410
+ if (!metadata || metadata.state !== "ready" || metadata.expiresAt <= this.clock() || !sameOwner(metadata.owner, owner)) {
411
+ throw new EvidenceAccessError()
412
+ }
413
+ const indexPath = this.path(runHash(metadata.owner.runId), ".index")
414
+ const indexStats = await safeRegularFile(indexPath)
415
+ if (indexStats === null) throw new EvidenceAccessError()
416
+ try {
417
+ return inspectIndex(indexPath, validated, this)
418
+ } catch (error) {
419
+ if (error instanceof EvidenceAccessError) throw error
420
+ if (error instanceof Error && (error.name === "EvidenceAccessError" || /unavailable/u.test(error.message))) {
421
+ throw new EvidenceAccessError()
422
+ }
423
+ throw error
424
+ }
425
+ }
426
+
427
+ /** @param {unknown} request @returns {Promise<import("./evidence-index.js").EvidenceInspectResult>} */
428
+ async inspectBearer(request) {
429
+ const validated = validateInspectRequest(request)
430
+ const id = this.lookupCapability(validated.handle)
431
+ if (!id) throw new EvidenceAccessError()
432
+ const metadata = this.artifacts.get(id)
433
+ if (!metadata) throw new EvidenceAccessError()
434
+ const owner = Object.freeze({...metadata.owner})
435
+ this.scopes.add(owner)
436
+ try {
437
+ return await this.inspect(owner, validated)
438
+ } finally {
439
+ this.scopes.delete(owner)
440
+ }
441
+ }
442
+
324
443
  /** @param {object} owner @param {string} handle @param {"delivery_succeeded" | "delivery_failed"} state */
325
444
  recordDelivery(owner, handle, state) {
326
445
  const finish = this.beginOperation()
@@ -440,6 +559,7 @@ export class EvidenceStore {
440
559
  const names = await readdir(this.root)
441
560
  const candidates = new Map()
442
561
  const abandonedTemps = []
562
+ const readyIndexNames = []
443
563
  for (const name of names) {
444
564
  const match = /^([A-Za-z0-9_-]{43})(\.meta\.json|\.payload|\.pending)$/u.exec(name)
445
565
  if (name === LOCK_FILE || name === ROOT_MARKER) continue
@@ -447,6 +567,14 @@ export class EvidenceStore {
447
567
  abandonedTemps.push(name)
448
568
  continue
449
569
  }
570
+ if (isPendingIndexName(name) || isIndexJournalName(name)) {
571
+ abandonedTemps.push(name)
572
+ continue
573
+ }
574
+ if (isReadyIndexName(name)) {
575
+ readyIndexNames.push(name)
576
+ continue
577
+ }
450
578
  if (!match) {
451
579
  throw new Error("Evidence store contains an unknown entry")
452
580
  }
@@ -458,6 +586,8 @@ export class EvidenceStore {
458
586
  candidates.set(id, group)
459
587
  }
460
588
  for (const name of abandonedTemps) await safeUnlink(join(this.root, basename(name)))
589
+ /** @type {Map<string, string>} */
590
+ const validRunHashes = new Map()
461
591
  for (const [id, files] of candidates) {
462
592
  let metadata
463
593
  try {
@@ -474,10 +604,26 @@ export class EvidenceStore {
474
604
  this.artifactSlots += 1
475
605
  this.artifacts.set(id, metadata)
476
606
  this.capabilities.set(metadata.capabilityHash, id)
607
+ const runId = metadata.owner.runId
608
+ this.runArtifactCounts.set(runId, (this.runArtifactCounts.get(runId) ?? 0) + 1)
609
+ validRunHashes.set(runHash(runId), runId)
477
610
  } catch {
478
611
  await Promise.all([...files].map((suffix) => safeUnlink(this.path(id, suffix))))
479
612
  }
480
613
  }
614
+ for (const name of readyIndexNames) {
615
+ const hash = readyIndexRunHash(name)
616
+ const runId = hash === undefined ? undefined : validRunHashes.get(hash)
617
+ if (runId === undefined) {
618
+ await safeUnlink(join(this.root, name))
619
+ continue
620
+ }
621
+ const stats = await safeRegularFile(join(this.root, name))
622
+ if (stats && stats.size > 0) {
623
+ this.reserve(runId, stats.size, 0)
624
+ this.runIndexBytes.set(runId, stats.size)
625
+ }
626
+ }
481
627
  }
482
628
 
483
629
  /** @param {string} runId @param {number} bytes @param {number} events */
@@ -504,6 +650,39 @@ export class EvidenceStore {
504
650
  run.events -= metadata.events
505
651
  if (run.bytes === 0 && run.events === 0) this.runs.delete(metadata.owner.runId)
506
652
  }
653
+ this.decrementRunArtifactCount(metadata.owner.runId)
654
+ }
655
+
656
+ /** @param {string} runId */
657
+ decrementRunArtifactCount(runId) {
658
+ const count = (this.runArtifactCounts.get(runId) ?? 1) - 1
659
+ if (count <= 0) {
660
+ this.runArtifactCounts.delete(runId)
661
+ this.removeRunIndex(runId)
662
+ } else {
663
+ this.runArtifactCounts.set(runId, count)
664
+ }
665
+ }
666
+
667
+ /** @param {string} runId */
668
+ removeRunIndex(runId) {
669
+ const index = this.runIndices.get(runId)
670
+ if (index) {
671
+ index.releaseAccounting()
672
+ this.runIndices.delete(runId)
673
+ this.runIndexBytes.delete(runId)
674
+ try { unlinkSync(index.readyPath) } catch { /* ignore */ }
675
+ try { unlinkSync(index.pendingPath) } catch { /* ignore */ }
676
+ return
677
+ }
678
+ const reservedBytes = this.runIndexBytes.get(runId) ?? 0
679
+ if (reservedBytes > 0) {
680
+ this.reserve(runId, -reservedBytes, 0)
681
+ }
682
+ this.runIndexBytes.delete(runId)
683
+ const hash = runHash(runId)
684
+ try { unlinkSync(this.path(hash, ".index")) } catch { /* ignore */ }
685
+ try { unlinkSync(this.path(hash, ".index.pending")) } catch { /* ignore */ }
507
686
  }
508
687
 
509
688
  /** @param {object} owner */
@@ -939,49 +1118,6 @@ function utf8End(buffer, offset) {
939
1118
  return offset
940
1119
  }
941
1120
 
942
- /** @param {string} text @param {string[]} secrets */
943
- function redactText(text, secrets) {
944
- const merged = redactionRanges(text, secrets)
945
- if (merged.length === 0) return {text, redacted: false}
946
- return {text: applyRedactionRanges(text, merged, text.length), redacted: true}
947
- }
948
-
949
- /** @param {string} text @param {string[]} secrets */
950
- function redactionRanges(text, secrets) {
951
- /** @type {{start: number, end: number}[]} */
952
- const ranges = []
953
- for (const secret of secrets) {
954
- let offset = 0
955
- while (offset <= text.length - secret.length) {
956
- const index = text.indexOf(secret, offset)
957
- if (index < 0) break
958
- ranges.push({start: index, end: index + secret.length})
959
- offset = index + 1
960
- }
961
- }
962
- ranges.sort((left, right) => left.start - right.start || left.end - right.end)
963
- /** @type {{start: number, end: number}[]} */
964
- const merged = []
965
- for (const range of ranges) {
966
- const previous = merged.at(-1)
967
- if (previous && range.start <= previous.end) previous.end = Math.max(previous.end, range.end)
968
- else merged.push({...range})
969
- }
970
- return merged
971
- }
972
-
973
- /** @param {string} text @param {{start: number, end: number}[]} ranges @param {number} end */
974
- function applyRedactionRanges(text, ranges, end) {
975
- let output = ""
976
- let offset = 0
977
- for (const range of ranges) {
978
- if (range.start >= end) break
979
- output += `${text.slice(offset, range.start)}[REDACTED]`
980
- offset = range.end
981
- }
982
- return output + text.slice(offset, end)
983
- }
984
-
985
1121
  /** @param {string} text @param {string[]} secrets @param {number} safeEnd */
986
1122
  function redactStablePrefix(text, secrets, safeEnd) {
987
1123
  const ranges = redactionRanges(text, secrets)
@@ -1757,7 +1757,7 @@ function validWorkerSecurity(config, host, provider = "codex") {
1757
1757
  ]
1758
1758
  return expected.every((name) => names.filter((entry) => entry === name).length === 1)
1759
1759
  && config.Env.includes("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
1760
- && config.Env.includes("NODE_VERSION=22.17.0") && config.Env.includes("YARN_VERSION=1.22.22")
1760
+ && config.Env.includes("NODE_VERSION=24.19.0") && config.Env.includes("YARN_VERSION=1.22.22")
1761
1761
  }
1762
1762
 
1763
1763
  function ownedNetworkIdentity(network, labels, identity, brokerContainer) {
@@ -4,6 +4,14 @@ import {closeSync, mkdtempSync, openSync, readFileSync, rmSync, unlinkSync, writ
4
4
  import {tmpdir} from "node:os"
5
5
  import {isAbsolute, join, normalize} from "node:path"
6
6
 
7
+ const filesystemOperations = {writeSync}
8
+
9
+ /**
10
+ * @typedef {object} FilesystemOperations
11
+ * @property {(fileDescriptor: number, buffer: Buffer, offset: number, length: number) => number} writeSync
12
+ * Write bytes to a file descriptor and return the count written.
13
+ */
14
+
7
15
  /**
8
16
  * Single-owner byte-oriented spool for one worker's pending JSONL record.
9
17
  *
@@ -21,13 +29,14 @@ import {isAbsolute, join, normalize} from "node:path"
21
29
  * owner calls it on every settlement path.
22
30
  */
23
31
  export class JsonlRecordSpool {
24
- /** @param {{memoryBytes: number, maxBytes: number, directory?: string}} options */
32
+ /** @param {{memoryBytes: number, maxBytes: number, directory?: string, filesystem?: FilesystemOperations}} options */
25
33
  constructor(options) {
26
34
  this.memoryBytes = positiveSafeInteger(options.memoryBytes, "memoryBytes")
27
35
  this.maxBytes = positiveSafeInteger(options.maxBytes, "maxBytes")
28
36
  const directory = options.directory ?? tmpdir()
29
37
  if (!isAbsolute(directory) || normalize(directory) !== directory) throw new Error("directory must be a normalized absolute path")
30
38
  this.directory = directory
39
+ this.filesystem = options.filesystem ?? filesystemOperations
31
40
  /** @type {Buffer[]} */
32
41
  this.segments = []
33
42
  this.pendingBytes = 0
@@ -120,7 +129,7 @@ export class JsonlRecordSpool {
120
129
  if (this.file === null) this.append(finalSegment)
121
130
  else {
122
131
  this.pendingBytes = recordBytes
123
- writeSync(this.file.fd, finalSegment)
132
+ this.writeAll(this.file.fd, finalSegment)
124
133
  }
125
134
  const file = this.file
126
135
  if (file === null) throw new Error("JSONL record spool file is unavailable")
@@ -137,7 +146,7 @@ export class JsonlRecordSpool {
137
146
  this.pendingBytes += segment.length
138
147
  if (this.pendingBytes > this.maxBytes) throw new StdoutRecordTooLargeError()
139
148
  if (this.file !== null) {
140
- writeSync(this.file.fd, segment)
149
+ this.writeAll(this.file.fd, segment)
141
150
  return
142
151
  }
143
152
  if (this.pendingBytes <= this.memoryBytes) {
@@ -150,9 +159,26 @@ export class JsonlRecordSpool {
150
159
  const path = join(this.ownedDirectory, "pending-record")
151
160
  const fd = openSync(path, "wx", 0o600)
152
161
  this.file = {fd, path}
153
- for (const buffered of this.segments) writeSync(fd, buffered)
162
+ for (const buffered of this.segments) this.writeAll(fd, buffered)
154
163
  this.segments = []
155
- writeSync(fd, segment)
164
+ this.writeAll(fd, segment)
165
+ }
166
+
167
+ /**
168
+ * Write a complete Buffer to one file descriptor, looping over short writes.
169
+ * @param {number} fd
170
+ * @param {Buffer} buffer
171
+ */
172
+ writeAll(fd, buffer) {
173
+ let offset = 0
174
+ while (offset < buffer.length) {
175
+ const written = this.filesystem.writeSync(fd, buffer, offset, buffer.length - offset)
176
+ if (written === 0) throw new Error("Spool write made zero bytes of progress")
177
+ if (!Number.isSafeInteger(written) || written < 0 || written > buffer.length - offset) {
178
+ throw new Error(`Spool write returned invalid byte count: ${written}`)
179
+ }
180
+ offset += written
181
+ }
156
182
  }
157
183
 
158
184
  /** Remove the current record's private spill directory, if one exists. */
@@ -25,6 +25,9 @@ const UPSTREAM_URL = "https://api.kimi.com/coding/v1/chat/completions"
25
25
  export async function startKimiModelBroker(options = {}) {
26
26
  const environment = options.environment ?? process.env
27
27
  const host = safeHost(environment.THREADWIRE_KIMI_MODEL_BROKER_HOST ?? "0.0.0.0")
28
+ // Port 0 is accepted for test/development use: the broker binds an
29
+ // OS-assigned ephemeral port and exposes the actual selected port in the
30
+ // returned config. Production deployments normally use the fixed defaults.
28
31
  const port = portValue(environment.THREADWIRE_KIMI_MODEL_BROKER_PORT, 8791)
29
32
  const workerHost = safeHost(environment.THREADWIRE_KIMI_MODEL_BROKER_WORKER_HOST ?? "0.0.0.0")
30
33
  const workerPort = portValue(environment.THREADWIRE_KIMI_MODEL_BROKER_WORKER_PORT, 8792)
@@ -143,10 +146,23 @@ export async function startKimiModelBroker(options = {}) {
143
146
  socket.once("close", () => controlSockets.delete(socket))
144
147
  })
145
148
  await listen(workerServer, workerPort, workerHost)
149
+ const workerAddress = workerServer.address()
150
+ if (workerAddress === null || typeof workerAddress === "string") {
151
+ await boundedClose(workerServer)
152
+ throw new Error("Kimi model broker worker listener address unavailable")
153
+ }
154
+ const actualWorkerPort = workerAddress.port
146
155
  try { await listen(server, port, host) } catch (error) {
147
156
  await boundedClose(workerServer)
148
157
  throw error
149
158
  }
159
+ const address = server.address()
160
+ if (address === null || typeof address === "string") {
161
+ await boundedClose(server)
162
+ await boundedClose(workerServer)
163
+ throw new Error("Kimi model broker listener address unavailable")
164
+ }
165
+ const actualPort = address.port
150
166
  return {
151
167
  server,
152
168
  workerServer,
@@ -159,7 +175,7 @@ export async function startKimiModelBroker(options = {}) {
159
175
  for (const socket of workerSockets) socket.destroy()
160
176
  await Promise.all([boundedClose(server), boundedClose(workerServer)])
161
177
  },
162
- config: {host, port, workerHost, workerPort, workerBind, models: [...approvedModels.keys()]}
178
+ config: {host, port: actualPort, workerHost, workerPort: actualWorkerPort, workerBind, models: [...approvedModels.keys()]}
163
179
  }
164
180
  }
165
181
 
@@ -326,7 +342,7 @@ function bindSetting(value, name) {
326
342
  function portValue(value, fallback) {
327
343
  if (value === undefined) return fallback
328
344
  const number = Number(value)
329
- if (!/^\d+$/u.test(value) || !Number.isSafeInteger(number) || number < 1 || number > 65535) throw new Error("Invalid Kimi model broker port")
345
+ if (!/^\d+$/u.test(value) || !Number.isSafeInteger(number) || number < 0 || number > 65535) throw new Error("Invalid Kimi model broker port")
330
346
  return number
331
347
  }
332
348
  /** @param {string | null} value @returns {string} */
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Bounded literal-secret redaction helpers.
3
+ *
4
+ * These are used by both evidence artifacts and the per-run SQLite index, so
5
+ * they live in a separate module to avoid a circular dependency between the
6
+ * store and index implementations.
7
+ */
8
+
9
+ /** @param {string} text @param {string[]} secrets */
10
+ export function redactText(text, secrets) {
11
+ const merged = redactionRanges(text, secrets)
12
+ if (merged.length === 0) return {text, redacted: false}
13
+ return {text: applyRedactionRanges(text, merged, text.length), redacted: true}
14
+ }
15
+
16
+ /** @param {string} text @param {string[]} secrets */
17
+ export function redactionRanges(text, secrets) {
18
+ /** @type {{start: number, end: number}[]} */
19
+ const ranges = []
20
+ for (const secret of secrets) {
21
+ let offset = 0
22
+ while (offset <= text.length - secret.length) {
23
+ const index = text.indexOf(secret, offset)
24
+ if (index < 0) break
25
+ ranges.push({start: index, end: index + secret.length})
26
+ offset = index + 1
27
+ }
28
+ }
29
+ ranges.sort((left, right) => left.start - right.start || left.end - right.end)
30
+ /** @type {{start: number, end: number}[]} */
31
+ const merged = []
32
+ for (const range of ranges) {
33
+ const previous = merged.at(-1)
34
+ if (previous && range.start <= previous.end) previous.end = Math.max(previous.end, range.end)
35
+ else merged.push({...range})
36
+ }
37
+ return merged
38
+ }
39
+
40
+ /** @param {string} text @param {{start: number, end: number}[]} ranges @param {number} end */
41
+ export function applyRedactionRanges(text, ranges, end) {
42
+ let output = ""
43
+ let offset = 0
44
+ for (const range of ranges) {
45
+ if (range.start >= end) break
46
+ output += `${text.slice(offset, range.start)}[REDACTED]`
47
+ offset = range.end
48
+ }
49
+ return output + text.slice(offset, end)
50
+ }
package/src/run-worker.js CHANGED
@@ -27,13 +27,15 @@ export function runWorker(options) {
27
27
  const spawnImplementation = options.spawnImplementation ?? nodeSpawn
28
28
  const killProcess = options.killProcess ?? ((pid, signal) => process.kill(pid, signal))
29
29
  // Attempt-owned liveness probe: signal 0 against the exact process group.
30
- // Never a name match or a broad process scan.
30
+ // Never a name match or a broad process scan. Only ESRCH/EINVAL mean the
31
+ // group is actually gone; EPERM means a process exists but we cannot signal
32
+ // it, so it must be treated as alive to avoid false settlement.
31
33
  const probeProcessGroup = options.probeProcessGroup ?? ((pid) => {
32
34
  try {
33
35
  process.kill(-pid, 0)
34
36
  return true
35
- } catch {
36
- return false
37
+ } catch (error) {
38
+ return !isProcessGoneError(error)
37
39
  }
38
40
  })
39
41
  const setTimer = options.setTimer ?? ((callback, milliseconds) => setTimeout(callback, milliseconds))
@@ -491,6 +493,11 @@ function signalExitCode(signal) {
491
493
  return 128 + (osConstants.signals[signal] ?? 0)
492
494
  }
493
495
 
496
+ /** @param {unknown} error @returns {boolean} */
497
+ function isProcessGoneError(error) {
498
+ return error instanceof Error && "code" in error && (error.code === "ESRCH" || error.code === "EINVAL")
499
+ }
500
+
494
501
  /** @param {unknown} value @returns {value is number} */
495
502
  function isPositiveSafeInteger(value) {
496
503
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0
@@ -4,6 +4,7 @@ import {createHash, randomUUID, timingSafeEqual} from "node:crypto"
4
4
  import {abortable} from "../absolute-deadline.js"
5
5
  import {DelegatedResultAdmission} from "../delegated-result-admission.js"
6
6
  import {evidenceTelegramDestination} from "../evidence-store.js"
7
+ import {workerEventToIndexEvent} from "../evidence-index.js"
7
8
  import {NoticeQueue} from "../notice-queue.js"
8
9
  import {createFetchTransport} from "../notifiers/fetch-transport.js"
9
10
  import {createTelegramSender} from "../notifiers/telegram.js"
@@ -164,7 +165,13 @@ export async function dispatchWorker(job, config, dependencies = {}) {
164
165
  runId: deliveryIdentity
165
166
  })
166
167
  if (evidenceOwner !== undefined) {
168
+ if (typeof dependencies.evidenceStore?.ensureRunIndex === "function") {
169
+ dependencies.evidenceStore.ensureRunIndex(evidenceOwner, {provider: job.provider})
170
+ }
167
171
  const redactions = await collectEvidenceRedactions(dependencies.providerEnvironment ?? {})
172
+ if (typeof dependencies.evidenceStore?.setRunIndexRedactions === "function") {
173
+ dependencies.evidenceStore.setRunIndexRedactions(evidenceOwner, redactions)
174
+ }
168
175
  evidence = await dependencies.evidenceStore?.createArtifact(evidenceOwner, {
169
176
  contentType: "text/plain; charset=utf-8",
170
177
  redactions
@@ -188,6 +195,7 @@ export async function dispatchWorker(job, config, dependencies = {}) {
188
195
  /** @param {import("../types.js").WorkerEvent} event */
189
196
  const acceptEvent = async (event) => {
190
197
  if (event.type === "text-delta") admission.acceptConclusionEvent(event)
198
+ await recordEvidenceEvent(workerEventToIndexEvent(event, job.provider))
191
199
  try {
192
200
  await control.accept(event)
193
201
  } catch (error) {
@@ -204,6 +212,11 @@ export async function dispatchWorker(job, config, dependencies = {}) {
204
212
  dependencies.activity?.recordSession(provider.name, id)
205
213
  }
206
214
  }
215
+ /** @param {import("../evidence-index.js").EvidenceIndexEvent} event */
216
+ const recordEvidenceEvent = (event) => {
217
+ if (evidenceOwner === undefined || typeof dependencies.evidenceStore?.recordEvent !== "function") return Promise.resolve()
218
+ return dependencies.evidenceStore.recordEvent(evidenceOwner, event)
219
+ }
207
220
  /** @param {number} exitCode */
208
221
  const finalizeEvidence = async (exitCode) => {
209
222
  if (resultEvidence === undefined || resultEvidenceFinalized) return
@@ -223,9 +236,23 @@ export async function dispatchWorker(job, config, dependencies = {}) {
223
236
  state: /** @type {"completed" | "failed"} */ (exitCode === 0 ? "completed" : "failed"),
224
237
  exitCode
225
238
  }
226
- await resultEvidence.append("provider-result", `${JSON.stringify(admission.createProviderResult(terminal))}\n`)
239
+ const providerResult = `${JSON.stringify(admission.createProviderResult(terminal))}\n`
240
+ await resultEvidence.append("provider-result", providerResult)
241
+ await recordEvidenceEvent({
242
+ kind: "provider_result",
243
+ artifactId: resultEvidence.metadata?.id,
244
+ provider: job.provider,
245
+ metadata: {terminal, length: Buffer.byteLength(providerResult, "utf8")}
246
+ })
227
247
  await resultEvidence.finalize()
228
248
  resultEvidenceFinalized = true
249
+ if (evidenceOwner !== undefined && typeof dependencies.evidenceStore?.finalizeRunIndex === "function") {
250
+ try {
251
+ await dependencies.evidenceStore.finalizeRunIndex(evidenceOwner, terminal)
252
+ } catch (error) {
253
+ evidenceError ??= error
254
+ }
255
+ }
229
256
  }
230
257
  const finishDelivery = async () => {
231
258
  if (resultEvidence !== undefined && deliveryError === undefined) {
@@ -254,7 +281,16 @@ export async function dispatchWorker(job, config, dependencies = {}) {
254
281
  }
255
282
  let evidenceTransferred = false
256
283
  try {
257
- await evidence?.append("prompt", `prompt\n${job.prompt}\nprovider-stream\n`)
284
+ if (evidence !== undefined) {
285
+ const promptEvidence = `prompt\n${job.prompt}\nprovider-stream\n`
286
+ await evidence.append("prompt", promptEvidence)
287
+ await recordEvidenceEvent({
288
+ kind: "prompt",
289
+ artifactId: evidence.metadata?.id,
290
+ provider: job.provider,
291
+ metadata: {length: Buffer.byteLength(promptEvidence, "utf8")}
292
+ })
293
+ }
258
294
 
259
295
  if (isolatedRuntimeClient !== undefined) {
260
296
  evidenceTransferred = true
@@ -277,8 +313,20 @@ export async function dispatchWorker(job, config, dependencies = {}) {
277
313
  onEvent: acceptEvent,
278
314
  onRecord: async (record) => acceptRecord(record),
279
315
  ...(job.provider === "kimi" ? {} : {
280
- onStdoutChunk: (chunk) => evidence?.append("provider-stdout", chunk),
281
- onStderrChunk: (chunk) => evidence?.append("provider-stderr", chunk)
316
+ onStdoutChunk: (chunk) => {
317
+ if (evidence === undefined) return undefined
318
+ return Promise.all([
319
+ evidence.append("provider-stdout", chunk),
320
+ recordEvidenceEvent({kind: "provider_stdout", artifactId: evidence.metadata?.id, provider: job.provider, metadata: {length: chunk.length}})
321
+ ]).then(() => {})
322
+ },
323
+ onStderrChunk: (chunk) => {
324
+ if (evidence === undefined) return undefined
325
+ return Promise.all([
326
+ evidence.append("provider-stderr", chunk),
327
+ recordEvidenceEvent({kind: "provider_stderr", artifactId: evidence.metadata?.id, provider: job.provider, metadata: {length: chunk.length}})
328
+ ]).then(() => {})
329
+ }
282
330
  })
283
331
  })
284
332
  let exitCode = 2
@@ -382,8 +430,20 @@ export async function dispatchWorker(job, config, dependencies = {}) {
382
430
  spawnGate.resolve()
383
431
  },
384
432
  onRecord: async (record) => acceptRecord(record),
385
- onStdoutChunk: (chunk) => evidence?.append("provider-stdout", chunk),
386
- onStderrChunk: (chunk) => evidence?.append("provider-stderr", chunk)
433
+ onStdoutChunk: (chunk) => {
434
+ if (evidence === undefined) return undefined
435
+ return Promise.all([
436
+ evidence.append("provider-stdout", chunk),
437
+ recordEvidenceEvent({kind: "provider_stdout", artifactId: evidence.metadata?.id, provider: job.provider, metadata: {length: chunk.length}})
438
+ ]).then(() => {})
439
+ },
440
+ onStderrChunk: (chunk) => {
441
+ if (evidence === undefined) return undefined
442
+ return Promise.all([
443
+ evidence.append("provider-stderr", chunk),
444
+ recordEvidenceEvent({kind: "provider_stderr", artifactId: evidence.metadata?.id, provider: job.provider, metadata: {length: chunk.length}})
445
+ ]).then(() => {})
446
+ }
387
447
  })
388
448
 
389
449
  // Detached observer: never resolves the spawn gate from process exit — only