threadwire 0.1.5 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +17 -4
- package/TELEGRAM-INGRESS.md +8 -2
- package/bin/isolated-runtime.js +5 -0
- package/bin/model-broker.js +5 -0
- package/docs/card-10520-plan.md +45 -0
- package/docs/container-runtime.md +3 -2
- package/docs/delegated-result-protocol.md +161 -0
- package/docs/evidence-artifacts.md +106 -0
- package/docs/isolated-provider-runtime.md +137 -0
- package/package.json +5 -1
- package/scripts/provider-shims/front-door.sh.template +18 -0
- package/scripts/verify-package.js +22 -1
- package/src/absolute-deadline.js +94 -0
- package/src/cli.js +362 -42
- package/src/context-budget-metrics.js +180 -0
- package/src/delegated-result-admission.js +377 -0
- package/src/docker-api.js +131 -0
- package/src/evidence-store.js +1472 -0
- package/src/isolated-runtime-client.js +149 -0
- package/src/isolated-runtime.js +982 -0
- package/src/isolated-state.js +409 -0
- package/src/isolated-worker.js +123 -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/notice-queue.js +22 -7
- package/src/providers/opencode.js +42 -18
- package/src/relay-write.js +44 -0
- package/src/relay.js +6 -6
- package/src/run-worker.js +158 -40
- package/src/telegram-ingress/command.js +16 -0
- package/src/telegram-ingress/config.js +96 -11
- package/src/telegram-ingress/core.js +201 -89
- package/src/telegram-ingress/http.js +17 -1
- package/src/telegram-webhook.js +22 -0
- package/src/types.js +2 -2
- package/src/worker-control.js +294 -0
- package/src/hermes-protocol.js +0 -126
package/src/cli.js
CHANGED
|
@@ -1,28 +1,39 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import {readFile} from "node:fs/promises"
|
|
4
|
-
import {
|
|
4
|
+
import {randomUUID} from "node:crypto"
|
|
5
|
+
import {isAbsolute, normalize, resolve} from "node:path"
|
|
5
6
|
import {stdin, stderr, stdout} from "node:process"
|
|
6
7
|
import {createFetchTransport} from "./notifiers/fetch-transport.js"
|
|
7
8
|
import {createTelegramSender, parseTelegramTarget} from "./notifiers/telegram.js"
|
|
8
|
-
import {NoticeQueue} from "./notice-queue.js"
|
|
9
9
|
import {createProvider, PROVIDERS} from "./providers/index.js"
|
|
10
|
-
import {Relay} from "./relay.js"
|
|
11
10
|
import {runWorker} from "./run-worker.js"
|
|
12
11
|
import {ActivityLog} from "./activity-log.js"
|
|
13
|
-
import {
|
|
14
|
-
import {buildProviderEnvironment, parseTelegramRequestTimeoutMs, resolveFileBackedSettings} from "./telegram-ingress/config.js"
|
|
12
|
+
import {DelegatedResultAdmission, validateContinuationHandle} from "./delegated-result-admission.js"
|
|
13
|
+
import {buildProviderEnvironment, collectEvidenceRedactions, parseTelegramRequestTimeoutMs, resolveFileBackedSettings} from "./telegram-ingress/config.js"
|
|
15
14
|
import {resolveWorkspaceProfile} from "./workspace-profile.js"
|
|
15
|
+
import {WorkerControl} from "./worker-control.js"
|
|
16
|
+
import {EvidenceStore} from "./evidence-store.js"
|
|
17
|
+
import {ContextBudgetMetrics} from "./context-budget-metrics.js"
|
|
18
|
+
import {isolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
|
|
19
|
+
import {validateRelayWriteProviderArguments} from "./relay-write.js"
|
|
20
|
+
import {abortable} from "./absolute-deadline.js"
|
|
21
|
+
import {NormalizedOutput} from "./normalized-output.js"
|
|
16
22
|
|
|
17
23
|
const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
|
|
18
24
|
[--process-number <positive-integer>] [--cwd <directory>]
|
|
19
25
|
[--workspace-profile <name>]
|
|
26
|
+
[--relay-write]
|
|
20
27
|
[--tool-messages] [--max-output-length <positive-integer>]
|
|
21
|
-
[--resume-session <provider-session-id>] [--
|
|
28
|
+
[--resume-session <provider-session-id>] [--transcript <normalized-jsonl-path>]
|
|
29
|
+
[--activity-log <local-jsonl-path>]
|
|
22
30
|
[--prompt <text> | --prompt-file <path> | stdin]
|
|
23
|
-
[-- <provider arguments...>]
|
|
31
|
+
[-- <provider arguments...>]
|
|
32
|
+
threadwire evidence read --handle <opaque-handle>
|
|
33
|
+
(--bytes <offset>:<limit> | --lines <start>:<limit> | --query <literal> --context-bytes <limit>)`
|
|
24
34
|
|
|
25
|
-
/** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, workspaceProfile?: string, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
|
|
35
|
+
/** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, relayWrite: boolean, workspaceProfile?: string, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, transcript?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
|
|
36
|
+
/** @typedef {{evidenceRead: true, request: unknown}} EvidenceParsedArguments */
|
|
26
37
|
/**
|
|
27
38
|
* @typedef {{
|
|
28
39
|
* env?: NodeJS.ProcessEnv,
|
|
@@ -31,7 +42,11 @@ const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target
|
|
|
31
42
|
* errorOutput?: NodeJS.WritableStream,
|
|
32
43
|
* transportFactory?: typeof createFetchTransport,
|
|
33
44
|
* workerRunner?: typeof runWorker,
|
|
45
|
+
* evidenceStore?: EvidenceStore,
|
|
46
|
+
* evidenceOwnerScope?: {destinationId: string, runId: string},
|
|
47
|
+
* workerControlOptions?: Pick<ConstructorParameters<typeof WorkerControl>[0], "queueOptions">,
|
|
34
48
|
* workspaceProfileOperations?: import("./workspace-profile.js").WorkspaceProfileOperations
|
|
49
|
+
* isolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">
|
|
35
50
|
* }} MainDependencies
|
|
36
51
|
*/
|
|
37
52
|
|
|
@@ -44,8 +59,9 @@ export function parseArguments(arguments_) {
|
|
|
44
59
|
const ownArguments = separator < 0 ? arguments_.slice(1) : arguments_.slice(1, separator)
|
|
45
60
|
const providerArguments = separator < 0 ? [] : arguments_.slice(separator + 1)
|
|
46
61
|
/** @type {Partial<ParsedArguments>} */
|
|
47
|
-
const parsed = {providerArguments, cwd: process.cwd(), toolMessages: false}
|
|
62
|
+
const parsed = {providerArguments, cwd: process.cwd(), toolMessages: false, relayWrite: false}
|
|
48
63
|
let cwdProvided = false
|
|
64
|
+
let transcriptProvided = false
|
|
49
65
|
for (let index = 0; index < ownArguments.length;) {
|
|
50
66
|
const option = ownArguments[index]
|
|
51
67
|
if (option === "--tool-messages") {
|
|
@@ -53,7 +69,15 @@ export function parseArguments(arguments_) {
|
|
|
53
69
|
index += 1
|
|
54
70
|
continue
|
|
55
71
|
}
|
|
72
|
+
if (option === "--relay-write") {
|
|
73
|
+
parsed.relayWrite = true
|
|
74
|
+
index += 1
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
56
77
|
const value = ownArguments[index + 1]
|
|
78
|
+
if (option === "--transcript" && (value === undefined || value.startsWith("--"))) {
|
|
79
|
+
throw new Error("Missing value for --transcript")
|
|
80
|
+
}
|
|
57
81
|
if (!option || !value) throw new Error(`Missing value for ${option ?? "option"}`)
|
|
58
82
|
if (option === "--provider") parsed.provider = value
|
|
59
83
|
else if (option === "--target") parsed.target = value
|
|
@@ -67,7 +91,12 @@ export function parseArguments(arguments_) {
|
|
|
67
91
|
else if (option === "--max-output-length") parsed.maxOutputLength = positiveInteger(value, "--max-output-length")
|
|
68
92
|
else if (option === "--resume-session") parsed.resumeSession = sessionId(value)
|
|
69
93
|
else if (option === "--activity-log") parsed.activityLog = resolve(value)
|
|
70
|
-
else
|
|
94
|
+
else if (option === "--transcript") {
|
|
95
|
+
if (transcriptProvided) throw new Error("Duplicate option: --transcript")
|
|
96
|
+
if (value.includes("\0")) throw new Error("--transcript path is invalid")
|
|
97
|
+
parsed.transcript = resolve(value)
|
|
98
|
+
transcriptProvided = true
|
|
99
|
+
} else throw new Error(`Unknown option: ${option}`)
|
|
71
100
|
index += 2
|
|
72
101
|
}
|
|
73
102
|
if (!parsed.provider) throw new Error("--provider is required")
|
|
@@ -79,9 +108,54 @@ export function parseArguments(arguments_) {
|
|
|
79
108
|
if (parsed.workspaceProfile !== undefined && cwdProvided) {
|
|
80
109
|
throw new Error("--cwd and --workspace-profile are mutually exclusive")
|
|
81
110
|
}
|
|
111
|
+
if (parsed.transcript !== undefined && parsed.transcript === parsed.activityLog) {
|
|
112
|
+
throw new Error("--transcript and --activity-log must resolve to different paths")
|
|
113
|
+
}
|
|
114
|
+
if (parsed.relayWrite && parsed.workspaceProfile === undefined) throw new Error("--relay-write requires --workspace-profile")
|
|
115
|
+
if (parsed.relayWrite && parsed.provider !== "codex") throw new Error("--relay-write is only supported for codex")
|
|
82
116
|
return /** @type {ParsedArguments} */ (parsed)
|
|
83
117
|
}
|
|
84
118
|
|
|
119
|
+
/** @param {string[]} arguments_ @returns {EvidenceParsedArguments} */
|
|
120
|
+
function parseEvidenceArguments(arguments_) {
|
|
121
|
+
if (arguments_[1] !== "read") throw new Error(HELP)
|
|
122
|
+
/** @type {Record<string, string>} */
|
|
123
|
+
const options = {}
|
|
124
|
+
for (let index = 2; index < arguments_.length; index += 2) {
|
|
125
|
+
const name = arguments_[index]
|
|
126
|
+
const value = arguments_[index + 1]
|
|
127
|
+
if (!name || !value || !["--handle", "--bytes", "--lines", "--query", "--context-bytes"].includes(name)) throw new Error(HELP)
|
|
128
|
+
if (options[name] !== undefined) throw new Error(`Duplicate evidence option: ${name}`)
|
|
129
|
+
options[name] = value
|
|
130
|
+
}
|
|
131
|
+
const handle = options["--handle"]
|
|
132
|
+
if (!handle) throw new Error("--handle is required")
|
|
133
|
+
const selectors = ["--bytes", "--lines", "--query"].filter((name) => options[name] !== undefined)
|
|
134
|
+
if (selectors.length !== 1) throw new Error("Exactly one bounded evidence selector is required")
|
|
135
|
+
let request
|
|
136
|
+
if (options["--bytes"]) request = {handle, bytes: rangePair(options["--bytes"], true)}
|
|
137
|
+
else if (options["--lines"]) request = {handle, lines: rangePair(options["--lines"], false)}
|
|
138
|
+
else {
|
|
139
|
+
const context = options["--context-bytes"]
|
|
140
|
+
if (!context) throw new Error("--context-bytes is required with --query")
|
|
141
|
+
request = {handle, query: {literal: options["--query"], limit: 1, contextBytes: positiveInteger(context, "--context-bytes")}}
|
|
142
|
+
}
|
|
143
|
+
if (options["--query"] === undefined && options["--context-bytes"] !== undefined) throw new Error("--context-bytes requires --query")
|
|
144
|
+
return {evidenceRead: true, request}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @param {string} value @param {boolean} zeroStart */
|
|
148
|
+
function rangePair(value, zeroStart) {
|
|
149
|
+
const match = /^(\d+):(\d+)$/u.exec(value)
|
|
150
|
+
if (!match) throw new Error("Evidence range must be <start>:<limit>")
|
|
151
|
+
const start = Number(match[1])
|
|
152
|
+
const limit = Number(match[2])
|
|
153
|
+
if (!Number.isSafeInteger(start) || (!zeroStart && start < 1) || (zeroStart && start < 0) || !Number.isSafeInteger(limit) || limit < 1) {
|
|
154
|
+
throw new Error("Evidence range must contain bounded safe integers")
|
|
155
|
+
}
|
|
156
|
+
return zeroStart ? {offset: start, limit} : {start, limit}
|
|
157
|
+
}
|
|
158
|
+
|
|
85
159
|
/**
|
|
86
160
|
* @param {string[]} arguments_
|
|
87
161
|
* @param {MainDependencies} [dependencies]
|
|
@@ -92,13 +166,36 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
92
166
|
const sourceEnvironment = dependencies.env ?? process.env
|
|
93
167
|
let environment = sourceEnvironment
|
|
94
168
|
const validateOnly = environment.THREADWIRE_VALIDATE_ONLY === "1"
|
|
169
|
+
/** @type {NormalizedOutput | undefined} */
|
|
170
|
+
let normalizedOutput
|
|
171
|
+
/** @type {DelegatedResultAdmission | undefined} */
|
|
172
|
+
let runAdmission
|
|
95
173
|
try {
|
|
96
|
-
const parsed = parseArguments(arguments_)
|
|
174
|
+
const parsed = arguments_[0] === "evidence" ? parseEvidenceArguments(arguments_) : parseArguments(arguments_)
|
|
97
175
|
if ("help" in parsed) {
|
|
98
176
|
if (!validateOnly) output.write(`${HELP}\n`)
|
|
99
177
|
return 0
|
|
100
178
|
}
|
|
179
|
+
if ("evidenceRead" in parsed) {
|
|
180
|
+
const root = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
|
|
181
|
+
if (!root) throw new Error("THREADWIRE_EVIDENCE_ROOT is required")
|
|
182
|
+
if (validateOnly) return 0
|
|
183
|
+
const store = await EvidenceStore.open({root})
|
|
184
|
+
try {
|
|
185
|
+
const result = await store.readBearer(parsed.request)
|
|
186
|
+
output.write(`${JSON.stringify(result)}\n`)
|
|
187
|
+
return 0
|
|
188
|
+
} finally {
|
|
189
|
+
await store.close()
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (!validateOnly) {
|
|
193
|
+
normalizedOutput = new NormalizedOutput(output, parsed.transcript)
|
|
194
|
+
}
|
|
195
|
+
const metrics = new ContextBudgetMetrics()
|
|
196
|
+
runAdmission = normalizedOutput === undefined ? undefined : new DelegatedResultAdmission({output: normalizedOutput, metrics})
|
|
101
197
|
const target = parseTelegramTarget(parsed.target)
|
|
198
|
+
if (parsed.relayWrite) validateRelayWriteProviderArguments(parsed.providerArguments)
|
|
102
199
|
const resolvedWorkspace = parsed.workspaceProfile === undefined
|
|
103
200
|
? undefined
|
|
104
201
|
: await resolveWorkspaceProfile(
|
|
@@ -106,35 +203,76 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
106
203
|
dependencies.workspaceProfileOperations
|
|
107
204
|
)
|
|
108
205
|
if (validateOnly) return 0
|
|
109
|
-
|
|
110
|
-
|
|
206
|
+
const isolatedRuntimeClient = parsed.relayWrite
|
|
207
|
+
? dependencies.isolatedRuntimeClient ?? isolatedRuntimeClientFromEnvironment(sourceEnvironment)
|
|
208
|
+
: undefined
|
|
209
|
+
const isolatedPreflight = parsed.relayWrite
|
|
210
|
+
? await isolatedRuntimeClient?.preflight({
|
|
211
|
+
provider: parsed.provider,
|
|
212
|
+
profile: /** @type {string} */ (parsed.workspaceProfile),
|
|
213
|
+
repositoryRoot: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).repositoryRoot,
|
|
214
|
+
cwd: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).cwd,
|
|
215
|
+
providerArguments: parsed.providerArguments,
|
|
216
|
+
...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession})
|
|
217
|
+
})
|
|
218
|
+
: undefined
|
|
219
|
+
const admission = /** @type {DelegatedResultAdmission} */ (runAdmission)
|
|
111
220
|
let terminalExitCode = 2
|
|
221
|
+
/** @type {unknown} */
|
|
222
|
+
let evidenceError
|
|
112
223
|
/** @type {ActivityLog | undefined} */
|
|
113
224
|
let activity
|
|
225
|
+
/** @type {Awaited<ReturnType<EvidenceStore["createArtifact"]>> | undefined} */
|
|
226
|
+
let evidence
|
|
227
|
+
/** @type {EvidenceStore | undefined} */
|
|
228
|
+
let ownedEvidenceStore
|
|
229
|
+
let evidencePayloadBytes = 0
|
|
230
|
+
const launchDeadline = isolatedPreflight?.deadline
|
|
114
231
|
try {
|
|
115
|
-
|
|
232
|
+
environment = await boundedLaunch(resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"]), launchDeadline)
|
|
233
|
+
const prompt = await readPrompt(parsed, dependencies.input ?? stdin, launchDeadline?.signal)
|
|
234
|
+
const configuredEvidenceRoot = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
|
|
235
|
+
if (dependencies.evidenceStore === undefined && configuredEvidenceRoot !== undefined) {
|
|
236
|
+
ownedEvidenceStore = await boundedLaunch(EvidenceStore.open({root: configuredEvidenceRoot}), launchDeadline)
|
|
237
|
+
}
|
|
238
|
+
const evidenceStore = dependencies.evidenceStore ?? ownedEvidenceStore
|
|
239
|
+
if (evidenceStore !== undefined) {
|
|
240
|
+
const evidenceOwner = dependencies.evidenceOwnerScope ?? evidenceStore.createOwnerScope({
|
|
241
|
+
destinationId: `${target.chatId}:${target.threadId ?? "dm"}`,
|
|
242
|
+
runId: randomUUID()
|
|
243
|
+
})
|
|
244
|
+
evidence = await evidenceStore.createArtifact(evidenceOwner, {
|
|
245
|
+
contentType: "text/plain; charset=utf-8",
|
|
246
|
+
redactions: await boundedLaunch(collectEvidenceRedactions(environment), launchDeadline)
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
if (evidence !== undefined) {
|
|
250
|
+
const promptEvidence = `prompt\n${prompt}\nprovider-stream\n`
|
|
251
|
+
await boundedLaunch(evidence.append("prompt", promptEvidence), launchDeadline)
|
|
252
|
+
evidencePayloadBytes += Buffer.byteLength(promptEvidence, "utf8")
|
|
253
|
+
}
|
|
116
254
|
const providerEnvironment = buildProviderEnvironment(environment)
|
|
117
|
-
const provider = createProvider(
|
|
255
|
+
const provider = createProvider(
|
|
256
|
+
parsed.provider,
|
|
257
|
+
parsed.providerArguments,
|
|
258
|
+
prompt,
|
|
259
|
+
parsed.resumeSession,
|
|
260
|
+
parsed.relayWrite ? {} : providerEnvironment
|
|
261
|
+
)
|
|
262
|
+
if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
|
|
118
263
|
if (parsed.workspaceProfile !== undefined) {
|
|
119
264
|
if (resolvedWorkspace === undefined) throw new Error("Workspace profile resolution failed")
|
|
120
|
-
hermes.write({
|
|
121
|
-
type: "execution_environment",
|
|
122
|
-
profile: resolvedWorkspace.profile,
|
|
123
|
-
environment: "container",
|
|
124
|
-
repositoryPath: resolvedWorkspace.cwd,
|
|
125
|
-
revision: resolvedWorkspace.revision,
|
|
126
|
-
sourceIdentity: resolvedWorkspace.sourceIdentity
|
|
127
|
-
})
|
|
128
265
|
}
|
|
129
266
|
const token = environment.THREADWIRE_TELEGRAM_BOT_TOKEN
|
|
130
267
|
if (!token) throw new Error("THREADWIRE_TELEGRAM_BOT_TOKEN is required")
|
|
131
268
|
const transport = (dependencies.transportFactory ?? createFetchTransport)(token, undefined, parseTelegramRequestTimeoutMs(environment))
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
queue,
|
|
269
|
+
const control = new WorkerControl({
|
|
270
|
+
sender: createTelegramSender(target, transport),
|
|
135
271
|
processNumber: parsed.processNumber ?? process.pid,
|
|
136
272
|
toolMessages: parsed.toolMessages,
|
|
137
|
-
...(
|
|
273
|
+
...(dependencies.workerControlOptions ?? {}),
|
|
274
|
+
...(parsed.maxOutputLength === undefined ? {} : {maxOutputLength: parsed.maxOutputLength}),
|
|
275
|
+
metrics
|
|
138
276
|
})
|
|
139
277
|
activity = parsed.activityLog === undefined ? undefined : new ActivityLog(parsed.activityLog)
|
|
140
278
|
if (activity && resolvedWorkspace) {
|
|
@@ -145,52 +283,187 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
145
283
|
resolvedWorkspace.sourceIdentity
|
|
146
284
|
)
|
|
147
285
|
}
|
|
148
|
-
|
|
286
|
+
/** @type {import("./run-worker.js").RunWorkerOptions} */
|
|
287
|
+
const workerOptions = {
|
|
149
288
|
executable: provider.executable,
|
|
150
289
|
arguments: provider.arguments,
|
|
151
290
|
cwd: resolvedWorkspace?.cwd ?? parsed.cwd,
|
|
152
291
|
environment: providerEnvironment,
|
|
153
292
|
parse: provider.parse,
|
|
154
293
|
onEvent: (event) => {
|
|
155
|
-
|
|
156
|
-
|
|
294
|
+
validateNormalizedWorkerEvent(event)
|
|
295
|
+
if (event.type !== "text-delta") {
|
|
296
|
+
metrics.recordRejected(`${event.type}_progress`, Buffer.byteLength(JSON.stringify(event), "utf8"))
|
|
297
|
+
}
|
|
298
|
+
acceptAdmissionEvent(admission, event)
|
|
299
|
+
return control.accept(event)
|
|
157
300
|
},
|
|
158
301
|
onSpawn: (pid) => {
|
|
159
302
|
if (activity && pid !== undefined) activity.recordStarted(provider.name, pid)
|
|
160
303
|
},
|
|
161
304
|
onRecord: (record) => {
|
|
305
|
+
metrics.recordParsedProviderRecord("provider_stdout")
|
|
162
306
|
const id = provider.sessionId(record)
|
|
163
|
-
if (id !== undefined)
|
|
307
|
+
if (id !== undefined) {
|
|
308
|
+
admission.setContinuationHandle(id)
|
|
309
|
+
activity?.recordSession(provider.name, id)
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
onStdoutChunk: (chunk) => {
|
|
313
|
+
metrics.recordRawChildChunk("provider_stdout", chunk.length)
|
|
314
|
+
metrics.recordRejected("provider_stream", chunk.length)
|
|
315
|
+
if (evidence === undefined) return undefined
|
|
316
|
+
return evidence.append("provider-stdout", chunk).then(() => { evidencePayloadBytes += chunk.length })
|
|
317
|
+
},
|
|
318
|
+
onStderrChunk: (chunk) => {
|
|
319
|
+
metrics.recordRawChildChunk("provider_stderr", chunk.length)
|
|
320
|
+
metrics.recordRejected("stderr", chunk.length)
|
|
321
|
+
if (evidence === undefined) return undefined
|
|
322
|
+
return evidence.append("provider-stderr", chunk).then(() => { evidencePayloadBytes += chunk.length })
|
|
164
323
|
}
|
|
165
|
-
}
|
|
166
|
-
|
|
324
|
+
}
|
|
325
|
+
const exitCode = parsed.relayWrite
|
|
326
|
+
? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).run({
|
|
327
|
+
preflightId: /** @type {{preflightId: string}} */ (isolatedPreflight).preflightId,
|
|
328
|
+
prompt,
|
|
329
|
+
providerArguments: parsed.providerArguments,
|
|
330
|
+
...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession}),
|
|
331
|
+
onEvent: workerOptions.onEvent,
|
|
332
|
+
onRecord: /** @type {NonNullable<typeof workerOptions.onRecord>} */ (workerOptions.onRecord),
|
|
333
|
+
...(workerOptions.onStdoutChunk === undefined ? {} : {onStdoutChunk: workerOptions.onStdoutChunk}),
|
|
334
|
+
...(workerOptions.onStderrChunk === undefined ? {} : {onStderrChunk: workerOptions.onStderrChunk}),
|
|
335
|
+
...(launchDeadline === undefined ? {} : {deadline: launchDeadline})
|
|
336
|
+
})
|
|
337
|
+
: await (dependencies.workerRunner ?? runWorker)(workerOptions)
|
|
338
|
+
await boundedLaunch(control.close(), launchDeadline)
|
|
167
339
|
terminalExitCode = exitCode
|
|
168
|
-
return exitCode
|
|
169
340
|
} finally {
|
|
170
|
-
|
|
341
|
+
if (evidence !== undefined) {
|
|
342
|
+
try {
|
|
343
|
+
admission.addArtifactHandle(evidence.handle)
|
|
344
|
+
const terminal = {state: /** @type {"completed" | "failed"} */ (terminalExitCode === 0 ? "completed" : "failed"), exitCode: terminalExitCode}
|
|
345
|
+
metrics.recordAdmission(admission.preview(terminal))
|
|
346
|
+
const projectedArtifactBytes = metrics.projectSelfInclusiveArtifact(evidencePayloadBytes, 1)
|
|
347
|
+
await evidence.append("context-metrics", `${JSON.stringify(metrics.snapshot())}\n`)
|
|
348
|
+
const artifact = await evidence.finalize()
|
|
349
|
+
assertArtifactProjection(artifact.bytes, projectedArtifactBytes)
|
|
350
|
+
} catch (error) {
|
|
351
|
+
await evidence.abort()
|
|
352
|
+
admission.removeArtifactHandle(evidence.handle)
|
|
353
|
+
metrics.clearArtifact()
|
|
354
|
+
terminalExitCode = 2
|
|
355
|
+
evidenceError = error
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
admission.complete({state: terminalExitCode === 0 ? "completed" : "failed", exitCode: terminalExitCode})
|
|
359
|
+
errorOutput.write(`threadwire-context-metrics ${JSON.stringify(metrics.conciseDiagnostic())}\n`)
|
|
171
360
|
activity?.close()
|
|
361
|
+
await boundedLaunch(ownedEvidenceStore?.close(), launchDeadline).catch(() => {})
|
|
362
|
+
launchDeadline?.close()
|
|
172
363
|
}
|
|
364
|
+
if (evidenceError !== undefined) throw evidenceError
|
|
365
|
+
return terminalExitCode
|
|
173
366
|
} catch (error) {
|
|
174
|
-
|
|
367
|
+
let reportedError = error
|
|
368
|
+
if (runAdmission !== undefined && !runAdmission.completed) {
|
|
369
|
+
try {
|
|
370
|
+
runAdmission.complete({state: "failed", exitCode: 2})
|
|
371
|
+
} catch (transcriptError) {
|
|
372
|
+
reportedError = transcriptError
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
errorOutput.write(`threadwire: ${safeMessage(reportedError)}\n`)
|
|
175
376
|
return 2
|
|
377
|
+
} finally {
|
|
378
|
+
try {
|
|
379
|
+
normalizedOutput?.close()
|
|
380
|
+
} catch (error) {
|
|
381
|
+
errorOutput.write(`threadwire: ${safeMessage(error)}\n`)
|
|
382
|
+
}
|
|
176
383
|
}
|
|
177
384
|
}
|
|
178
385
|
|
|
179
|
-
/** @param {
|
|
180
|
-
|
|
386
|
+
/** @param {string | undefined} value */
|
|
387
|
+
function evidenceRoot(value) {
|
|
388
|
+
if (value === undefined) return undefined
|
|
389
|
+
if (!isAbsolute(value) || normalize(value) !== value) throw new Error("THREADWIRE_EVIDENCE_ROOT must be a normalized absolute path")
|
|
390
|
+
return value
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** @param {number} actual @param {number} projected */
|
|
394
|
+
function assertArtifactProjection(actual, projected) {
|
|
395
|
+
if (actual !== projected) throw new Error("Context metrics artifact projection mismatch")
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** @param {DelegatedResultAdmission} admission @param {import("./types.js").WorkerEvent} event */
|
|
399
|
+
function acceptAdmissionEvent(admission, event) {
|
|
400
|
+
if (event.type === "text-delta") admission.acceptConclusionEvent(event)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** @param {unknown} event */
|
|
404
|
+
function validateNormalizedWorkerEvent(event) {
|
|
405
|
+
if (!isPlainObject(event) || typeof event.type !== "string") {
|
|
406
|
+
throw new Error("invalid normalized worker event")
|
|
407
|
+
}
|
|
408
|
+
if (event.type === "text-delta") {
|
|
409
|
+
if (!hasExactEnumerableFields(event, ["type", "text", "streamId"]) || typeof event.text !== "string" || typeof event.streamId !== "string") {
|
|
410
|
+
throw new Error("invalid normalized worker event")
|
|
411
|
+
}
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
if (event.type === "lifecycle") {
|
|
415
|
+
if (
|
|
416
|
+
!hasExactEnumerableFields(event, ["type", "phase", "summary"])
|
|
417
|
+
|| (event.phase !== "started" && event.phase !== "completed" && event.phase !== "failed")
|
|
418
|
+
|| typeof event.summary !== "string"
|
|
419
|
+
) throw new Error("invalid normalized worker event")
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
if (event.type === "diagnostic") {
|
|
423
|
+
if (
|
|
424
|
+
!hasExactEnumerableFields(event, ["type", "level", "summary"])
|
|
425
|
+
|| (event.level !== "warning" && event.level !== "error")
|
|
426
|
+
|| typeof event.summary !== "string"
|
|
427
|
+
) throw new Error("invalid normalized worker event")
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
if (event.type === "tool") {
|
|
431
|
+
if (!isToolEventShape(event)) throw new Error("invalid normalized worker event")
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
throw new Error("Worker emitted an unknown normalized event kind")
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** @param {ParsedArguments} parsed @param {NodeJS.ReadableStream & {isTTY?: boolean}} input @param {AbortSignal | undefined} signal */
|
|
438
|
+
async function readPrompt(parsed, input, signal) {
|
|
181
439
|
let prompt
|
|
182
440
|
if (parsed.prompt !== undefined) prompt = parsed.prompt
|
|
183
|
-
else if (parsed.promptFile !== undefined) prompt = await readFile(parsed.promptFile, "utf8")
|
|
441
|
+
else if (parsed.promptFile !== undefined) prompt = await readFile(parsed.promptFile, {encoding: "utf8", signal})
|
|
184
442
|
else {
|
|
185
443
|
if (input.isTTY) throw new Error("A prompt is required via --prompt, --prompt-file, or piped stdin")
|
|
186
444
|
prompt = ""
|
|
187
445
|
input.setEncoding("utf8")
|
|
188
|
-
|
|
446
|
+
const abort = () => /** @type {NodeJS.ReadableStream & {destroy?: (error?: unknown) => void}} */ (input).destroy?.(signal?.reason)
|
|
447
|
+
signal?.addEventListener("abort", abort, {once: true})
|
|
448
|
+
try {
|
|
449
|
+
for await (const chunk of input) {
|
|
450
|
+
if (signal?.aborted) throw signal.reason
|
|
451
|
+
prompt += chunk
|
|
452
|
+
}
|
|
453
|
+
} finally {
|
|
454
|
+
signal?.removeEventListener("abort", abort)
|
|
455
|
+
}
|
|
189
456
|
}
|
|
190
457
|
if (prompt.trim().length === 0) throw new Error("Prompt cannot be empty")
|
|
191
458
|
return prompt
|
|
192
459
|
}
|
|
193
460
|
|
|
461
|
+
/** @param {Promise<unknown> | undefined} promise @param {import("./absolute-deadline.js").AbsoluteDeadline | undefined} deadline */
|
|
462
|
+
function boundedLaunch(promise, deadline) {
|
|
463
|
+
deadline?.throwIfAborted()
|
|
464
|
+
return abortable(Promise.resolve(promise), deadline?.signal)
|
|
465
|
+
}
|
|
466
|
+
|
|
194
467
|
/** @param {unknown} error */
|
|
195
468
|
function safeMessage(error) {
|
|
196
469
|
return error instanceof Error ? error.message : "Unknown error"
|
|
@@ -207,6 +480,53 @@ function positiveInteger(value, option) {
|
|
|
207
480
|
|
|
208
481
|
/** @param {string} value */
|
|
209
482
|
function sessionId(value) {
|
|
210
|
-
|
|
211
|
-
|
|
483
|
+
try {
|
|
484
|
+
return validateContinuationHandle(value)
|
|
485
|
+
} catch {
|
|
486
|
+
throw new Error("--resume-session must be a valid provider session ID")
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** @param {Record<string, unknown>} event */
|
|
491
|
+
function isToolEventShape(event) {
|
|
492
|
+
const allowed = event.phase === "finished"
|
|
493
|
+
? ["type", "phase", "name", "key", "detail", "output"]
|
|
494
|
+
: ["type", "phase", "name", "key", "detail"]
|
|
495
|
+
if (
|
|
496
|
+
!hasOnlyAllowedEnumerableFields(event, allowed, ["type", "phase", "name", "key"])
|
|
497
|
+
|| (event.phase !== "started" && event.phase !== "finished")
|
|
498
|
+
|| typeof event.name !== "string"
|
|
499
|
+
|| typeof event.key !== "string"
|
|
500
|
+
|| (event.detail !== undefined && typeof event.detail !== "string")
|
|
501
|
+
|| (event.output !== undefined && typeof event.output !== "string")
|
|
502
|
+
) return false
|
|
503
|
+
if (event.phase === "started" && event.output !== undefined) return false
|
|
504
|
+
return true
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
508
|
+
function isPlainObject(value) {
|
|
509
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false
|
|
510
|
+
const prototype = Object.getPrototypeOf(value)
|
|
511
|
+
return prototype === Object.prototype || prototype === null
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** @param {Record<string, unknown>} value @param {string[]} expected */
|
|
515
|
+
function hasExactEnumerableFields(value, expected) {
|
|
516
|
+
const fields = Reflect.ownKeys(value)
|
|
517
|
+
return fields.length === expected.length && fields.every((field) => (
|
|
518
|
+
typeof field === "string"
|
|
519
|
+
&& expected.includes(field)
|
|
520
|
+
&& Object.prototype.propertyIsEnumerable.call(value, field)
|
|
521
|
+
))
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** @param {Record<string, unknown>} value @param {string[]} allowed @param {string[]} required */
|
|
525
|
+
function hasOnlyAllowedEnumerableFields(value, allowed, required) {
|
|
526
|
+
const fields = Reflect.ownKeys(value)
|
|
527
|
+
return required.every((field) => Object.prototype.propertyIsEnumerable.call(value, field)) && fields.every((field) => (
|
|
528
|
+
typeof field === "string"
|
|
529
|
+
&& allowed.includes(field)
|
|
530
|
+
&& Object.prototype.propertyIsEnumerable.call(value, field)
|
|
531
|
+
))
|
|
212
532
|
}
|