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.
- package/CHANGELOG.md +25 -0
- package/README.md +16 -8
- package/TELEGRAM-INGRESS.md +8 -6
- package/docs/container-runtime.md +56 -36
- package/docs/isolated-provider-runtime.md +113 -32
- package/package.json +2 -3
- package/scripts/provider-shims/front-door.sh.template +1 -5
- package/scripts/verify-package.js +2 -3
- package/src/absolute-deadline.js +8 -5
- package/src/activity-log.js +1 -14
- package/src/cli.js +17 -44
- package/src/docker-api.js +100 -14
- package/src/isolated-runtime-client.js +116 -44
- package/src/isolated-runtime.js +673 -64
- package/src/isolated-state.js +62 -11
- package/src/isolated-worker.js +231 -23
- package/src/kimi-model-broker-policy.js +16 -5
- package/src/kimi-model-broker.js +134 -92
- package/src/model-broker-policy.js +11 -8
- package/src/model-broker.js +14 -0
- package/src/providers/index.js +1 -1
- package/src/providers/kimi.js +40 -8
- package/src/run-worker.js +5 -5
- package/src/telegram-ingress/config.js +14 -8
- package/src/telegram-ingress/core.js +32 -28
- package/src/telegram-ingress/http.js +0 -6
- package/src/telegram-webhook.js +13 -2
- package/src/threadwire-binding.js +192 -0
- package/src/workspace-profile.js +0 -189
- package/threadwire.workspace-profiles.json +0 -11
package/src/activity-log.js
CHANGED
|
@@ -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: "
|
|
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
|
|
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,
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
191
|
+
const usesIsolatedRuntime = parsed.relayWrite
|
|
210
192
|
const isolatedRuntimeClient = usesIsolatedRuntime
|
|
211
|
-
?
|
|
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:
|
|
219
|
-
repositoryRoot:
|
|
220
|
-
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
|
-
|
|
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:
|
|
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 =
|
|
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 =
|
|
64
|
-
() => request.destroy(new Error(`Docker API ${method} ${path} timed out`)),
|
|
65
|
-
|
|
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(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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 =
|
|
15
|
+
this.timeoutMs = optionalDuration(options.timeoutMs)
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
/**
|
|
20
|
-
* @param {{provider: string, profile: string, repositoryRoot
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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) =>
|
|
37
|
-
* onRecord?: (record: unknown) =>
|
|
38
|
-
* onStdoutChunk?: (chunk: Buffer) =>
|
|
39
|
-
* onStderrChunk?: (chunk: Buffer) =>
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
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
|
-
|
|
110
|
-
|
|
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
|