threadwire 0.1.6 → 0.1.8

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/src/cli.js CHANGED
@@ -15,18 +15,24 @@ import {resolveWorkspaceProfile} from "./workspace-profile.js"
15
15
  import {WorkerControl} from "./worker-control.js"
16
16
  import {EvidenceStore} from "./evidence-store.js"
17
17
  import {ContextBudgetMetrics} from "./context-budget-metrics.js"
18
+ import {isolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
19
+ import {validateRelayWriteProviderArguments} from "./relay-write.js"
20
+ import {abortable} from "./absolute-deadline.js"
21
+ import {NormalizedOutput} from "./normalized-output.js"
18
22
 
19
23
  const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
20
24
  [--process-number <positive-integer>] [--cwd <directory>]
21
25
  [--workspace-profile <name>]
26
+ [--relay-write]
22
27
  [--tool-messages] [--max-output-length <positive-integer>]
23
- [--resume-session <provider-session-id>] [--activity-log <local-jsonl-path>]
28
+ [--resume-session <provider-session-id>] [--transcript <normalized-jsonl-path>]
29
+ [--activity-log <local-jsonl-path>]
24
30
  [--prompt <text> | --prompt-file <path> | stdin]
25
31
  [-- <provider arguments...>]
26
32
  threadwire evidence read --handle <opaque-handle>
27
33
  (--bytes <offset>:<limit> | --lines <start>:<limit> | --query <literal> --context-bytes <limit>)`
28
34
 
29
- /** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, workspaceProfile?: string, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
35
+ /** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, relayWrite: boolean, workspaceProfile?: string, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, transcript?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
30
36
  /** @typedef {{evidenceRead: true, request: unknown}} EvidenceParsedArguments */
31
37
  /**
32
38
  * @typedef {{
@@ -40,6 +46,7 @@ const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target
40
46
  * evidenceOwnerScope?: {destinationId: string, runId: string},
41
47
  * workerControlOptions?: Pick<ConstructorParameters<typeof WorkerControl>[0], "queueOptions">,
42
48
  * workspaceProfileOperations?: import("./workspace-profile.js").WorkspaceProfileOperations
49
+ * isolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">
43
50
  * }} MainDependencies
44
51
  */
45
52
 
@@ -52,8 +59,9 @@ export function parseArguments(arguments_) {
52
59
  const ownArguments = separator < 0 ? arguments_.slice(1) : arguments_.slice(1, separator)
53
60
  const providerArguments = separator < 0 ? [] : arguments_.slice(separator + 1)
54
61
  /** @type {Partial<ParsedArguments>} */
55
- const parsed = {providerArguments, cwd: process.cwd(), toolMessages: false}
62
+ const parsed = {providerArguments, cwd: process.cwd(), toolMessages: false, relayWrite: false}
56
63
  let cwdProvided = false
64
+ let transcriptProvided = false
57
65
  for (let index = 0; index < ownArguments.length;) {
58
66
  const option = ownArguments[index]
59
67
  if (option === "--tool-messages") {
@@ -61,7 +69,15 @@ export function parseArguments(arguments_) {
61
69
  index += 1
62
70
  continue
63
71
  }
72
+ if (option === "--relay-write") {
73
+ parsed.relayWrite = true
74
+ index += 1
75
+ continue
76
+ }
64
77
  const value = ownArguments[index + 1]
78
+ if (option === "--transcript" && (value === undefined || value.startsWith("--"))) {
79
+ throw new Error("Missing value for --transcript")
80
+ }
65
81
  if (!option || !value) throw new Error(`Missing value for ${option ?? "option"}`)
66
82
  if (option === "--provider") parsed.provider = value
67
83
  else if (option === "--target") parsed.target = value
@@ -75,7 +91,12 @@ export function parseArguments(arguments_) {
75
91
  else if (option === "--max-output-length") parsed.maxOutputLength = positiveInteger(value, "--max-output-length")
76
92
  else if (option === "--resume-session") parsed.resumeSession = sessionId(value)
77
93
  else if (option === "--activity-log") parsed.activityLog = resolve(value)
78
- else throw new Error(`Unknown option: ${option}`)
94
+ else if (option === "--transcript") {
95
+ if (transcriptProvided) throw new Error("Duplicate option: --transcript")
96
+ if (value.includes("\0")) throw new Error("--transcript path is invalid")
97
+ parsed.transcript = resolve(value)
98
+ transcriptProvided = true
99
+ } else throw new Error(`Unknown option: ${option}`)
79
100
  index += 2
80
101
  }
81
102
  if (!parsed.provider) throw new Error("--provider is required")
@@ -87,6 +108,11 @@ export function parseArguments(arguments_) {
87
108
  if (parsed.workspaceProfile !== undefined && cwdProvided) {
88
109
  throw new Error("--cwd and --workspace-profile are mutually exclusive")
89
110
  }
111
+ if (parsed.transcript !== undefined && parsed.transcript === parsed.activityLog) {
112
+ throw new Error("--transcript and --activity-log must resolve to different paths")
113
+ }
114
+ if (parsed.relayWrite && parsed.workspaceProfile === undefined) throw new Error("--relay-write requires --workspace-profile")
115
+ if (parsed.relayWrite && parsed.provider !== "codex") throw new Error("--relay-write is only supported for codex")
90
116
  return /** @type {ParsedArguments} */ (parsed)
91
117
  }
92
118
 
@@ -140,6 +166,10 @@ export async function main(arguments_, dependencies = {}) {
140
166
  const sourceEnvironment = dependencies.env ?? process.env
141
167
  let environment = sourceEnvironment
142
168
  const validateOnly = environment.THREADWIRE_VALIDATE_ONLY === "1"
169
+ /** @type {NormalizedOutput | undefined} */
170
+ let normalizedOutput
171
+ /** @type {DelegatedResultAdmission | undefined} */
172
+ let runAdmission
143
173
  try {
144
174
  const parsed = arguments_[0] === "evidence" ? parseEvidenceArguments(arguments_) : parseArguments(arguments_)
145
175
  if ("help" in parsed) {
@@ -159,7 +189,13 @@ export async function main(arguments_, dependencies = {}) {
159
189
  await store.close()
160
190
  }
161
191
  }
192
+ if (!validateOnly) {
193
+ normalizedOutput = new NormalizedOutput(output, parsed.transcript)
194
+ }
195
+ const metrics = new ContextBudgetMetrics()
196
+ runAdmission = normalizedOutput === undefined ? undefined : new DelegatedResultAdmission({output: normalizedOutput, metrics})
162
197
  const target = parseTelegramTarget(parsed.target)
198
+ if (parsed.relayWrite) validateRelayWriteProviderArguments(parsed.providerArguments)
163
199
  const resolvedWorkspace = parsed.workspaceProfile === undefined
164
200
  ? undefined
165
201
  : await resolveWorkspaceProfile(
@@ -167,8 +203,20 @@ export async function main(arguments_, dependencies = {}) {
167
203
  dependencies.workspaceProfileOperations
168
204
  )
169
205
  if (validateOnly) return 0
170
- const metrics = new ContextBudgetMetrics()
171
- const admission = new DelegatedResultAdmission({output, metrics})
206
+ const isolatedRuntimeClient = parsed.relayWrite
207
+ ? dependencies.isolatedRuntimeClient ?? isolatedRuntimeClientFromEnvironment(sourceEnvironment)
208
+ : undefined
209
+ const isolatedPreflight = parsed.relayWrite
210
+ ? await isolatedRuntimeClient?.preflight({
211
+ provider: parsed.provider,
212
+ profile: /** @type {string} */ (parsed.workspaceProfile),
213
+ repositoryRoot: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).repositoryRoot,
214
+ cwd: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).cwd,
215
+ providerArguments: parsed.providerArguments,
216
+ ...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession})
217
+ })
218
+ : undefined
219
+ const admission = /** @type {DelegatedResultAdmission} */ (runAdmission)
172
220
  let terminalExitCode = 2
173
221
  /** @type {unknown} */
174
222
  let evidenceError
@@ -179,12 +227,13 @@ export async function main(arguments_, dependencies = {}) {
179
227
  /** @type {EvidenceStore | undefined} */
180
228
  let ownedEvidenceStore
181
229
  let evidencePayloadBytes = 0
230
+ const launchDeadline = isolatedPreflight?.deadline
182
231
  try {
183
- environment = await resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"])
184
- const prompt = await readPrompt(parsed, dependencies.input ?? stdin)
232
+ environment = await boundedLaunch(resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"]), launchDeadline)
233
+ const prompt = await readPrompt(parsed, dependencies.input ?? stdin, launchDeadline?.signal)
185
234
  const configuredEvidenceRoot = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
186
235
  if (dependencies.evidenceStore === undefined && configuredEvidenceRoot !== undefined) {
187
- ownedEvidenceStore = await EvidenceStore.open({root: configuredEvidenceRoot})
236
+ ownedEvidenceStore = await boundedLaunch(EvidenceStore.open({root: configuredEvidenceRoot}), launchDeadline)
188
237
  }
189
238
  const evidenceStore = dependencies.evidenceStore ?? ownedEvidenceStore
190
239
  if (evidenceStore !== undefined) {
@@ -194,16 +243,22 @@ export async function main(arguments_, dependencies = {}) {
194
243
  })
195
244
  evidence = await evidenceStore.createArtifact(evidenceOwner, {
196
245
  contentType: "text/plain; charset=utf-8",
197
- redactions: await collectEvidenceRedactions(environment)
246
+ redactions: await boundedLaunch(collectEvidenceRedactions(environment), launchDeadline)
198
247
  })
199
248
  }
200
249
  if (evidence !== undefined) {
201
250
  const promptEvidence = `prompt\n${prompt}\nprovider-stream\n`
202
- await evidence.append("prompt", promptEvidence)
251
+ await boundedLaunch(evidence.append("prompt", promptEvidence), launchDeadline)
203
252
  evidencePayloadBytes += Buffer.byteLength(promptEvidence, "utf8")
204
253
  }
205
254
  const providerEnvironment = buildProviderEnvironment(environment)
206
- const provider = createProvider(parsed.provider, parsed.providerArguments, prompt, parsed.resumeSession, providerEnvironment)
255
+ const provider = createProvider(
256
+ parsed.provider,
257
+ parsed.providerArguments,
258
+ prompt,
259
+ parsed.resumeSession,
260
+ parsed.relayWrite ? {} : providerEnvironment
261
+ )
207
262
  if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
208
263
  if (parsed.workspaceProfile !== undefined) {
209
264
  if (resolvedWorkspace === undefined) throw new Error("Workspace profile resolution failed")
@@ -228,7 +283,8 @@ export async function main(arguments_, dependencies = {}) {
228
283
  resolvedWorkspace.sourceIdentity
229
284
  )
230
285
  }
231
- const exitCode = await (dependencies.workerRunner ?? runWorker)({
286
+ /** @type {import("./run-worker.js").RunWorkerOptions} */
287
+ const workerOptions = {
232
288
  executable: provider.executable,
233
289
  arguments: provider.arguments,
234
290
  cwd: resolvedWorkspace?.cwd ?? parsed.cwd,
@@ -265,8 +321,21 @@ export async function main(arguments_, dependencies = {}) {
265
321
  if (evidence === undefined) return undefined
266
322
  return evidence.append("provider-stderr", chunk).then(() => { evidencePayloadBytes += chunk.length })
267
323
  }
268
- })
269
- await control.close()
324
+ }
325
+ const exitCode = parsed.relayWrite
326
+ ? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).run({
327
+ preflightId: /** @type {{preflightId: string}} */ (isolatedPreflight).preflightId,
328
+ prompt,
329
+ providerArguments: parsed.providerArguments,
330
+ ...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession}),
331
+ onEvent: workerOptions.onEvent,
332
+ onRecord: /** @type {NonNullable<typeof workerOptions.onRecord>} */ (workerOptions.onRecord),
333
+ ...(workerOptions.onStdoutChunk === undefined ? {} : {onStdoutChunk: workerOptions.onStdoutChunk}),
334
+ ...(workerOptions.onStderrChunk === undefined ? {} : {onStderrChunk: workerOptions.onStderrChunk}),
335
+ ...(launchDeadline === undefined ? {} : {deadline: launchDeadline})
336
+ })
337
+ : await (dependencies.workerRunner ?? runWorker)(workerOptions)
338
+ await boundedLaunch(control.close(), launchDeadline)
270
339
  terminalExitCode = exitCode
271
340
  } finally {
272
341
  if (evidence !== undefined) {
@@ -289,13 +358,28 @@ export async function main(arguments_, dependencies = {}) {
289
358
  admission.complete({state: terminalExitCode === 0 ? "completed" : "failed", exitCode: terminalExitCode})
290
359
  errorOutput.write(`threadwire-context-metrics ${JSON.stringify(metrics.conciseDiagnostic())}\n`)
291
360
  activity?.close()
292
- await ownedEvidenceStore?.close()
361
+ await boundedLaunch(ownedEvidenceStore?.close(), launchDeadline).catch(() => {})
362
+ launchDeadline?.close()
293
363
  }
294
364
  if (evidenceError !== undefined) throw evidenceError
295
365
  return terminalExitCode
296
366
  } catch (error) {
297
- errorOutput.write(`threadwire: ${safeMessage(error)}\n`)
367
+ let reportedError = error
368
+ if (runAdmission !== undefined && !runAdmission.completed) {
369
+ try {
370
+ runAdmission.complete({state: "failed", exitCode: 2})
371
+ } catch (transcriptError) {
372
+ reportedError = transcriptError
373
+ }
374
+ }
375
+ errorOutput.write(`threadwire: ${safeMessage(reportedError)}\n`)
298
376
  return 2
377
+ } finally {
378
+ try {
379
+ normalizedOutput?.close()
380
+ } catch (error) {
381
+ errorOutput.write(`threadwire: ${safeMessage(error)}\n`)
382
+ }
299
383
  }
300
384
  }
301
385
 
@@ -350,21 +434,36 @@ function validateNormalizedWorkerEvent(event) {
350
434
  throw new Error("Worker emitted an unknown normalized event kind")
351
435
  }
352
436
 
353
- /** @param {ParsedArguments} parsed @param {NodeJS.ReadableStream & {isTTY?: boolean}} input */
354
- async function readPrompt(parsed, input) {
437
+ /** @param {ParsedArguments} parsed @param {NodeJS.ReadableStream & {isTTY?: boolean}} input @param {AbortSignal | undefined} signal */
438
+ async function readPrompt(parsed, input, signal) {
355
439
  let prompt
356
440
  if (parsed.prompt !== undefined) prompt = parsed.prompt
357
- else if (parsed.promptFile !== undefined) prompt = await readFile(parsed.promptFile, "utf8")
441
+ else if (parsed.promptFile !== undefined) prompt = await readFile(parsed.promptFile, {encoding: "utf8", signal})
358
442
  else {
359
443
  if (input.isTTY) throw new Error("A prompt is required via --prompt, --prompt-file, or piped stdin")
360
444
  prompt = ""
361
445
  input.setEncoding("utf8")
362
- for await (const chunk of input) prompt += chunk
446
+ const abort = () => /** @type {NodeJS.ReadableStream & {destroy?: (error?: unknown) => void}} */ (input).destroy?.(signal?.reason)
447
+ signal?.addEventListener("abort", abort, {once: true})
448
+ try {
449
+ for await (const chunk of input) {
450
+ if (signal?.aborted) throw signal.reason
451
+ prompt += chunk
452
+ }
453
+ } finally {
454
+ signal?.removeEventListener("abort", abort)
455
+ }
363
456
  }
364
457
  if (prompt.trim().length === 0) throw new Error("Prompt cannot be empty")
365
458
  return prompt
366
459
  }
367
460
 
461
+ /** @param {Promise<unknown> | undefined} promise @param {import("./absolute-deadline.js").AbsoluteDeadline | undefined} deadline */
462
+ function boundedLaunch(promise, deadline) {
463
+ deadline?.throwIfAborted()
464
+ return abortable(Promise.resolve(promise), deadline?.signal)
465
+ }
466
+
368
467
  /** @param {unknown} error */
369
468
  function safeMessage(error) {
370
469
  return error instanceof Error ? error.message : "Unknown error"
@@ -45,7 +45,7 @@ const STATES = new Set(["completed", "failed", "blocked", "needs_decision"])
45
45
  */
46
46
 
47
47
  export class DelegatedResultAdmission {
48
- /** @param {{output: NodeJS.WritableStream, metrics?: import("./context-budget-metrics.js").ContextBudgetMetrics}} options */
48
+ /** @param {{output: {write: (chunk: string) => unknown}, metrics?: import("./context-budget-metrics.js").ContextBudgetMetrics}} options */
49
49
  constructor(options) {
50
50
  this.output = options.output
51
51
  this.metrics = options.metrics
@@ -0,0 +1,131 @@
1
+ // @ts-nocheck
2
+ /* eslint-disable jsdoc/require-jsdoc */
3
+
4
+ import {request as httpRequest} from "node:http"
5
+
6
+ export class DockerApi {
7
+ /** @param {{host?: string, requestImplementation?: typeof httpRequest}} [options] */
8
+ constructor(options = {}) {
9
+ this.host = options.host ?? process.env.DOCKER_HOST ?? "unix:///var/run/docker.sock"
10
+ this.requestImplementation = options.requestImplementation ?? httpRequest
11
+ }
12
+
13
+ /** @param {string} method @param {string} path @param {unknown} [body] @param {boolean} [raw] */
14
+ request(method, path, body, raw = false, options = {}) {
15
+ const timeoutMs = options.timeoutMs ?? 30_000
16
+ const payload = body === undefined ? undefined : Buffer.from(JSON.stringify(body))
17
+ const target = dockerTarget(this.host, path)
18
+ return new Promise((resolve, reject) => {
19
+ const request = this.requestImplementation({
20
+ ...target,
21
+ method,
22
+ headers: payload === undefined ? {} : {"content-type": "application/json", "content-length": payload.length}
23
+ }, (response) => {
24
+ /** @type {Buffer[]} */
25
+ const chunks = []
26
+ let bytes = 0
27
+ response.on("data", (chunk) => {
28
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
29
+ bytes += buffer.length
30
+ if (bytes > 8_388_608) {
31
+ request.destroy(new Error("Docker response exceeded capacity"))
32
+ return
33
+ }
34
+ chunks.push(buffer)
35
+ })
36
+ response.on("end", () => {
37
+ const combined = Buffer.concat(chunks)
38
+ const content = combined.toString("utf8")
39
+ const status = response.statusCode ?? 500
40
+ if (status < 200 || status >= 300) {
41
+ reject(new Error(`Docker API ${method} ${path} failed (${status})`))
42
+ return
43
+ }
44
+ if (combined.length === 0) {
45
+ resolve(undefined)
46
+ return
47
+ }
48
+ if (raw) {
49
+ resolve(combined)
50
+ return
51
+ }
52
+ try {
53
+ resolve(JSON.parse(content))
54
+ } catch {
55
+ resolve(content)
56
+ }
57
+ })
58
+ })
59
+ request.on("error", reject)
60
+ const abort = () => request.destroy(options.signal.reason instanceof Error ? options.signal.reason : new Error("Docker request aborted"))
61
+ if (options.signal?.aborted) abort()
62
+ else options.signal?.addEventListener("abort", abort, {once: true})
63
+ const deadline = setTimeout(
64
+ () => request.destroy(new Error(`Docker API ${method} ${path} timed out`)),
65
+ timeoutMs
66
+ )
67
+ deadline.unref()
68
+ request.once("close", () => {
69
+ clearTimeout(deadline)
70
+ options.signal?.removeEventListener("abort", abort)
71
+ })
72
+ if (payload !== undefined) request.end(payload)
73
+ else request.end()
74
+ })
75
+ }
76
+
77
+ version(options) { return this.request("GET", "/version", undefined, false, options) }
78
+ inspectImage(image, options) { return this.request("GET", `/images/${encodeURIComponent(image)}/json`, undefined, false, options) }
79
+ createNetwork(name, labels = {}, options) {
80
+ return this.request("POST", "/networks/create", {
81
+ Name: name,
82
+ Internal: true,
83
+ CheckDuplicate: true,
84
+ EnableIPv6: false,
85
+ Labels: {"org.threadwire.owner": "isolated-runtime", ...labels}
86
+ }, false, options)
87
+ }
88
+ removeNetwork(id, options) { return this.request("DELETE", `/networks/${encodeURIComponent(id)}`, undefined, false, options) }
89
+ inspectNetwork(id, options) { return this.request("GET", `/networks/${encodeURIComponent(id)}`, undefined, false, options) }
90
+ connectNetwork(id, container, aliases = [], options) {
91
+ return this.request("POST", `/networks/${encodeURIComponent(id)}/connect`, {
92
+ Container: container,
93
+ EndpointConfig: {Aliases: aliases}
94
+ }, false, options)
95
+ }
96
+ disconnectNetwork(id, container, options) {
97
+ return this.request("POST", `/networks/${encodeURIComponent(id)}/disconnect`, {Container: container, Force: true}, false, options)
98
+ }
99
+ createContainer(name, spec, options) {
100
+ return this.request("POST", `/containers/create?name=${encodeURIComponent(name)}`, spec, false, options)
101
+ }
102
+ listContainers(labels = {}, all = true, options) {
103
+ const filters = {label: Object.entries(labels).map(([key, value]) => `${key}=${value}`)}
104
+ return this.request("GET", `/containers/json?all=${all ? 1 : 0}&filters=${encodeURIComponent(JSON.stringify(filters))}`, undefined, false, options)
105
+ }
106
+ listNetworks(labels = {}, options) {
107
+ const filters = {label: Object.entries(labels).map(([key, value]) => `${key}=${value}`)}
108
+ return this.request("GET", `/networks?filters=${encodeURIComponent(JSON.stringify(filters))}`, undefined, false, options)
109
+ }
110
+ startContainer(id, options) { return this.request("POST", `/containers/${encodeURIComponent(id)}/start`, undefined, false, options) }
111
+ waitContainer(id, options) { return this.request("POST", `/containers/${encodeURIComponent(id)}/wait?condition=not-running`, undefined, false, options) }
112
+ logs(id, options) { return this.request("GET", `/containers/${encodeURIComponent(id)}/logs?stdout=1&stderr=1`, undefined, true, options) }
113
+ inspectContainer(id, options) { return this.request("GET", `/containers/${encodeURIComponent(id)}/json`, undefined, false, options) }
114
+ removeContainer(id, options) { return this.request("DELETE", `/containers/${encodeURIComponent(id)}?force=1&v=1`, undefined, false, options) }
115
+ createVolume(name, labels = {}, options) {
116
+ return this.request("POST", "/volumes/create", {Name: name, Labels: labels}, false, options)
117
+ }
118
+ listVolumes(labels = {}, options) {
119
+ const filters = {label: Object.entries(labels).map(([key, value]) => `${key}=${value}`)}
120
+ return this.request("GET", `/volumes?filters=${encodeURIComponent(JSON.stringify(filters))}`, undefined, false, options)
121
+ }
122
+ inspectVolume(name, options) { return this.request("GET", `/volumes/${encodeURIComponent(name)}`, undefined, false, options) }
123
+ removeVolume(name, options) { return this.request("DELETE", `/volumes/${encodeURIComponent(name)}?force=0`, undefined, false, options) }
124
+ }
125
+
126
+ function dockerTarget(host, path) {
127
+ const url = new URL(host)
128
+ if (url.protocol === "unix:") return {socketPath: url.pathname, path}
129
+ if (url.protocol !== "tcp:" && url.protocol !== "http:") throw new Error("DOCKER_HOST must use unix or tcp")
130
+ return {hostname: url.hostname, port: Number(url.port || "2375"), path}
131
+ }
@@ -0,0 +1,149 @@
1
+ // @ts-nocheck
2
+ /* eslint-disable jsdoc/require-jsdoc */
3
+
4
+ import {deadlineAfter, abortable, readResponseCapped} from "./absolute-deadline.js"
5
+
6
+ const MAX_RESPONSE_BYTES = 2_097_152
7
+ const DEFAULT_TIMEOUT_MS = 3_660_000
8
+ const MAX_RAW_OUTPUT_BYTES = 1_048_576
9
+
10
+ export class IsolatedRuntimeClient {
11
+ /** @param {{url: string, controlToken: string, timeoutMs?: number | string, fetchImplementation?: typeof fetch}} options */
12
+ constructor(options) {
13
+ this.url = normalizedUrl(options.url)
14
+ this.controlToken = nonempty(options.controlToken, "isolated runtime control token")
15
+ this.fetch = options.fetchImplementation ?? fetch
16
+ this.timeoutMs = positiveDuration(options.timeoutMs, DEFAULT_TIMEOUT_MS)
17
+ }
18
+
19
+ /**
20
+ * @param {{provider: string, profile: string, repositoryRoot: string, cwd: string, providerArguments: string[], resumeSession?: string, signal?: AbortSignal, deadline?: import("./absolute-deadline.js").AbsoluteDeadline}} request
21
+ * @returns {Promise<{preflightId: string, deadline?: import("./absolute-deadline.js").AbsoluteDeadline}>}
22
+ */
23
+ async preflight(request) {
24
+ const deadline = request.deadline ?? deadlineAfter(this.timeoutMs, {signal: request.signal, timeoutMessage: "Isolated runtime launch timeout"})
25
+ const response = await this.request("/preflight", {...request, launchDeadlineAt: deadline.expiresAt}, deadline)
26
+ if (response.ok !== true || typeof response.preflightId !== "string") throw new Error("Isolated runtime preflight failed")
27
+ return {preflightId: response.preflightId, deadline}
28
+ }
29
+
30
+ /**
31
+ * @param {{
32
+ * preflightId: string, prompt: string, providerArguments: string[],
33
+ * resumeSession?: string,
34
+ * signal?: AbortSignal,
35
+ * deadline?: import("./absolute-deadline.js").AbsoluteDeadline,
36
+ * onEvent: (event: import("./types.js").WorkerEvent) => void | Promise<void>,
37
+ * onRecord?: (record: unknown) => void | Promise<void>,
38
+ * onStdoutChunk?: (chunk: Buffer) => void | Promise<void>,
39
+ * onStderrChunk?: (chunk: Buffer) => void | Promise<void>
40
+ * }} options
41
+ */
42
+ async run(options) {
43
+ const deadline = options.deadline ?? deadlineAfter(this.timeoutMs, {signal: options.signal, timeoutMessage: "Isolated runtime launch timeout"})
44
+ const response = await this.request("/run", {
45
+ preflightId: options.preflightId,
46
+ prompt: options.prompt,
47
+ providerArguments: options.providerArguments,
48
+ ...(options.resumeSession === undefined ? {} : {resumeSession: options.resumeSession})
49
+ }, deadline)
50
+ if (!Array.isArray(response.records) || !Array.isArray(response.rawChunks) || !Number.isSafeInteger(response.exitCode)) throw new Error("Isolated runtime emitted an invalid response")
51
+ let rawBytes = 0
52
+ const decoded = response.rawChunks.map((item) => {
53
+ if (!isRecord(item) || Object.keys(item).sort().join(",") !== "channel,data"
54
+ || (item.channel !== "stdout" && item.channel !== "stderr") || typeof item.data !== "string"
55
+ || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(item.data)) {
56
+ throw new Error("Isolated runtime emitted an invalid response")
57
+ }
58
+ const data = Buffer.from(item.data, "base64")
59
+ rawBytes += data.length
60
+ if (rawBytes > MAX_RAW_OUTPUT_BYTES) throw new Error("Isolated runtime emitted an invalid response")
61
+ return {channel: item.channel, data}
62
+ })
63
+ for (const chunk of decoded) {
64
+ deadline.throwIfAborted()
65
+ const callback = chunk.channel === "stdout" ? options.onStdoutChunk : options.onStderrChunk
66
+ await abortable(callback?.(chunk.data), deadline.signal)
67
+ }
68
+ for (const record of response.records) {
69
+ deadline.throwIfAborted()
70
+ await abortable(options.onRecord?.(record), deadline.signal)
71
+ if (isWorkerEventEnvelope(record)) await abortable(options.onEvent(record.event), deadline.signal)
72
+ }
73
+ return /** @type {number} */ (response.exitCode)
74
+ }
75
+
76
+ /** @param {string} path @param {unknown} body */
77
+ async request(path, body, deadline) {
78
+ const signal = deadline.signal
79
+ const response = await abortable(this.fetch(`${this.url}${path}`, {
80
+ method: "POST",
81
+ redirect: "error",
82
+ headers: {
83
+ authorization: `Bearer ${this.controlToken}`,
84
+ "content-type": "application/json",
85
+ connection: "close",
86
+ "x-threadwire-launch-deadline": String(deadline.expiresAt)
87
+ },
88
+ body: JSON.stringify(body),
89
+ signal
90
+ }), signal)
91
+ let bytes
92
+ try {
93
+ bytes = await readResponseCapped(response, MAX_RESPONSE_BYTES, signal)
94
+ } catch (error) {
95
+ if (error instanceof Error && error.message === "Response exceeded capacity") throw new Error("Isolated runtime response exceeded the configured capacity", {cause: error})
96
+ throw error
97
+ }
98
+ if (!response.ok) throw new Error(safeRemoteError(bytes))
99
+ try {
100
+ const value = JSON.parse(bytes.toString("utf8"))
101
+ if (!isRecord(value)) throw new Error()
102
+ return value
103
+ } catch {
104
+ throw new Error("Isolated runtime emitted an invalid response")
105
+ }
106
+ }
107
+ }
108
+
109
+ function positiveDuration(value, fallback) {
110
+ if (value === undefined) return fallback
111
+ const number = Number(value)
112
+ if (!Number.isSafeInteger(number) || number < 1 || number > 604_800_000) throw new Error("Invalid isolated runtime client timeout")
113
+ return number
114
+ }
115
+ /** @param {NodeJS.ProcessEnv} environment */
116
+ export function isolatedRuntimeClientFromEnvironment(environment) {
117
+ return new IsolatedRuntimeClient({
118
+ url: nonempty(environment.THREADWIRE_ISOLATED_RUNTIME_URL, "THREADWIRE_ISOLATED_RUNTIME_URL"),
119
+ controlToken: nonempty(environment.THREADWIRE_ISOLATED_RUNTIME_CONTROL_TOKEN, "THREADWIRE_ISOLATED_RUNTIME_CONTROL_TOKEN"),
120
+ timeoutMs: environment.THREADWIRE_ISOLATED_RUNTIME_CLIENT_TIMEOUT_MS
121
+ })
122
+ }
123
+
124
+ function normalizedUrl(value) {
125
+ const url = new URL(value)
126
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("THREADWIRE_ISOLATED_RUNTIME_URL must use HTTP or HTTPS")
127
+ url.pathname = url.pathname.replace(/\/+$/u, "")
128
+ url.search = ""
129
+ url.hash = ""
130
+ return url.toString().replace(/\/$/u, "")
131
+ }
132
+
133
+ function nonempty(value, name) {
134
+ if (typeof value !== "string" || value.trim().length === 0 || /[\r\n]/u.test(value)) throw new Error(`${name} is required`)
135
+ return value
136
+ }
137
+
138
+ function safeRemoteError(bytes) {
139
+ const text = bytes.toString("utf8").trim()
140
+ return /^[\x20-\x7e]{1,200}$/u.test(text) ? text : "Isolated runtime request failed"
141
+ }
142
+
143
+ function isRecord(value) {
144
+ return typeof value === "object" && value !== null && !Array.isArray(value)
145
+ }
146
+
147
+ function isWorkerEventEnvelope(value) {
148
+ return isRecord(value) && value.type === "worker-event" && isRecord(value.event)
149
+ }