threadwire 0.1.11 → 0.1.13

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.
@@ -3,9 +3,6 @@
3
3
  import {closeSync, openSync, writeSync} from "node:fs"
4
4
 
5
5
  const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,511}$/u
6
- const PROFILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u
7
- const REVISION_PATTERN = /^[0-9a-f]{40}$/u
8
- const SOURCE_IDENTITY_PATTERN = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/u
9
6
 
10
7
  export class ActivityLog {
11
8
  /** @param {string} path */
@@ -14,16 +11,6 @@ export class ActivityLog {
14
11
  this.closed = false
15
12
  }
16
13
 
17
- /** @param {string} profile @param {string} repositoryRoot @param {string} revision @param {string} sourceIdentity */
18
- recordWorkspace(profile, repositoryRoot, revision, sourceIdentity) {
19
- if (!PROFILE_PATTERN.test(profile)) throw new Error("Workspace profile is invalid")
20
- if (typeof repositoryRoot !== "string" || repositoryRoot.length === 0) throw new Error("Workspace repository root is invalid")
21
- if (!REVISION_PATTERN.test(revision) || !SOURCE_IDENTITY_PATTERN.test(sourceIdentity)) {
22
- throw new Error("Workspace provenance is invalid")
23
- }
24
- this.write({type: "workspace-selected", profile, repositoryRoot, revision, sourceIdentity})
25
- }
26
-
27
14
  /** @param {"codex" | "claude" | "kimi" | "opencode"} provider @param {number} pid */
28
15
  recordStarted(provider, pid) {
29
16
  if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("Provider child PID is unavailable")
@@ -42,7 +29,7 @@ export class ActivityLog {
42
29
  closeSync(this.fileDescriptor)
43
30
  }
44
31
 
45
- /** @param {{type: "workspace-selected", profile: string, repositoryRoot: string, revision: string, sourceIdentity: string} | {type: "provider-started", provider: "codex" | "claude" | "kimi" | "opencode", pid: number} | {type: "session-available", provider: "codex" | "claude" | "kimi" | "opencode", sessionId: string}} fact */
32
+ /** @param {{type: "provider-started", provider: "codex" | "claude" | "kimi" | "opencode", pid: number} | {type: "session-available", provider: "codex" | "claude" | "kimi" | "opencode", sessionId: string}} fact */
46
33
  write(fact) {
47
34
  if (this.closed) throw new Error("Activity log is closed")
48
35
  writeSync(this.fileDescriptor, `${JSON.stringify(fact)}\n`)
package/src/cli.js CHANGED
@@ -11,19 +11,16 @@ import {runWorker} from "./run-worker.js"
11
11
  import {ActivityLog} from "./activity-log.js"
12
12
  import {DelegatedResultAdmission, validateContinuationHandle} from "./delegated-result-admission.js"
13
13
  import {buildProviderEnvironment, collectEvidenceRedactions, parseTelegramRequestTimeoutMs, resolveFileBackedSettings} from "./telegram-ingress/config.js"
14
- import {resolveWorkspaceProfile} from "./workspace-profile.js"
15
14
  import {WorkerControl} from "./worker-control.js"
16
15
  import {EvidenceStore} from "./evidence-store.js"
17
16
  import {ContextBudgetMetrics} from "./context-budget-metrics.js"
18
- import {isolatedRuntimeClientFromEnvironment, kimiIsolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
19
- import {kimiSessionEnvelopeId} from "./providers/kimi.js"
17
+ import {isolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
20
18
  import {validateRelayWriteProviderArguments} from "./relay-write.js"
21
19
  import {abortable} from "./absolute-deadline.js"
22
20
  import {NormalizedOutput} from "./normalized-output.js"
23
21
 
24
22
  const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
25
23
  [--process-number <positive-integer>] [--cwd <directory>]
26
- [--workspace-profile <name>]
27
24
  [--relay-write]
28
25
  [--tool-messages] [--max-output-length <positive-integer>]
29
26
  [--resume-session <provider-session-id>] [--transcript <normalized-jsonl-path>]
@@ -33,7 +30,7 @@ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --ta
33
30
  threadwire evidence read --handle <opaque-handle>
34
31
  (--bytes <offset>:<limit> | --lines <start>:<limit> | --query <literal> --context-bytes <limit>)`
35
32
 
36
- /** @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 */
33
+ /** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, relayWrite: boolean, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, transcript?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
37
34
  /** @typedef {{evidenceRead: true, request: unknown}} EvidenceParsedArguments */
38
35
  /**
39
36
  * @typedef {{
@@ -46,9 +43,7 @@ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --ta
46
43
  * evidenceStore?: EvidenceStore,
47
44
  * evidenceOwnerScope?: {destinationId: string, runId: string},
48
45
  * workerControlOptions?: Pick<ConstructorParameters<typeof WorkerControl>[0], "queueOptions">,
49
- * workspaceProfileOperations?: import("./workspace-profile.js").WorkspaceProfileOperations,
50
- * isolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">,
51
- * kimiIsolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">
46
+ * isolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">
52
47
  * }} MainDependencies
53
48
  */
54
49
 
@@ -62,7 +57,6 @@ export function parseArguments(arguments_) {
62
57
  const providerArguments = separator < 0 ? [] : arguments_.slice(separator + 1)
63
58
  /** @type {Partial<ParsedArguments>} */
64
59
  const parsed = {providerArguments, cwd: process.cwd(), toolMessages: false, relayWrite: false}
65
- let cwdProvided = false
66
60
  let transcriptProvided = false
67
61
  for (let index = 0; index < ownArguments.length;) {
68
62
  const option = ownArguments[index]
@@ -85,8 +79,7 @@ export function parseArguments(arguments_) {
85
79
  else if (option === "--target") parsed.target = value
86
80
  else if (option === "--cwd") {
87
81
  parsed.cwd = resolve(value)
88
- cwdProvided = true
89
- } else if (option === "--workspace-profile") parsed.workspaceProfile = value
82
+ } else if (option === "--workspace-profile") throw new Error("--workspace-profile is no longer supported; use --cwd")
90
83
  else if (option === "--prompt") parsed.prompt = value
91
84
  else if (option === "--prompt-file") parsed.promptFile = resolve(value)
92
85
  else if (option === "--process-number") parsed.processNumber = positiveInteger(value, "--process-number")
@@ -107,14 +100,9 @@ export function parseArguments(arguments_) {
107
100
  }
108
101
  if (!parsed.target) throw new Error("--target is required")
109
102
  if (parsed.prompt !== undefined && parsed.promptFile !== undefined) throw new Error("Use exactly one prompt source")
110
- if (parsed.workspaceProfile !== undefined && cwdProvided) {
111
- throw new Error("--cwd and --workspace-profile are mutually exclusive")
112
- }
113
103
  if (parsed.transcript !== undefined && parsed.transcript === parsed.activityLog) {
114
104
  throw new Error("--transcript and --activity-log must resolve to different paths")
115
105
  }
116
- if (parsed.relayWrite && parsed.workspaceProfile === undefined) throw new Error("--relay-write requires --workspace-profile")
117
- if (parsed.provider === "kimi" && parsed.workspaceProfile === undefined) throw new Error("Kimi requires --workspace-profile")
118
106
  if (parsed.relayWrite && parsed.provider !== "codex") throw new Error("--relay-write is only supported for codex")
119
107
  return /** @type {ParsedArguments} */ (parsed)
120
108
  }
@@ -199,25 +187,17 @@ export async function main(arguments_, dependencies = {}) {
199
187
  runAdmission = normalizedOutput === undefined ? undefined : new DelegatedResultAdmission({output: normalizedOutput, metrics})
200
188
  const target = parseTelegramTarget(parsed.target)
201
189
  if (parsed.relayWrite) validateRelayWriteProviderArguments(parsed.providerArguments)
202
- const resolvedWorkspace = parsed.workspaceProfile === undefined
203
- ? undefined
204
- : await resolveWorkspaceProfile(
205
- {provider: /** @type {"codex" | "claude" | "kimi" | "opencode"} */ (parsed.provider), profile: parsed.workspaceProfile},
206
- dependencies.workspaceProfileOperations
207
- )
208
190
  if (validateOnly) return 0
209
- const usesIsolatedRuntime = parsed.relayWrite || parsed.provider === "kimi"
191
+ const usesIsolatedRuntime = parsed.relayWrite
210
192
  const isolatedRuntimeClient = usesIsolatedRuntime
211
- ? parsed.provider === "kimi"
212
- ? dependencies.kimiIsolatedRuntimeClient ?? kimiIsolatedRuntimeClientFromEnvironment(sourceEnvironment)
213
- : dependencies.isolatedRuntimeClient ?? isolatedRuntimeClientFromEnvironment(sourceEnvironment)
193
+ ? dependencies.isolatedRuntimeClient ?? isolatedRuntimeClientFromEnvironment(sourceEnvironment)
214
194
  : undefined
215
195
  const isolatedPreflight = usesIsolatedRuntime
216
196
  ? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).preflight({
217
197
  provider: parsed.provider,
218
- profile: /** @type {string} */ (parsed.workspaceProfile),
219
- repositoryRoot: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).repositoryRoot,
220
- cwd: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).cwd,
198
+ profile: "explicit-cwd",
199
+ repositoryRoot: parsed.cwd,
200
+ cwd: parsed.cwd,
221
201
  providerArguments: parsed.providerArguments,
222
202
  ...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession})
223
203
  })
@@ -257,18 +237,18 @@ export async function main(arguments_, dependencies = {}) {
257
237
  await boundedLaunch(evidence.append("prompt", promptEvidence), launchDeadline)
258
238
  evidencePayloadBytes += Buffer.byteLength(promptEvidence, "utf8")
259
239
  }
260
- const providerEnvironment = buildProviderEnvironment(environment)
240
+ const providerEnvironment = buildProviderEnvironment(environment, {
241
+ provider: parsed.provider,
242
+ isolated: usesIsolatedRuntime
243
+ })
261
244
  const provider = createProvider(
262
245
  parsed.provider,
263
246
  parsed.providerArguments,
264
247
  prompt,
265
248
  parsed.resumeSession,
266
- usesIsolatedRuntime ? {} : providerEnvironment
249
+ providerEnvironment
267
250
  )
268
251
  if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
269
- if (parsed.workspaceProfile !== undefined) {
270
- if (resolvedWorkspace === undefined) throw new Error("Workspace profile resolution failed")
271
- }
272
252
  const token = environment.THREADWIRE_TELEGRAM_BOT_TOKEN
273
253
  if (!token) throw new Error("THREADWIRE_TELEGRAM_BOT_TOKEN is required")
274
254
  const transport = (dependencies.transportFactory ?? createFetchTransport)(token, undefined, parseTelegramRequestTimeoutMs(environment))
@@ -281,20 +261,13 @@ export async function main(arguments_, dependencies = {}) {
281
261
  metrics
282
262
  })
283
263
  activity = parsed.activityLog === undefined ? undefined : new ActivityLog(parsed.activityLog)
284
- if (activity && resolvedWorkspace) {
285
- activity.recordWorkspace(
286
- resolvedWorkspace.profile,
287
- resolvedWorkspace.repositoryRoot,
288
- resolvedWorkspace.revision,
289
- resolvedWorkspace.sourceIdentity
290
- )
291
- }
292
264
  /** @type {import("./run-worker.js").RunWorkerOptions} */
293
265
  const workerOptions = {
294
266
  executable: provider.executable,
295
267
  arguments: provider.arguments,
296
- cwd: resolvedWorkspace?.cwd ?? parsed.cwd,
268
+ cwd: parsed.cwd,
297
269
  environment: providerEnvironment,
270
+ provider: provider.name,
298
271
  parse: provider.parse,
299
272
  onEvent: (event) => {
300
273
  validateNormalizedWorkerEvent(event)
@@ -309,7 +282,7 @@ export async function main(arguments_, dependencies = {}) {
309
282
  },
310
283
  onRecord: (record) => {
311
284
  metrics.recordParsedProviderRecord("provider_stdout")
312
- const id = parsed.provider === "kimi" ? kimiSessionEnvelopeId(record) : provider.sessionId(record)
285
+ const id = provider.sessionId(record)
313
286
  if (id !== undefined) {
314
287
  admission.setContinuationHandle(id)
315
288
  activity?.recordSession(provider.name, id)
package/src/docker-api.js CHANGED
@@ -60,13 +60,12 @@ export class DockerApi {
60
60
  const abort = () => request.destroy(options.signal.reason instanceof Error ? options.signal.reason : new Error("Docker request aborted"))
61
61
  if (options.signal?.aborted) abort()
62
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()
63
+ const deadline = timeoutMs > 0
64
+ ? setTimeout(() => request.destroy(new Error(`Docker API ${method} ${path} timed out`)), timeoutMs)
65
+ : undefined
66
+ deadline?.unref()
68
67
  request.once("close", () => {
69
- clearTimeout(deadline)
68
+ if (deadline !== undefined) clearTimeout(deadline)
70
69
  options.signal?.removeEventListener("abort", abort)
71
70
  })
72
71
  if (payload !== undefined) request.end(payload)
@@ -77,13 +76,11 @@ export class DockerApi {
77
76
  version(options) { return this.request("GET", "/version", undefined, false, options) }
78
77
  inspectImage(image, options) { return this.request("GET", `/images/${encodeURIComponent(image)}/json`, undefined, false, options) }
79
78
  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)
79
+ return createNetwork(this.request.bind(this), name, labels, true, options)
80
+ }
81
+ /** Create a task-owned non-internal egress network for the per-run Kimi relay. */
82
+ createEgressNetwork(name, labels = {}, options) {
83
+ return createNetwork(this.request.bind(this), name, labels, false, options)
87
84
  }
88
85
  removeNetwork(id, options) { return this.request("DELETE", `/networks/${encodeURIComponent(id)}`, undefined, false, options) }
89
86
  inspectNetwork(id, options) { return this.request("GET", `/networks/${encodeURIComponent(id)}`, undefined, false, options) }
@@ -108,8 +105,72 @@ export class DockerApi {
108
105
  return this.request("GET", `/networks?filters=${encodeURIComponent(JSON.stringify(filters))}`, undefined, false, options)
109
106
  }
110
107
  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) }
108
+ waitContainer(id, options = {}) {
109
+ return this.request("POST", `/containers/${encodeURIComponent(id)}/wait?condition=not-running`, undefined, false, {
110
+ ...options,
111
+ timeoutMs: options.timeoutMs ?? 0
112
+ })
113
+ }
112
114
  logs(id, options) { return this.request("GET", `/containers/${encodeURIComponent(id)}/logs?stdout=1&stderr=1`, undefined, true, options) }
115
+ /**
116
+ * Stream a container's multiplexed logs (stdout+stderr, follow) as raw bytes.
117
+ * Resolves once the follow stream ends (container stop) or rejects on abort,
118
+ * capacity overflow, or a Docker error. Each `onData` invocation carries one
119
+ * received chunk; framing/demultiplexing is the caller's responsibility.
120
+ * @param {string} id @param {(chunk: Buffer) => void} onData @param {{signal?: AbortSignal, timeoutMs?: number}} [options]
121
+ */
122
+ streamLogs(id, onData, options = {}) {
123
+ const timeoutMs = options.timeoutMs
124
+ const target = dockerTarget(this.host, `/containers/${encodeURIComponent(id)}/logs?stdout=1&stderr=1&follow=1`)
125
+ return new Promise((resolve, reject) => {
126
+ let bytes = 0
127
+ let settled = false
128
+ const fail = (error) => {
129
+ if (settled) return
130
+ settled = true
131
+ request.destroy()
132
+ reject(error)
133
+ }
134
+ const request = this.requestImplementation({...target, method: "GET", headers: {}}, (response) => {
135
+ const status = response.statusCode ?? 500
136
+ if (status < 200 || status >= 300) {
137
+ response.resume()
138
+ fail(new Error(`Docker API GET /containers/${id}/logs failed (${status})`))
139
+ return
140
+ }
141
+ response.on("data", (chunk) => {
142
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
143
+ bytes += buffer.length
144
+ if (bytes > 8_388_608) {
145
+ fail(new Error("Docker response exceeded capacity"))
146
+ return
147
+ }
148
+ onData(buffer)
149
+ })
150
+ response.on("end", () => {
151
+ if (settled) return
152
+ settled = true
153
+ resolve(undefined)
154
+ })
155
+ response.on("error", fail)
156
+ })
157
+ request.on("error", (error) => {
158
+ if (settled) return
159
+ settled = true
160
+ reject(error)
161
+ })
162
+ const abort = () => fail(options.signal.reason instanceof Error ? options.signal.reason : new Error("Docker request aborted"))
163
+ if (options.signal?.aborted) abort()
164
+ else options.signal?.addEventListener("abort", abort, {once: true})
165
+ const deadline = timeoutMs === undefined ? undefined : setTimeout(() => fail(new Error(`Docker API GET /containers/${id}/logs timed out`)), timeoutMs)
166
+ deadline?.unref()
167
+ request.once("close", () => {
168
+ if (deadline !== undefined) clearTimeout(deadline)
169
+ options.signal?.removeEventListener("abort", abort)
170
+ })
171
+ request.end()
172
+ })
173
+ }
113
174
  inspectContainer(id, options) { return this.request("GET", `/containers/${encodeURIComponent(id)}/json`, undefined, false, options) }
114
175
  removeContainer(id, options) { return this.request("DELETE", `/containers/${encodeURIComponent(id)}?force=1&v=1`, undefined, false, options) }
115
176
  createVolume(name, labels = {}, options) {
@@ -119,10 +180,35 @@ export class DockerApi {
119
180
  const filters = {label: Object.entries(labels).map(([key, value]) => `${key}=${value}`)}
120
181
  return this.request("GET", `/volumes?filters=${encodeURIComponent(JSON.stringify(filters))}`, undefined, false, options)
121
182
  }
183
+ /** List all containers so a volume reference can be inspected fail-closed. */
184
+ listVolumeReferences(volume, options) {
185
+ if (typeof volume !== "string" || volume.length === 0) throw new Error("Docker volume reference is invalid")
186
+ return this.listContainers({}, true, options).then(async (containers) => {
187
+ if (!Array.isArray(containers)) throw new Error("Docker volume references unavailable")
188
+ const references = []
189
+ for (const container of containers) {
190
+ if (!container || typeof container.Id !== "string") throw new Error("Docker volume references unavailable")
191
+ const inspection = await this.inspectContainer(container.Id, options)
192
+ const mounts = Array.isArray(inspection?.Mounts) ? inspection.Mounts : []
193
+ if (mounts.some((mount) => mount?.Type === "volume" && mount?.Name === volume)) references.push(inspection)
194
+ }
195
+ return references
196
+ })
197
+ }
122
198
  inspectVolume(name, options) { return this.request("GET", `/volumes/${encodeURIComponent(name)}`, undefined, false, options) }
123
199
  removeVolume(name, options) { return this.request("DELETE", `/volumes/${encodeURIComponent(name)}?force=0`, undefined, false, options) }
124
200
  }
125
201
 
202
+ function createNetwork(request, name, labels, internal, options) {
203
+ return request("POST", "/networks/create", {
204
+ Name: name,
205
+ Internal: internal,
206
+ CheckDuplicate: true,
207
+ EnableIPv6: false,
208
+ Labels: {"org.threadwire.owner": "isolated-runtime", ...labels}
209
+ }, false, options)
210
+ }
211
+
126
212
  function dockerTarget(host, path) {
127
213
  const url = new URL(host)
128
214
  if (url.protocol === "unix:") return {socketPath: url.pathname, path}
@@ -1,10 +1,9 @@
1
1
  // @ts-nocheck
2
2
  /* eslint-disable jsdoc/require-jsdoc */
3
3
 
4
- import {deadlineAfter, abortable, readResponseCapped} from "./absolute-deadline.js"
4
+ import {AbsoluteDeadline, deadlineAfter, abortable, readResponseCapped} from "./absolute-deadline.js"
5
5
 
6
6
  const MAX_RESPONSE_BYTES = 2_097_152
7
- const DEFAULT_TIMEOUT_MS = 3_660_000
8
7
  const MAX_RAW_OUTPUT_BYTES = 1_048_576
9
8
 
10
9
  export class IsolatedRuntimeClient {
@@ -13,18 +12,27 @@ export class IsolatedRuntimeClient {
13
12
  this.url = normalizedUrl(options.url)
14
13
  this.controlToken = nonempty(options.controlToken, "isolated runtime control token")
15
14
  this.fetch = options.fetchImplementation ?? fetch
16
- this.timeoutMs = positiveDuration(options.timeoutMs, DEFAULT_TIMEOUT_MS)
15
+ this.timeoutMs = optionalDuration(options.timeoutMs)
17
16
  }
18
17
 
19
18
  /**
20
- * @param {{provider: string, profile: string, repositoryRoot: string, cwd: string, providerArguments: string[], resumeSession?: string, signal?: AbortSignal, deadline?: import("./absolute-deadline.js").AbsoluteDeadline}} request
19
+ * @param {{provider: string, profile: string, repositoryRoot?: string, cwd?: string, binding?: unknown, providerArguments: string[], resumeSession?: string, signal?: AbortSignal, deadline?: import("./absolute-deadline.js").AbsoluteDeadline}} request
21
20
  * @returns {Promise<{preflightId: string, deadline?: import("./absolute-deadline.js").AbsoluteDeadline}>}
22
21
  */
23
22
  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}
23
+ const deadline = request.deadline ?? (this.timeoutMs === undefined ? undefined : deadlineAfter(this.timeoutMs, {signal: request.signal, timeoutMessage: "Isolated runtime launch timeout"}))
24
+ const operation = deadline ?? new AbsoluteDeadline(undefined, {signal: request.signal, timeoutMessage: "Isolated runtime launch timeout"})
25
+ const body = {...request}
26
+ delete body.signal
27
+ delete body.deadline
28
+ if (deadline !== undefined) body.launchDeadlineAt = deadline.expiresAt
29
+ try {
30
+ const response = await this.request("/preflight", body, operation)
31
+ if (response.ok !== true || typeof response.preflightId !== "string") throw new Error("Isolated runtime preflight failed")
32
+ return deadline === undefined ? {preflightId: response.preflightId} : {preflightId: response.preflightId, deadline}
33
+ } finally {
34
+ if (deadline === undefined) operation.close()
35
+ }
28
36
  }
29
37
 
30
38
  /**
@@ -33,44 +41,42 @@ export class IsolatedRuntimeClient {
33
41
  * resumeSession?: string,
34
42
  * signal?: AbortSignal,
35
43
  * 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>
44
+ * onEvent: (event: import("./types.js").WorkerEvent) => unknown | Promise<unknown>,
45
+ * onRecord?: (record: unknown) => unknown | Promise<unknown>,
46
+ * onStdoutChunk?: (chunk: Buffer) => unknown | Promise<unknown>,
47
+ * onStderrChunk?: (chunk: Buffer) => unknown | Promise<unknown>
40
48
  * }} options
49
+ * @returns {Promise<number>}
41
50
  */
42
51
  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")
52
+ const deadline = options.deadline ?? (this.timeoutMs === undefined ? undefined : deadlineAfter(this.timeoutMs, {signal: options.signal, timeoutMessage: "Isolated runtime launch timeout"}))
53
+ const operation = deadline ?? new AbsoluteDeadline(undefined, {signal: options.signal, timeoutMessage: "Isolated runtime launch timeout"})
54
+ try {
55
+ const response = await abortable(this.fetch(`${this.url}/run`, {
56
+ method: "POST",
57
+ redirect: "error",
58
+ headers: {
59
+ authorization: `Bearer ${this.controlToken}`,
60
+ "content-type": "application/json",
61
+ connection: "close",
62
+ ...(operation.expiresAt === undefined ? {} : {"x-threadwire-launch-deadline": String(operation.expiresAt)})
63
+ },
64
+ body: JSON.stringify({
65
+ preflightId: options.preflightId,
66
+ prompt: options.prompt,
67
+ providerArguments: options.providerArguments,
68
+ ...(options.resumeSession === undefined ? {} : {resumeSession: options.resumeSession})
69
+ }),
70
+ signal: operation.signal
71
+ }), operation.signal)
72
+ if (!response.ok) {
73
+ const bytes = await readResponseCapped(response, MAX_RESPONSE_BYTES, operation.signal)
74
+ throw new Error(safeRemoteError(bytes))
57
75
  }
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)
76
+ return await consumeRunStream(response, options, operation)
77
+ } finally {
78
+ if (options.deadline === undefined) operation.close()
67
79
  }
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
80
  }
75
81
 
76
82
  /** @param {string} path @param {unknown} body */
@@ -83,7 +89,7 @@ export class IsolatedRuntimeClient {
83
89
  authorization: `Bearer ${this.controlToken}`,
84
90
  "content-type": "application/json",
85
91
  connection: "close",
86
- "x-threadwire-launch-deadline": String(deadline.expiresAt)
92
+ ...(deadline.expiresAt === undefined ? {} : {"x-threadwire-launch-deadline": String(deadline.expiresAt)})
87
93
  },
88
94
  body: JSON.stringify(body),
89
95
  signal
@@ -106,8 +112,74 @@ export class IsolatedRuntimeClient {
106
112
  }
107
113
  }
108
114
 
109
- function positiveDuration(value, fallback) {
110
- if (value === undefined) return fallback
115
+ /** @returns {Promise<number>} */
116
+ async function consumeRunStream(response, options, operation) {
117
+ const reader = response.body?.getReader()
118
+ if (reader === undefined) throw new Error("Isolated runtime emitted an invalid response")
119
+ let pending = Buffer.alloc(0)
120
+ let totalBytes = 0
121
+ let rawBytes = 0
122
+ /** @type {number | undefined} */
123
+ let terminal
124
+ const consume = async (line) => {
125
+ if (line.length === 0 || terminal !== undefined) throw new Error("Isolated runtime emitted an invalid response")
126
+ let frame
127
+ try { frame = JSON.parse(line.toString("utf8")) } catch { throw new Error("Isolated runtime emitted an invalid response") }
128
+ if (!isRecord(frame) || typeof frame.type !== "string") throw new Error("Isolated runtime emitted an invalid response")
129
+ if (frame.type === "chunk") {
130
+ if (Object.keys(frame).sort().join(",") !== "channel,data,type"
131
+ || (frame.channel !== "stdout" && frame.channel !== "stderr") || typeof frame.data !== "string"
132
+ || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(frame.data)) {
133
+ throw new Error("Isolated runtime emitted an invalid response")
134
+ }
135
+ const data = Buffer.from(frame.data, "base64")
136
+ rawBytes += data.length
137
+ if (rawBytes > MAX_RAW_OUTPUT_BYTES) throw new Error("Isolated runtime emitted an invalid response")
138
+ const callback = frame.channel === "stdout" ? options.onStdoutChunk : options.onStderrChunk
139
+ await abortable(callback?.(data), operation.signal)
140
+ return
141
+ }
142
+ if (frame.type === "record") {
143
+ if (Object.keys(frame).sort().join(",") !== "record,type") throw new Error("Isolated runtime emitted an invalid response")
144
+ await abortable(options.onRecord?.(frame.record), operation.signal)
145
+ if (isWorkerEventEnvelope(frame.record)) await abortable(options.onEvent(frame.record.event), operation.signal)
146
+ return
147
+ }
148
+ if (frame.type === "terminal" && Object.keys(frame).sort().join(",") === "exitCode,type" && Number.isSafeInteger(frame.exitCode)) {
149
+ terminal = frame.exitCode
150
+ return
151
+ }
152
+ throw new Error("Isolated runtime emitted an invalid response")
153
+ }
154
+ try {
155
+ while (true) {
156
+ const {done, value} = await abortable(reader.read(), operation.signal)
157
+ if (done) break
158
+ const chunk = Buffer.from(value)
159
+ totalBytes += chunk.length
160
+ if (totalBytes > MAX_RESPONSE_BYTES) {
161
+ await reader.cancel(new Error("Response exceeded capacity")).catch(() => {})
162
+ throw new Error("Isolated runtime response exceeded the configured capacity")
163
+ }
164
+ pending = Buffer.concat([pending, chunk])
165
+ while (true) {
166
+ const newline = pending.indexOf(0x0a)
167
+ if (newline < 0) break
168
+ const line = pending.subarray(0, newline)
169
+ pending = pending.subarray(newline + 1)
170
+ await consume(line)
171
+ }
172
+ }
173
+ if (pending.length !== 0 || terminal === undefined) throw new Error("Isolated runtime emitted an invalid response")
174
+ return terminal
175
+ } finally {
176
+ if (operation.signal.aborted) await reader.cancel(operation.signal.reason).catch(() => {})
177
+ reader.releaseLock()
178
+ }
179
+ }
180
+
181
+ function optionalDuration(value) {
182
+ if (value === undefined || (typeof value === "string" && value.trim().length === 0)) return undefined
111
183
  const number = Number(value)
112
184
  if (!Number.isSafeInteger(number) || number < 1 || number > 604_800_000) throw new Error("Invalid isolated runtime client timeout")
113
185
  return number