threadwire 0.1.6 → 0.1.9
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 +52 -0
- package/README.md +16 -5
- package/TELEGRAM-INGRESS.md +13 -3
- package/bin/isolated-runtime.js +5 -0
- package/bin/kimi-model-broker.js +5 -0
- package/bin/model-broker.js +5 -0
- package/docs/container-runtime.md +22 -0
- package/docs/isolated-provider-runtime.md +232 -0
- package/package.json +10 -3
- package/scripts/provider-shims/front-door.sh.template +18 -0
- package/scripts/verify-package.js +23 -1
- package/src/absolute-deadline.js +94 -0
- package/src/activity-log.js +3 -3
- package/src/cli.js +130 -25
- package/src/delegated-result-admission.js +1 -1
- package/src/docker-api.js +131 -0
- package/src/isolated-runtime-client.js +158 -0
- package/src/isolated-runtime.js +1117 -0
- package/src/isolated-state.js +470 -0
- package/src/isolated-worker.js +144 -0
- package/src/kimi-model-broker-policy.js +209 -0
- package/src/kimi-model-broker.js +325 -0
- package/src/kimi-oauth-store.js +267 -0
- package/src/model-broker-policy.js +139 -0
- package/src/model-broker.js +313 -0
- package/src/mount-policy.js +28 -0
- package/src/normalized-output.js +68 -0
- package/src/providers/index.js +8 -3
- package/src/providers/kimi.js +165 -0
- package/src/relay-write.js +44 -0
- package/src/telegram-ingress/command.js +4 -4
- package/src/telegram-ingress/config.js +18 -0
- package/src/telegram-ingress/core.js +65 -2
- package/src/telegram-ingress/http.js +2 -1
- package/src/telegram-webhook.js +11 -0
- package/src/workspace-profile.js +3 -3
- package/threadwire.workspace-profiles.json +1 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/* eslint-disable jsdoc/require-jsdoc */
|
|
3
|
+
|
|
4
|
+
export class AbsoluteDeadline {
|
|
5
|
+
constructor(expiresAt, {signal, request, response, now = Date.now, timeoutMessage = "Operation timeout", disconnectMessage = "Caller disconnected"} = {}) {
|
|
6
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt <= now()) throw new Error(timeoutMessage)
|
|
7
|
+
this.expiresAt = expiresAt
|
|
8
|
+
this.now = now
|
|
9
|
+
this.controller = new AbortController()
|
|
10
|
+
this.signal = this.controller.signal
|
|
11
|
+
this.parentSignal = signal
|
|
12
|
+
this.request = request
|
|
13
|
+
this.response = response
|
|
14
|
+
this.socket = request?.socket
|
|
15
|
+
this.timeoutMessage = timeoutMessage
|
|
16
|
+
this.abortParent = () => this.controller.abort(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted"))
|
|
17
|
+
this.abortRequest = () => this.controller.abort(new Error(disconnectMessage))
|
|
18
|
+
this.abortResponse = () => {
|
|
19
|
+
if (!response?.writableEnded) this.abortRequest()
|
|
20
|
+
}
|
|
21
|
+
this.abortSocket = () => {
|
|
22
|
+
if (!response?.writableEnded) this.abortRequest()
|
|
23
|
+
}
|
|
24
|
+
if (signal?.aborted) this.abortParent()
|
|
25
|
+
else signal?.addEventListener("abort", this.abortParent, {once: true})
|
|
26
|
+
request?.once?.("aborted", this.abortRequest)
|
|
27
|
+
response?.once?.("close", this.abortResponse)
|
|
28
|
+
this.socket?.once?.("close", this.abortSocket)
|
|
29
|
+
this.timer = setTimeout(() => this.controller.abort(new Error(timeoutMessage)), Math.max(1, expiresAt - now()))
|
|
30
|
+
this.timer.unref()
|
|
31
|
+
this.signal.addEventListener("abort", () => request?.destroy?.(this.signal.reason), {once: true})
|
|
32
|
+
if (request?.aborted || (response?.destroyed && !response.writableEnded)) this.abortRequest()
|
|
33
|
+
}
|
|
34
|
+
remaining() {
|
|
35
|
+
this.throwIfAborted()
|
|
36
|
+
const remaining = this.expiresAt - this.now()
|
|
37
|
+
if (remaining <= 0) {
|
|
38
|
+
this.controller.abort(new Error(this.timeoutMessage))
|
|
39
|
+
this.throwIfAborted()
|
|
40
|
+
}
|
|
41
|
+
return Math.max(1, remaining)
|
|
42
|
+
}
|
|
43
|
+
options() {
|
|
44
|
+
return {signal: this.signal, timeoutMs: this.remaining()}
|
|
45
|
+
}
|
|
46
|
+
throwIfAborted() {
|
|
47
|
+
if (this.signal.aborted) throw this.signal.reason instanceof Error ? this.signal.reason : new Error("Operation aborted")
|
|
48
|
+
}
|
|
49
|
+
close() {
|
|
50
|
+
clearTimeout(this.timer)
|
|
51
|
+
this.abortParent && this.parentSignal?.removeEventListener?.("abort", this.abortParent)
|
|
52
|
+
this.request?.off?.("aborted", this.abortRequest)
|
|
53
|
+
this.response?.off?.("close", this.abortResponse)
|
|
54
|
+
this.socket?.off?.("close", this.abortSocket)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function deadlineAfter(timeoutMs, options = {}) {
|
|
59
|
+
const now = options.now ?? Date.now
|
|
60
|
+
return new AbsoluteDeadline(now() + timeoutMs, {...options, now})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function abortable(promise, signal) {
|
|
64
|
+
if (signal?.aborted) throw signal.reason
|
|
65
|
+
if (!signal) return promise
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const abort = () => reject(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted"))
|
|
68
|
+
signal.addEventListener("abort", abort, {once: true})
|
|
69
|
+
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort))
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function readResponseCapped(response, capacity, signal) {
|
|
74
|
+
const reader = response.body?.getReader()
|
|
75
|
+
if (!reader) return Buffer.alloc(0)
|
|
76
|
+
const chunks = []
|
|
77
|
+
let size = 0
|
|
78
|
+
try {
|
|
79
|
+
while (true) {
|
|
80
|
+
const {done, value} = await abortable(reader.read(), signal)
|
|
81
|
+
if (done) break
|
|
82
|
+
const chunk = Buffer.from(value)
|
|
83
|
+
size += chunk.length
|
|
84
|
+
if (size > capacity) {
|
|
85
|
+
await reader.cancel(new Error("Response exceeded capacity")).catch(() => {})
|
|
86
|
+
throw new Error("Response exceeded capacity")
|
|
87
|
+
}
|
|
88
|
+
chunks.push(chunk)
|
|
89
|
+
}
|
|
90
|
+
return Buffer.concat(chunks, size)
|
|
91
|
+
} finally {
|
|
92
|
+
reader.releaseLock()
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/activity-log.js
CHANGED
|
@@ -24,13 +24,13 @@ export class ActivityLog {
|
|
|
24
24
|
this.write({type: "workspace-selected", profile, repositoryRoot, revision, sourceIdentity})
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/** @param {"codex" | "claude" | "opencode"} provider @param {number} pid */
|
|
27
|
+
/** @param {"codex" | "claude" | "kimi" | "opencode"} provider @param {number} pid */
|
|
28
28
|
recordStarted(provider, pid) {
|
|
29
29
|
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("Provider child PID is unavailable")
|
|
30
30
|
this.write({type: "provider-started", provider, pid})
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/** @param {"codex" | "claude" | "opencode"} provider @param {string} sessionId */
|
|
33
|
+
/** @param {"codex" | "claude" | "kimi" | "opencode"} provider @param {string} sessionId */
|
|
34
34
|
recordSession(provider, sessionId) {
|
|
35
35
|
if (!SESSION_ID_PATTERN.test(sessionId)) return
|
|
36
36
|
this.write({type: "session-available", provider, sessionId})
|
|
@@ -42,7 +42,7 @@ export class ActivityLog {
|
|
|
42
42
|
closeSync(this.fileDescriptor)
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** @param {{type: "workspace-selected", profile: string, repositoryRoot: string, revision: string, sourceIdentity: string} | {type: "provider-started", provider: "codex" | "claude" | "opencode", pid: number} | {type: "session-available", provider: "codex" | "claude" | "opencode", sessionId: string}} fact */
|
|
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 */
|
|
46
46
|
write(fact) {
|
|
47
47
|
if (this.closed) throw new Error("Activity log is closed")
|
|
48
48
|
writeSync(this.fileDescriptor, `${JSON.stringify(fact)}\n`)
|
package/src/cli.js
CHANGED
|
@@ -15,18 +15,25 @@ 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, kimiIsolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
|
|
19
|
+
import {kimiSessionEnvelopeId} from "./providers/kimi.js"
|
|
20
|
+
import {validateRelayWriteProviderArguments} from "./relay-write.js"
|
|
21
|
+
import {abortable} from "./absolute-deadline.js"
|
|
22
|
+
import {NormalizedOutput} from "./normalized-output.js"
|
|
18
23
|
|
|
19
|
-
const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
|
|
24
|
+
const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
|
|
20
25
|
[--process-number <positive-integer>] [--cwd <directory>]
|
|
21
26
|
[--workspace-profile <name>]
|
|
27
|
+
[--relay-write]
|
|
22
28
|
[--tool-messages] [--max-output-length <positive-integer>]
|
|
23
|
-
[--resume-session <provider-session-id>] [--
|
|
29
|
+
[--resume-session <provider-session-id>] [--transcript <normalized-jsonl-path>]
|
|
30
|
+
[--activity-log <local-jsonl-path>]
|
|
24
31
|
[--prompt <text> | --prompt-file <path> | stdin]
|
|
25
32
|
[-- <provider arguments...>]
|
|
26
33
|
threadwire evidence read --handle <opaque-handle>
|
|
27
34
|
(--bytes <offset>:<limit> | --lines <start>:<limit> | --query <literal> --context-bytes <limit>)`
|
|
28
35
|
|
|
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 */
|
|
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 */
|
|
30
37
|
/** @typedef {{evidenceRead: true, request: unknown}} EvidenceParsedArguments */
|
|
31
38
|
/**
|
|
32
39
|
* @typedef {{
|
|
@@ -39,7 +46,9 @@ const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target
|
|
|
39
46
|
* evidenceStore?: EvidenceStore,
|
|
40
47
|
* evidenceOwnerScope?: {destinationId: string, runId: string},
|
|
41
48
|
* workerControlOptions?: Pick<ConstructorParameters<typeof WorkerControl>[0], "queueOptions">,
|
|
42
|
-
* workspaceProfileOperations?: import("./workspace-profile.js").WorkspaceProfileOperations
|
|
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">
|
|
43
52
|
* }} MainDependencies
|
|
44
53
|
*/
|
|
45
54
|
|
|
@@ -52,8 +61,9 @@ export function parseArguments(arguments_) {
|
|
|
52
61
|
const ownArguments = separator < 0 ? arguments_.slice(1) : arguments_.slice(1, separator)
|
|
53
62
|
const providerArguments = separator < 0 ? [] : arguments_.slice(separator + 1)
|
|
54
63
|
/** @type {Partial<ParsedArguments>} */
|
|
55
|
-
const parsed = {providerArguments, cwd: process.cwd(), toolMessages: false}
|
|
64
|
+
const parsed = {providerArguments, cwd: process.cwd(), toolMessages: false, relayWrite: false}
|
|
56
65
|
let cwdProvided = false
|
|
66
|
+
let transcriptProvided = false
|
|
57
67
|
for (let index = 0; index < ownArguments.length;) {
|
|
58
68
|
const option = ownArguments[index]
|
|
59
69
|
if (option === "--tool-messages") {
|
|
@@ -61,7 +71,15 @@ export function parseArguments(arguments_) {
|
|
|
61
71
|
index += 1
|
|
62
72
|
continue
|
|
63
73
|
}
|
|
74
|
+
if (option === "--relay-write") {
|
|
75
|
+
parsed.relayWrite = true
|
|
76
|
+
index += 1
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
64
79
|
const value = ownArguments[index + 1]
|
|
80
|
+
if (option === "--transcript" && (value === undefined || value.startsWith("--"))) {
|
|
81
|
+
throw new Error("Missing value for --transcript")
|
|
82
|
+
}
|
|
65
83
|
if (!option || !value) throw new Error(`Missing value for ${option ?? "option"}`)
|
|
66
84
|
if (option === "--provider") parsed.provider = value
|
|
67
85
|
else if (option === "--target") parsed.target = value
|
|
@@ -75,7 +93,12 @@ export function parseArguments(arguments_) {
|
|
|
75
93
|
else if (option === "--max-output-length") parsed.maxOutputLength = positiveInteger(value, "--max-output-length")
|
|
76
94
|
else if (option === "--resume-session") parsed.resumeSession = sessionId(value)
|
|
77
95
|
else if (option === "--activity-log") parsed.activityLog = resolve(value)
|
|
78
|
-
else
|
|
96
|
+
else if (option === "--transcript") {
|
|
97
|
+
if (transcriptProvided) throw new Error("Duplicate option: --transcript")
|
|
98
|
+
if (value.includes("\0")) throw new Error("--transcript path is invalid")
|
|
99
|
+
parsed.transcript = resolve(value)
|
|
100
|
+
transcriptProvided = true
|
|
101
|
+
} else throw new Error(`Unknown option: ${option}`)
|
|
79
102
|
index += 2
|
|
80
103
|
}
|
|
81
104
|
if (!parsed.provider) throw new Error("--provider is required")
|
|
@@ -87,6 +110,12 @@ export function parseArguments(arguments_) {
|
|
|
87
110
|
if (parsed.workspaceProfile !== undefined && cwdProvided) {
|
|
88
111
|
throw new Error("--cwd and --workspace-profile are mutually exclusive")
|
|
89
112
|
}
|
|
113
|
+
if (parsed.transcript !== undefined && parsed.transcript === parsed.activityLog) {
|
|
114
|
+
throw new Error("--transcript and --activity-log must resolve to different paths")
|
|
115
|
+
}
|
|
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
|
+
if (parsed.relayWrite && parsed.provider !== "codex") throw new Error("--relay-write is only supported for codex")
|
|
90
119
|
return /** @type {ParsedArguments} */ (parsed)
|
|
91
120
|
}
|
|
92
121
|
|
|
@@ -140,6 +169,10 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
140
169
|
const sourceEnvironment = dependencies.env ?? process.env
|
|
141
170
|
let environment = sourceEnvironment
|
|
142
171
|
const validateOnly = environment.THREADWIRE_VALIDATE_ONLY === "1"
|
|
172
|
+
/** @type {NormalizedOutput | undefined} */
|
|
173
|
+
let normalizedOutput
|
|
174
|
+
/** @type {DelegatedResultAdmission | undefined} */
|
|
175
|
+
let runAdmission
|
|
143
176
|
try {
|
|
144
177
|
const parsed = arguments_[0] === "evidence" ? parseEvidenceArguments(arguments_) : parseArguments(arguments_)
|
|
145
178
|
if ("help" in parsed) {
|
|
@@ -159,16 +192,37 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
159
192
|
await store.close()
|
|
160
193
|
}
|
|
161
194
|
}
|
|
195
|
+
if (!validateOnly) {
|
|
196
|
+
normalizedOutput = new NormalizedOutput(output, parsed.transcript)
|
|
197
|
+
}
|
|
198
|
+
const metrics = new ContextBudgetMetrics()
|
|
199
|
+
runAdmission = normalizedOutput === undefined ? undefined : new DelegatedResultAdmission({output: normalizedOutput, metrics})
|
|
162
200
|
const target = parseTelegramTarget(parsed.target)
|
|
201
|
+
if (parsed.relayWrite) validateRelayWriteProviderArguments(parsed.providerArguments)
|
|
163
202
|
const resolvedWorkspace = parsed.workspaceProfile === undefined
|
|
164
203
|
? undefined
|
|
165
204
|
: await resolveWorkspaceProfile(
|
|
166
|
-
{provider: /** @type {"codex" | "claude" | "opencode"} */ (parsed.provider), profile: parsed.workspaceProfile},
|
|
205
|
+
{provider: /** @type {"codex" | "claude" | "kimi" | "opencode"} */ (parsed.provider), profile: parsed.workspaceProfile},
|
|
167
206
|
dependencies.workspaceProfileOperations
|
|
168
207
|
)
|
|
169
208
|
if (validateOnly) return 0
|
|
170
|
-
const
|
|
171
|
-
const
|
|
209
|
+
const usesIsolatedRuntime = parsed.relayWrite || parsed.provider === "kimi"
|
|
210
|
+
const isolatedRuntimeClient = usesIsolatedRuntime
|
|
211
|
+
? parsed.provider === "kimi"
|
|
212
|
+
? dependencies.kimiIsolatedRuntimeClient ?? kimiIsolatedRuntimeClientFromEnvironment(sourceEnvironment)
|
|
213
|
+
: dependencies.isolatedRuntimeClient ?? isolatedRuntimeClientFromEnvironment(sourceEnvironment)
|
|
214
|
+
: undefined
|
|
215
|
+
const isolatedPreflight = usesIsolatedRuntime
|
|
216
|
+
? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).preflight({
|
|
217
|
+
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,
|
|
221
|
+
providerArguments: parsed.providerArguments,
|
|
222
|
+
...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession})
|
|
223
|
+
})
|
|
224
|
+
: undefined
|
|
225
|
+
const admission = /** @type {DelegatedResultAdmission} */ (runAdmission)
|
|
172
226
|
let terminalExitCode = 2
|
|
173
227
|
/** @type {unknown} */
|
|
174
228
|
let evidenceError
|
|
@@ -179,12 +233,13 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
179
233
|
/** @type {EvidenceStore | undefined} */
|
|
180
234
|
let ownedEvidenceStore
|
|
181
235
|
let evidencePayloadBytes = 0
|
|
236
|
+
const launchDeadline = isolatedPreflight?.deadline
|
|
182
237
|
try {
|
|
183
|
-
environment = await resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"])
|
|
184
|
-
const prompt = await readPrompt(parsed, dependencies.input ?? stdin)
|
|
238
|
+
environment = await boundedLaunch(resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"]), launchDeadline)
|
|
239
|
+
const prompt = await readPrompt(parsed, dependencies.input ?? stdin, launchDeadline?.signal)
|
|
185
240
|
const configuredEvidenceRoot = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
|
|
186
241
|
if (dependencies.evidenceStore === undefined && configuredEvidenceRoot !== undefined) {
|
|
187
|
-
ownedEvidenceStore = await EvidenceStore.open({root: configuredEvidenceRoot})
|
|
242
|
+
ownedEvidenceStore = await boundedLaunch(EvidenceStore.open({root: configuredEvidenceRoot}), launchDeadline)
|
|
188
243
|
}
|
|
189
244
|
const evidenceStore = dependencies.evidenceStore ?? ownedEvidenceStore
|
|
190
245
|
if (evidenceStore !== undefined) {
|
|
@@ -194,16 +249,22 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
194
249
|
})
|
|
195
250
|
evidence = await evidenceStore.createArtifact(evidenceOwner, {
|
|
196
251
|
contentType: "text/plain; charset=utf-8",
|
|
197
|
-
redactions: await collectEvidenceRedactions(environment)
|
|
252
|
+
redactions: await boundedLaunch(collectEvidenceRedactions(environment), launchDeadline)
|
|
198
253
|
})
|
|
199
254
|
}
|
|
200
255
|
if (evidence !== undefined) {
|
|
201
256
|
const promptEvidence = `prompt\n${prompt}\nprovider-stream\n`
|
|
202
|
-
await evidence.append("prompt", promptEvidence)
|
|
257
|
+
await boundedLaunch(evidence.append("prompt", promptEvidence), launchDeadline)
|
|
203
258
|
evidencePayloadBytes += Buffer.byteLength(promptEvidence, "utf8")
|
|
204
259
|
}
|
|
205
260
|
const providerEnvironment = buildProviderEnvironment(environment)
|
|
206
|
-
const provider = createProvider(
|
|
261
|
+
const provider = createProvider(
|
|
262
|
+
parsed.provider,
|
|
263
|
+
parsed.providerArguments,
|
|
264
|
+
prompt,
|
|
265
|
+
parsed.resumeSession,
|
|
266
|
+
usesIsolatedRuntime ? {} : providerEnvironment
|
|
267
|
+
)
|
|
207
268
|
if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
|
|
208
269
|
if (parsed.workspaceProfile !== undefined) {
|
|
209
270
|
if (resolvedWorkspace === undefined) throw new Error("Workspace profile resolution failed")
|
|
@@ -228,7 +289,8 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
228
289
|
resolvedWorkspace.sourceIdentity
|
|
229
290
|
)
|
|
230
291
|
}
|
|
231
|
-
|
|
292
|
+
/** @type {import("./run-worker.js").RunWorkerOptions} */
|
|
293
|
+
const workerOptions = {
|
|
232
294
|
executable: provider.executable,
|
|
233
295
|
arguments: provider.arguments,
|
|
234
296
|
cwd: resolvedWorkspace?.cwd ?? parsed.cwd,
|
|
@@ -247,7 +309,7 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
247
309
|
},
|
|
248
310
|
onRecord: (record) => {
|
|
249
311
|
metrics.recordParsedProviderRecord("provider_stdout")
|
|
250
|
-
const id = provider.sessionId(record)
|
|
312
|
+
const id = parsed.provider === "kimi" ? kimiSessionEnvelopeId(record) : provider.sessionId(record)
|
|
251
313
|
if (id !== undefined) {
|
|
252
314
|
admission.setContinuationHandle(id)
|
|
253
315
|
activity?.recordSession(provider.name, id)
|
|
@@ -265,8 +327,21 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
265
327
|
if (evidence === undefined) return undefined
|
|
266
328
|
return evidence.append("provider-stderr", chunk).then(() => { evidencePayloadBytes += chunk.length })
|
|
267
329
|
}
|
|
268
|
-
}
|
|
269
|
-
|
|
330
|
+
}
|
|
331
|
+
const exitCode = usesIsolatedRuntime
|
|
332
|
+
? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).run({
|
|
333
|
+
preflightId: /** @type {{preflightId: string}} */ (isolatedPreflight).preflightId,
|
|
334
|
+
prompt,
|
|
335
|
+
providerArguments: parsed.providerArguments,
|
|
336
|
+
...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession}),
|
|
337
|
+
onEvent: workerOptions.onEvent,
|
|
338
|
+
onRecord: /** @type {NonNullable<typeof workerOptions.onRecord>} */ (workerOptions.onRecord),
|
|
339
|
+
...(parsed.provider === "kimi" || workerOptions.onStdoutChunk === undefined ? {} : {onStdoutChunk: workerOptions.onStdoutChunk}),
|
|
340
|
+
...(parsed.provider === "kimi" || workerOptions.onStderrChunk === undefined ? {} : {onStderrChunk: workerOptions.onStderrChunk}),
|
|
341
|
+
...(launchDeadline === undefined ? {} : {deadline: launchDeadline})
|
|
342
|
+
})
|
|
343
|
+
: await (dependencies.workerRunner ?? runWorker)(workerOptions)
|
|
344
|
+
await boundedLaunch(control.close(), launchDeadline)
|
|
270
345
|
terminalExitCode = exitCode
|
|
271
346
|
} finally {
|
|
272
347
|
if (evidence !== undefined) {
|
|
@@ -289,13 +364,28 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
289
364
|
admission.complete({state: terminalExitCode === 0 ? "completed" : "failed", exitCode: terminalExitCode})
|
|
290
365
|
errorOutput.write(`threadwire-context-metrics ${JSON.stringify(metrics.conciseDiagnostic())}\n`)
|
|
291
366
|
activity?.close()
|
|
292
|
-
await ownedEvidenceStore?.close()
|
|
367
|
+
await boundedLaunch(ownedEvidenceStore?.close(), launchDeadline).catch(() => {})
|
|
368
|
+
launchDeadline?.close()
|
|
293
369
|
}
|
|
294
370
|
if (evidenceError !== undefined) throw evidenceError
|
|
295
371
|
return terminalExitCode
|
|
296
372
|
} catch (error) {
|
|
297
|
-
|
|
373
|
+
let reportedError = error
|
|
374
|
+
if (runAdmission !== undefined && !runAdmission.completed) {
|
|
375
|
+
try {
|
|
376
|
+
runAdmission.complete({state: "failed", exitCode: 2})
|
|
377
|
+
} catch (transcriptError) {
|
|
378
|
+
reportedError = transcriptError
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
errorOutput.write(`threadwire: ${safeMessage(reportedError)}\n`)
|
|
298
382
|
return 2
|
|
383
|
+
} finally {
|
|
384
|
+
try {
|
|
385
|
+
normalizedOutput?.close()
|
|
386
|
+
} catch (error) {
|
|
387
|
+
errorOutput.write(`threadwire: ${safeMessage(error)}\n`)
|
|
388
|
+
}
|
|
299
389
|
}
|
|
300
390
|
}
|
|
301
391
|
|
|
@@ -350,21 +440,36 @@ function validateNormalizedWorkerEvent(event) {
|
|
|
350
440
|
throw new Error("Worker emitted an unknown normalized event kind")
|
|
351
441
|
}
|
|
352
442
|
|
|
353
|
-
/** @param {ParsedArguments} parsed @param {NodeJS.ReadableStream & {isTTY?: boolean}} input */
|
|
354
|
-
async function readPrompt(parsed, input) {
|
|
443
|
+
/** @param {ParsedArguments} parsed @param {NodeJS.ReadableStream & {isTTY?: boolean}} input @param {AbortSignal | undefined} signal */
|
|
444
|
+
async function readPrompt(parsed, input, signal) {
|
|
355
445
|
let prompt
|
|
356
446
|
if (parsed.prompt !== undefined) prompt = parsed.prompt
|
|
357
|
-
else if (parsed.promptFile !== undefined) prompt = await readFile(parsed.promptFile, "utf8")
|
|
447
|
+
else if (parsed.promptFile !== undefined) prompt = await readFile(parsed.promptFile, {encoding: "utf8", signal})
|
|
358
448
|
else {
|
|
359
449
|
if (input.isTTY) throw new Error("A prompt is required via --prompt, --prompt-file, or piped stdin")
|
|
360
450
|
prompt = ""
|
|
361
451
|
input.setEncoding("utf8")
|
|
362
|
-
|
|
452
|
+
const abort = () => /** @type {NodeJS.ReadableStream & {destroy?: (error?: unknown) => void}} */ (input).destroy?.(signal?.reason)
|
|
453
|
+
signal?.addEventListener("abort", abort, {once: true})
|
|
454
|
+
try {
|
|
455
|
+
for await (const chunk of input) {
|
|
456
|
+
if (signal?.aborted) throw signal.reason
|
|
457
|
+
prompt += chunk
|
|
458
|
+
}
|
|
459
|
+
} finally {
|
|
460
|
+
signal?.removeEventListener("abort", abort)
|
|
461
|
+
}
|
|
363
462
|
}
|
|
364
463
|
if (prompt.trim().length === 0) throw new Error("Prompt cannot be empty")
|
|
365
464
|
return prompt
|
|
366
465
|
}
|
|
367
466
|
|
|
467
|
+
/** @param {Promise<unknown> | undefined} promise @param {import("./absolute-deadline.js").AbsoluteDeadline | undefined} deadline */
|
|
468
|
+
function boundedLaunch(promise, deadline) {
|
|
469
|
+
deadline?.throwIfAborted()
|
|
470
|
+
return abortable(Promise.resolve(promise), deadline?.signal)
|
|
471
|
+
}
|
|
472
|
+
|
|
368
473
|
/** @param {unknown} error */
|
|
369
474
|
function safeMessage(error) {
|
|
370
475
|
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:
|
|
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
|
+
}
|