threadwire 0.1.13 → 0.1.14
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 +9 -0
- package/README.md +8 -1
- package/docs/capacity-admission.md +147 -0
- package/docs/development-container.md +77 -0
- package/package.json +1 -1
- package/scripts/verify-package.js +7 -0
- package/src/absolute-deadline.js +6 -3
- package/src/cli.js +129 -6
- package/src/docker-api.js +8 -0
- package/src/isolated-runtime-client.js +124 -4
- package/src/isolated-runtime.js +145 -11
- package/src/jsonl-record-spool.js +178 -0
- package/src/kimi-oauth-store.js +16 -0
- package/src/provider-capacity-codex.js +176 -0
- package/src/provider-capacity-kimi.js +105 -0
- package/src/provider-capacity.js +292 -0
- package/src/providers/index.js +3 -3
- package/src/providers/kimi.js +30 -0
- package/src/run-worker.js +286 -71
- package/src/telegram-ingress/core.js +51 -22
|
@@ -5,14 +5,16 @@ import {AbsoluteDeadline, deadlineAfter, abortable, readResponseCapped} from "./
|
|
|
5
5
|
|
|
6
6
|
const MAX_RESPONSE_BYTES = 2_097_152
|
|
7
7
|
const MAX_RAW_OUTPUT_BYTES = 1_048_576
|
|
8
|
+
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000
|
|
8
9
|
|
|
9
10
|
export class IsolatedRuntimeClient {
|
|
10
|
-
/** @param {{url: string, controlToken: string, timeoutMs?: number | string, fetchImplementation?: typeof fetch}} options */
|
|
11
|
+
/** @param {{url: string, controlToken: string, timeoutMs?: number | string, cleanupTimeoutMs?: number | string, fetchImplementation?: typeof fetch}} options */
|
|
11
12
|
constructor(options) {
|
|
12
13
|
this.url = normalizedUrl(options.url)
|
|
13
14
|
this.controlToken = nonempty(options.controlToken, "isolated runtime control token")
|
|
14
15
|
this.fetch = options.fetchImplementation ?? fetch
|
|
15
16
|
this.timeoutMs = optionalDuration(options.timeoutMs)
|
|
17
|
+
this.cleanupTimeoutMs = optionalDuration(options.cleanupTimeoutMs) ?? DEFAULT_CLEANUP_TIMEOUT_MS
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -79,6 +81,113 @@ export class IsolatedRuntimeClient {
|
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Start a container-backed run whose request side remains open as the
|
|
86
|
+
* cancellation channel. Closing that side asks the runtime to stop its exact
|
|
87
|
+
* worker container while leaving the response available for terminal cleanup
|
|
88
|
+
* confirmation.
|
|
89
|
+
* @param {{
|
|
90
|
+
* preflightId: string, prompt: string, providerArguments: string[],
|
|
91
|
+
* resumeSession?: string,
|
|
92
|
+
* signal?: AbortSignal,
|
|
93
|
+
* deadline?: import("./absolute-deadline.js").AbsoluteDeadline,
|
|
94
|
+
* onEvent: (event: import("./types.js").WorkerEvent) => unknown | Promise<unknown>,
|
|
95
|
+
* onRecord?: (record: unknown) => unknown | Promise<unknown>,
|
|
96
|
+
* onStdoutChunk?: (chunk: Buffer) => unknown | Promise<unknown>,
|
|
97
|
+
* onStderrChunk?: (chunk: Buffer) => unknown | Promise<unknown>
|
|
98
|
+
* }} options
|
|
99
|
+
*/
|
|
100
|
+
startRun(options) {
|
|
101
|
+
const deadline = options.deadline ?? (this.timeoutMs === undefined ? undefined : deadlineAfter(this.timeoutMs, {signal: options.signal, timeoutMessage: "Isolated runtime launch timeout"}))
|
|
102
|
+
const operation = deadline ?? new AbsoluteDeadline(undefined, {signal: options.signal, timeoutMessage: "Isolated runtime launch timeout"})
|
|
103
|
+
const requestController = new AbortController()
|
|
104
|
+
/** @type {ReadableStreamDefaultController<Uint8Array>} */
|
|
105
|
+
let bodyController
|
|
106
|
+
let bodyClosed = false
|
|
107
|
+
const body = new ReadableStream({
|
|
108
|
+
start(controller) {
|
|
109
|
+
bodyController = controller
|
|
110
|
+
controller.enqueue(new TextEncoder().encode(`${JSON.stringify({
|
|
111
|
+
preflightId: options.preflightId,
|
|
112
|
+
prompt: options.prompt,
|
|
113
|
+
providerArguments: options.providerArguments,
|
|
114
|
+
...(options.resumeSession === undefined ? {} : {resumeSession: options.resumeSession})
|
|
115
|
+
})}\n`))
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
const closeRequest = () => {
|
|
119
|
+
if (bodyClosed) return
|
|
120
|
+
bodyClosed = true
|
|
121
|
+
bodyController.close()
|
|
122
|
+
}
|
|
123
|
+
/** @type {Error | undefined} */
|
|
124
|
+
let consumerError
|
|
125
|
+
/** @type {(error: Error) => void} */
|
|
126
|
+
let rejectConsumer
|
|
127
|
+
const consumerFailure = new Promise((_resolve, reject) => { rejectConsumer = reject })
|
|
128
|
+
const transportCompletion = (async () => {
|
|
129
|
+
const response = await this.fetch(`${this.url}/run`, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
redirect: "error",
|
|
132
|
+
headers: {
|
|
133
|
+
authorization: `Bearer ${this.controlToken}`,
|
|
134
|
+
"content-type": "application/x-ndjson",
|
|
135
|
+
connection: "close",
|
|
136
|
+
"x-threadwire-run-lifecycle": "1",
|
|
137
|
+
...(operation.expiresAt === undefined ? {} : {"x-threadwire-launch-deadline": String(operation.expiresAt)})
|
|
138
|
+
},
|
|
139
|
+
body,
|
|
140
|
+
duplex: "half",
|
|
141
|
+
signal: requestController.signal
|
|
142
|
+
})
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
const bytes = await readResponseCapped(response, MAX_RESPONSE_BYTES, requestController.signal)
|
|
145
|
+
throw new Error(safeRemoteError(bytes))
|
|
146
|
+
}
|
|
147
|
+
return consumeRunStream(response, options, {
|
|
148
|
+
signal: requestController.signal,
|
|
149
|
+
onConsumerError(error) {
|
|
150
|
+
if (consumerError !== undefined) return
|
|
151
|
+
consumerError = error
|
|
152
|
+
rejectConsumer(error)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
})()
|
|
156
|
+
const runCompletion = Promise.race([transportCompletion, consumerFailure])
|
|
157
|
+
/** @type {Promise<void> | undefined} */
|
|
158
|
+
let cancellation
|
|
159
|
+
const cancel = (reason = new Error("Isolated runtime run cancelled")) => {
|
|
160
|
+
if (cancellation !== undefined) return cancellation
|
|
161
|
+
closeRequest()
|
|
162
|
+
const cleanup = deadlineAfter(this.cleanupTimeoutMs, {timeoutMessage: "Isolated runtime cleanup confirmation timeout"})
|
|
163
|
+
cancellation = (async () => {
|
|
164
|
+
try {
|
|
165
|
+
await abortable(transportCompletion, cleanup.signal)
|
|
166
|
+
} catch (error) {
|
|
167
|
+
requestController.abort(reason)
|
|
168
|
+
throw new Error("Isolated runtime cleanup could not be confirmed", {cause: error})
|
|
169
|
+
} finally {
|
|
170
|
+
cleanup.close()
|
|
171
|
+
}
|
|
172
|
+
})()
|
|
173
|
+
return cancellation
|
|
174
|
+
}
|
|
175
|
+
const completion = (async () => {
|
|
176
|
+
try {
|
|
177
|
+
return await abortable(runCompletion, operation.signal)
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (operation.signal.aborted || error === consumerError) {
|
|
180
|
+
await cancel(error instanceof Error ? error : new Error("Isolated runtime run failed"))
|
|
181
|
+
}
|
|
182
|
+
throw error
|
|
183
|
+
} finally {
|
|
184
|
+
closeRequest()
|
|
185
|
+
if (options.deadline === undefined) operation.close()
|
|
186
|
+
}
|
|
187
|
+
})()
|
|
188
|
+
return {completion, cancel}
|
|
189
|
+
}
|
|
190
|
+
|
|
82
191
|
/** @param {string} path @param {unknown} body */
|
|
83
192
|
async request(path, body, deadline) {
|
|
84
193
|
const signal = deadline.signal
|
|
@@ -121,6 +230,17 @@ async function consumeRunStream(response, options, operation) {
|
|
|
121
230
|
let rawBytes = 0
|
|
122
231
|
/** @type {number | undefined} */
|
|
123
232
|
let terminal
|
|
233
|
+
let consumerFailed = false
|
|
234
|
+
const invokeConsumer = async (callback) => {
|
|
235
|
+
if (consumerFailed) return
|
|
236
|
+
try {
|
|
237
|
+
await abortable(callback(), operation.signal)
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (operation.onConsumerError === undefined) throw error
|
|
240
|
+
consumerFailed = true
|
|
241
|
+
operation.onConsumerError(error instanceof Error ? error : new Error("Isolated runtime consumer failed"))
|
|
242
|
+
}
|
|
243
|
+
}
|
|
124
244
|
const consume = async (line) => {
|
|
125
245
|
if (line.length === 0 || terminal !== undefined) throw new Error("Isolated runtime emitted an invalid response")
|
|
126
246
|
let frame
|
|
@@ -136,13 +256,13 @@ async function consumeRunStream(response, options, operation) {
|
|
|
136
256
|
rawBytes += data.length
|
|
137
257
|
if (rawBytes > MAX_RAW_OUTPUT_BYTES) throw new Error("Isolated runtime emitted an invalid response")
|
|
138
258
|
const callback = frame.channel === "stdout" ? options.onStdoutChunk : options.onStderrChunk
|
|
139
|
-
await
|
|
259
|
+
if (callback !== undefined) await invokeConsumer(() => callback(data))
|
|
140
260
|
return
|
|
141
261
|
}
|
|
142
262
|
if (frame.type === "record") {
|
|
143
263
|
if (Object.keys(frame).sort().join(",") !== "record,type") throw new Error("Isolated runtime emitted an invalid response")
|
|
144
|
-
await
|
|
145
|
-
if (isWorkerEventEnvelope(frame.record)) await
|
|
264
|
+
if (options.onRecord !== undefined) await invokeConsumer(() => options.onRecord(frame.record))
|
|
265
|
+
if (isWorkerEventEnvelope(frame.record)) await invokeConsumer(() => options.onEvent(frame.record.event))
|
|
146
266
|
return
|
|
147
267
|
}
|
|
148
268
|
if (frame.type === "terminal" && Object.keys(frame).sort().join(",") === "exitCode,type" && Number.isSafeInteger(frame.exitCode)) {
|
package/src/isolated-runtime.js
CHANGED
|
@@ -29,6 +29,7 @@ const GRANT_LEASE_MS = 60_000
|
|
|
29
29
|
const GRANT_LEASE_MAX_MS = 3_600_000
|
|
30
30
|
const PREFLIGHT_TIMEOUT_MS = 30_000
|
|
31
31
|
const MAX_RAW_OUTPUT_BYTES = 1_048_576
|
|
32
|
+
const WORKER_STOP_GRACE_SECONDS = 3
|
|
32
33
|
|
|
33
34
|
/** @param {{environment?: NodeJS.ProcessEnv, docker?: DockerApi, fetchImplementation?: typeof fetch, now?: () => number}} [options] */
|
|
34
35
|
export async function startIsolatedRuntime(options = {}) {
|
|
@@ -160,21 +161,32 @@ export async function startIsolatedRuntime(options = {}) {
|
|
|
160
161
|
const releaseIncoming = incomingRequests.acquire("run")
|
|
161
162
|
const bodyOperation = new AbsoluteDeadline(Date.now() + preflightTimeoutMs, {signal: shutdown.signal, request, response, timeoutMessage: "Isolated runtime operation timed out"})
|
|
162
163
|
let body
|
|
164
|
+
let runLifecycle
|
|
163
165
|
try {
|
|
164
|
-
|
|
166
|
+
if (request.headers["x-threadwire-run-lifecycle"] === "1") {
|
|
167
|
+
runLifecycle = await readRunLifecycle(request, MAX_RUN_BYTES, bodyOperation.signal)
|
|
168
|
+
body = parseRun(runLifecycle.value)
|
|
169
|
+
} else {
|
|
170
|
+
body = parseRun(await readJson(request, MAX_RUN_BYTES, bodyOperation.signal))
|
|
171
|
+
}
|
|
165
172
|
} finally {
|
|
166
173
|
bodyOperation.close()
|
|
167
174
|
releaseIncoming()
|
|
168
175
|
}
|
|
169
176
|
const preflight = preflights.consume(body.preflightId)
|
|
170
177
|
if (!preflight) throw new ClientError("Isolated runtime preflight expired")
|
|
178
|
+
const operationSignal = runLifecycle === undefined
|
|
179
|
+
? shutdown.signal
|
|
180
|
+
: AbortSignal.any([shutdown.signal, runLifecycle.signal])
|
|
171
181
|
const operation = new AbsoluteDeadline(minimumDeadline(preflight.launchDeadlineAt,
|
|
172
182
|
requestedDeadline(request.headers["x-threadwire-launch-deadline"], workerTimeoutMs)), {
|
|
173
|
-
signal:
|
|
183
|
+
signal: operationSignal, request, response, timeoutMessage: "Isolated runtime launch timed out", disconnectMessage: "Isolated runtime caller disconnected",
|
|
184
|
+
destroyRequestOnAbort: runLifecycle === undefined
|
|
174
185
|
})
|
|
175
186
|
const task = taskIdentity(preflight)
|
|
176
187
|
let releaseRun
|
|
177
188
|
let releaseLineage
|
|
189
|
+
let retainLineage = false
|
|
178
190
|
try {
|
|
179
191
|
releaseRun = activeRuns.acquire(task)
|
|
180
192
|
releaseLineage = activeLineages.acquire(preflight.state.lineage)
|
|
@@ -188,6 +200,7 @@ export async function startIsolatedRuntime(options = {}) {
|
|
|
188
200
|
if (!response.headersSent) response.writeHead(200, {"content-type": "application/x-ndjson"})
|
|
189
201
|
if (!response.write(`${JSON.stringify(frame)}\n`)) await abortable(once(response, "drain"), operation.signal)
|
|
190
202
|
}
|
|
203
|
+
if (runLifecycle !== undefined && !response.headersSent) response.writeHead(200, {"content-type": "application/x-ndjson"})
|
|
191
204
|
const result = await runIsolatedWorker({
|
|
192
205
|
docker, workerImage, brokerUrl, brokerAdminToken, brokerContainer, relayImage, relayWorkerUrl,
|
|
193
206
|
fetchImplementation, preflight, ...body, task, now, stateRegistry, sessionTtlMs, operation, grantLeaseMs,
|
|
@@ -197,9 +210,13 @@ export async function startIsolatedRuntime(options = {}) {
|
|
|
197
210
|
for (const chunk of result.rawChunks) await writeFrame({type: "chunk", ...chunk})
|
|
198
211
|
await writeFrame({type: "terminal", exitCode: result.exitCode})
|
|
199
212
|
response.end()
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (error instanceof ContainerCleanupConfirmationError) retainLineage = true
|
|
215
|
+
throw error
|
|
200
216
|
} finally {
|
|
201
217
|
operation.close()
|
|
202
|
-
|
|
218
|
+
runLifecycle?.close()
|
|
219
|
+
if (!retainLineage) releaseLineage?.()
|
|
203
220
|
releaseRun?.()
|
|
204
221
|
void collectState()
|
|
205
222
|
}
|
|
@@ -209,7 +226,9 @@ export async function startIsolatedRuntime(options = {}) {
|
|
|
209
226
|
response.end()
|
|
210
227
|
} catch (error) {
|
|
211
228
|
if (response.headersSent) {
|
|
212
|
-
if (!response.writableEnded && !response.destroyed)
|
|
229
|
+
if (!response.writableEnded && !response.destroyed) {
|
|
230
|
+
response.end(error instanceof ContainerCleanupConfirmationError ? undefined : `${JSON.stringify({type: "terminal", exitCode: 1})}\n`)
|
|
231
|
+
}
|
|
213
232
|
return
|
|
214
233
|
}
|
|
215
234
|
sendJson(response, error instanceof ClientError ? 400 : 503, {
|
|
@@ -367,6 +386,9 @@ export async function runIsolatedWorker(options) {
|
|
|
367
386
|
let grantRenewalTimer
|
|
368
387
|
let runTracked = false
|
|
369
388
|
let runStage = "state"
|
|
389
|
+
let workerResult
|
|
390
|
+
let runError
|
|
391
|
+
let cleanupFailure
|
|
370
392
|
const operation = options.operation
|
|
371
393
|
const worktreeSubpath = options.worktreeVolume === undefined
|
|
372
394
|
? ""
|
|
@@ -632,10 +654,10 @@ export async function runIsolatedWorker(options) {
|
|
|
632
654
|
throw error
|
|
633
655
|
}
|
|
634
656
|
}
|
|
635
|
-
|
|
657
|
+
workerResult = result
|
|
636
658
|
} catch (error) {
|
|
637
659
|
process.stderr.write(`threadwire-runtime: isolated run failed at ${runStage}\n`)
|
|
638
|
-
|
|
660
|
+
runError = error
|
|
639
661
|
} finally {
|
|
640
662
|
let resourcesRemoved = true
|
|
641
663
|
if (grantRenewalTimer !== undefined) clearInterval(grantRenewalTimer)
|
|
@@ -658,10 +680,17 @@ export async function runIsolatedWorker(options) {
|
|
|
658
680
|
resourcesRemoved = false
|
|
659
681
|
process.stderr.write("threadwire-isolated-runtime: validator cleanup failed\n")
|
|
660
682
|
})
|
|
661
|
-
if (containerId !== undefined)
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
683
|
+
if (containerId !== undefined) {
|
|
684
|
+
try {
|
|
685
|
+
await stopAndRemoveWorkerContainer(options.docker, containerId, cleanupOptions())
|
|
686
|
+
} catch (error) {
|
|
687
|
+
resourcesRemoved = false
|
|
688
|
+
cleanupFailure = error instanceof ContainerCleanupConfirmationError
|
|
689
|
+
? error
|
|
690
|
+
: new ContainerCleanupConfirmationError({cause: error})
|
|
691
|
+
process.stderr.write("threadwire-isolated-runtime: worker cleanup failed\n")
|
|
692
|
+
}
|
|
693
|
+
}
|
|
665
694
|
if (relayId !== undefined) await cleanupDocker(() => options.docker.removeContainer(relayId, cleanupOptions())).catch(() => {
|
|
666
695
|
resourcesRemoved = false
|
|
667
696
|
process.stderr.write("threadwire-isolated-runtime: relay cleanup failed\n")
|
|
@@ -677,16 +706,43 @@ export async function runIsolatedWorker(options) {
|
|
|
677
706
|
resourcesRemoved = false
|
|
678
707
|
process.stderr.write("threadwire-isolated-runtime: egress network cleanup failed\n")
|
|
679
708
|
})
|
|
680
|
-
if (freshState && !keepFreshState) await cleanupDocker(() => options.docker.removeVolume(state.volume, cleanupOptions())).catch(() => { resourcesRemoved = false })
|
|
709
|
+
if (freshState && !keepFreshState && resourcesRemoved) await cleanupDocker(() => options.docker.removeVolume(state.volume, cleanupOptions())).catch(() => { resourcesRemoved = false })
|
|
681
710
|
if (runTracked && resourcesRemoved) await options.stateRegistry.completeRun(runId, cleanupOperation.signal).catch(() => {})
|
|
682
711
|
emergencyCleanup?.close()
|
|
683
712
|
}
|
|
713
|
+
if (cleanupFailure !== undefined) throw cleanupFailure
|
|
714
|
+
if (runError !== undefined) throw runError
|
|
715
|
+
return workerResult
|
|
684
716
|
}
|
|
685
717
|
|
|
686
718
|
async function cleanupDocker(operation) {
|
|
687
719
|
return operation()
|
|
688
720
|
}
|
|
689
721
|
|
|
722
|
+
async function stopAndRemoveWorkerContainer(docker, containerId, requestOptions) {
|
|
723
|
+
let inspection = await docker.inspectContainer(containerId, requestOptions)
|
|
724
|
+
if (typeof inspection?.State?.Running !== "boolean") throw new Error("Worker container state unavailable")
|
|
725
|
+
if (inspection.State.Running) {
|
|
726
|
+
await docker.stopContainer(containerId, WORKER_STOP_GRACE_SECONDS, requestOptions)
|
|
727
|
+
inspection = await docker.inspectContainer(containerId, requestOptions)
|
|
728
|
+
if (typeof inspection?.State?.Running !== "boolean") throw new Error("Worker container state unavailable")
|
|
729
|
+
if (inspection.State.Running) {
|
|
730
|
+
await docker.killContainer(containerId, "KILL", requestOptions)
|
|
731
|
+
inspection = await docker.inspectContainer(containerId, requestOptions)
|
|
732
|
+
if (inspection?.State?.Running !== false) throw new ContainerCleanupConfirmationError()
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
await docker.removeContainer(containerId, requestOptions)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
class ContainerCleanupConfirmationError extends Error {
|
|
739
|
+
/** @param {{cause?: unknown}} [options] */
|
|
740
|
+
constructor(options = {}) {
|
|
741
|
+
super("Worker container cleanup could not be confirmed", options)
|
|
742
|
+
this.name = "ContainerCleanupConfirmationError"
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
690
746
|
export async function prepareStateVolume(docker, state, fresh, requestOptions) {
|
|
691
747
|
if (fresh) await docker.createVolume(state.volume, state.labels, requestOptions)
|
|
692
748
|
const volume = await docker.inspectVolume(state.volume, requestOptions).catch(() => undefined)
|
|
@@ -1069,6 +1125,84 @@ async function readJson(request, capacity, signal) {
|
|
|
1069
1125
|
}
|
|
1070
1126
|
}
|
|
1071
1127
|
|
|
1128
|
+
function readRunLifecycle(request, capacity, signal) {
|
|
1129
|
+
const cancellation = new AbortController()
|
|
1130
|
+
/** @type {Buffer[]} */
|
|
1131
|
+
const chunks = []
|
|
1132
|
+
let bytes = 0
|
|
1133
|
+
let parsed = false
|
|
1134
|
+
let settled = false
|
|
1135
|
+
const cancel = (reason) => cancellation.abort(reason instanceof Error ? reason : new Error("Isolated runtime caller cancelled"))
|
|
1136
|
+
const close = () => {
|
|
1137
|
+
request.off("data", onData)
|
|
1138
|
+
request.off("end", onEnd)
|
|
1139
|
+
request.off("aborted", onAborted)
|
|
1140
|
+
request.off("error", onError)
|
|
1141
|
+
signal?.removeEventListener("abort", onSignal)
|
|
1142
|
+
}
|
|
1143
|
+
/** @type {(value: {value: unknown, signal: AbortSignal, close: () => void}) => void} */
|
|
1144
|
+
let resolveLifecycle
|
|
1145
|
+
/** @type {(error: Error) => void} */
|
|
1146
|
+
let rejectLifecycle
|
|
1147
|
+
const fail = (error) => {
|
|
1148
|
+
if (parsed) {
|
|
1149
|
+
cancel(error)
|
|
1150
|
+
return
|
|
1151
|
+
}
|
|
1152
|
+
if (settled) return
|
|
1153
|
+
settled = true
|
|
1154
|
+
close()
|
|
1155
|
+
rejectLifecycle(error)
|
|
1156
|
+
}
|
|
1157
|
+
const onData = (chunk) => {
|
|
1158
|
+
if (parsed) {
|
|
1159
|
+
fail(new ClientError("Invalid request"))
|
|
1160
|
+
return
|
|
1161
|
+
}
|
|
1162
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
1163
|
+
bytes += buffer.length
|
|
1164
|
+
if (bytes > capacity) {
|
|
1165
|
+
fail(new ClientError("Request exceeded capacity"))
|
|
1166
|
+
return
|
|
1167
|
+
}
|
|
1168
|
+
chunks.push(buffer)
|
|
1169
|
+
const combined = Buffer.concat(chunks, bytes)
|
|
1170
|
+
const newline = combined.indexOf(0x0a)
|
|
1171
|
+
if (newline < 0) return
|
|
1172
|
+
if (newline !== combined.length - 1) {
|
|
1173
|
+
fail(new ClientError("Invalid request"))
|
|
1174
|
+
return
|
|
1175
|
+
}
|
|
1176
|
+
let value
|
|
1177
|
+
try {
|
|
1178
|
+
value = JSON.parse(combined.subarray(0, newline).toString("utf8"))
|
|
1179
|
+
} catch {
|
|
1180
|
+
fail(new ClientError("Invalid request"))
|
|
1181
|
+
return
|
|
1182
|
+
}
|
|
1183
|
+
parsed = true
|
|
1184
|
+
settled = true
|
|
1185
|
+
resolveLifecycle({value, signal: cancellation.signal, close})
|
|
1186
|
+
}
|
|
1187
|
+
const onEnd = () => parsed
|
|
1188
|
+
? cancel(new Error("Isolated runtime caller cancelled"))
|
|
1189
|
+
: fail(new ClientError("Invalid request"))
|
|
1190
|
+
const onAborted = () => fail(new Error("Isolated runtime caller disconnected"))
|
|
1191
|
+
const onError = (error) => fail(error)
|
|
1192
|
+
const onSignal = () => fail(signal.reason instanceof Error ? signal.reason : new Error("Isolated runtime operation aborted"))
|
|
1193
|
+
const lifecycle = new Promise((resolve, reject) => {
|
|
1194
|
+
resolveLifecycle = resolve
|
|
1195
|
+
rejectLifecycle = reject
|
|
1196
|
+
})
|
|
1197
|
+
request.on("data", onData)
|
|
1198
|
+
request.once("end", onEnd)
|
|
1199
|
+
request.once("aborted", onAborted)
|
|
1200
|
+
request.once("error", onError)
|
|
1201
|
+
if (signal?.aborted) onSignal()
|
|
1202
|
+
else signal?.addEventListener("abort", onSignal, {once: true})
|
|
1203
|
+
return lifecycle
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1072
1206
|
function requireControl(value, expected) {
|
|
1073
1207
|
if (value !== `Bearer ${expected}`) throw new ClientError("Request denied")
|
|
1074
1208
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {closeSync, mkdtempSync, openSync, readFileSync, rmSync, unlinkSync, writeSync} from "node:fs"
|
|
4
|
+
import {tmpdir} from "node:os"
|
|
5
|
+
import {isAbsolute, join, normalize} from "node:path"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Single-owner byte-oriented spool for one worker's pending JSONL record.
|
|
9
|
+
*
|
|
10
|
+
* Worker stdout is consumed as raw bytes. A pending record (bytes not yet
|
|
11
|
+
* terminated by `\n`) is kept in bounded memory while it stays below
|
|
12
|
+
* `memoryBytes`; beyond that threshold it spills exactly once to one private
|
|
13
|
+
* run-scoped file, writing every byte segment exactly once. Newline scanning
|
|
14
|
+
* happens on raw bytes: `0x0a` can never appear inside a multibyte UTF-8
|
|
15
|
+
* sequence, so newline boundaries are preserved across arbitrary chunk splits
|
|
16
|
+
* and completed records always decode as well-formed UTF-8.
|
|
17
|
+
*
|
|
18
|
+
* `maxBytes` is a separate absolute pending-record cap: unterminated or
|
|
19
|
+
* malformed output can never consume unbounded memory, disk, or JSON.parse
|
|
20
|
+
* allocation. `close()` is idempotent and removes every spool artifact; the
|
|
21
|
+
* owner calls it on every settlement path.
|
|
22
|
+
*/
|
|
23
|
+
export class JsonlRecordSpool {
|
|
24
|
+
/** @param {{memoryBytes: number, maxBytes: number, directory?: string}} options */
|
|
25
|
+
constructor(options) {
|
|
26
|
+
this.memoryBytes = positiveSafeInteger(options.memoryBytes, "memoryBytes")
|
|
27
|
+
this.maxBytes = positiveSafeInteger(options.maxBytes, "maxBytes")
|
|
28
|
+
const directory = options.directory ?? tmpdir()
|
|
29
|
+
if (!isAbsolute(directory) || normalize(directory) !== directory) throw new Error("directory must be a normalized absolute path")
|
|
30
|
+
this.directory = directory
|
|
31
|
+
/** @type {Buffer[]} */
|
|
32
|
+
this.segments = []
|
|
33
|
+
this.pendingBytes = 0
|
|
34
|
+
/** @type {{fd: number, path: string} | null} */
|
|
35
|
+
this.file = null
|
|
36
|
+
/** @type {string | null} */
|
|
37
|
+
this.ownedDirectory = null
|
|
38
|
+
this.closed = false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Consume one stdout chunk and yield each completed record in order, decoded
|
|
43
|
+
* as UTF-8 without the terminating newline. Scanning advances only as the
|
|
44
|
+
* owner consumes records, so terminal protocol completion can leave trailing
|
|
45
|
+
* bytes in the same chunk untouched.
|
|
46
|
+
* @param {Buffer} chunk
|
|
47
|
+
* @returns {Generator<string>}
|
|
48
|
+
* @yields {string}
|
|
49
|
+
*/
|
|
50
|
+
*push(chunk) {
|
|
51
|
+
if (this.closed) throw new Error("JSONL record spool is closed")
|
|
52
|
+
let segmentStart = 0
|
|
53
|
+
for (let index = 0; index < chunk.length; index += 1) {
|
|
54
|
+
if (chunk[index] !== 0x0a) continue
|
|
55
|
+
const record = this.completeRecord(chunk.subarray(segmentStart, index))
|
|
56
|
+
segmentStart = index + 1
|
|
57
|
+
try {
|
|
58
|
+
yield record
|
|
59
|
+
} finally {
|
|
60
|
+
this.removeOwnedDirectory()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (segmentStart < chunk.length) this.append(chunk.subarray(segmentStart))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* End of stream: return the pending record ("" when none) and release it.
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
end() {
|
|
71
|
+
if (this.closed || this.pendingBytes === 0) return ""
|
|
72
|
+
let record
|
|
73
|
+
if (this.file === null) {
|
|
74
|
+
record = Buffer.concat(this.segments, this.pendingBytes).toString("utf8")
|
|
75
|
+
this.segments = []
|
|
76
|
+
} else {
|
|
77
|
+
const file = this.file
|
|
78
|
+
this.file = null
|
|
79
|
+
closeSync(file.fd)
|
|
80
|
+
record = readFileSync(file.path, "utf8")
|
|
81
|
+
unlinkSync(file.path)
|
|
82
|
+
this.removeOwnedDirectory()
|
|
83
|
+
}
|
|
84
|
+
this.pendingBytes = 0
|
|
85
|
+
return record
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Idempotent: close the spool file and remove every run-scoped artifact. */
|
|
89
|
+
close() {
|
|
90
|
+
if (this.closed) return
|
|
91
|
+
this.closed = true
|
|
92
|
+
this.segments = []
|
|
93
|
+
const file = this.file
|
|
94
|
+
this.file = null
|
|
95
|
+
if (file !== null) {
|
|
96
|
+
closeSync(file.fd)
|
|
97
|
+
rmSync(file.path, {force: true})
|
|
98
|
+
}
|
|
99
|
+
this.removeOwnedDirectory()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Finish one record: combine the pending bytes with the final segment and
|
|
104
|
+
* decode exactly once. A spilled record is closed, read back, and unlinked;
|
|
105
|
+
* the next oversized record spills into a freshly created file, so at most
|
|
106
|
+
* one private file ever exists for the run.
|
|
107
|
+
* @param {Buffer} finalSegment @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
completeRecord(finalSegment) {
|
|
110
|
+
const recordBytes = this.pendingBytes + finalSegment.length
|
|
111
|
+
if (recordBytes > this.maxBytes) throw new StdoutRecordTooLargeError()
|
|
112
|
+
if (this.file === null && recordBytes <= this.memoryBytes) {
|
|
113
|
+
const record = this.pendingBytes === 0
|
|
114
|
+
? finalSegment.toString("utf8")
|
|
115
|
+
: Buffer.concat([...this.segments, finalSegment], recordBytes).toString("utf8")
|
|
116
|
+
this.segments = []
|
|
117
|
+
this.pendingBytes = 0
|
|
118
|
+
return record
|
|
119
|
+
}
|
|
120
|
+
if (this.file === null) this.append(finalSegment)
|
|
121
|
+
else {
|
|
122
|
+
this.pendingBytes = recordBytes
|
|
123
|
+
writeSync(this.file.fd, finalSegment)
|
|
124
|
+
}
|
|
125
|
+
const file = this.file
|
|
126
|
+
if (file === null) throw new Error("JSONL record spool file is unavailable")
|
|
127
|
+
this.file = null
|
|
128
|
+
closeSync(file.fd)
|
|
129
|
+
const record = readFileSync(file.path, "utf8")
|
|
130
|
+
unlinkSync(file.path)
|
|
131
|
+
this.pendingBytes = 0
|
|
132
|
+
return record
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @param {Buffer} segment */
|
|
136
|
+
append(segment) {
|
|
137
|
+
this.pendingBytes += segment.length
|
|
138
|
+
if (this.pendingBytes > this.maxBytes) throw new StdoutRecordTooLargeError()
|
|
139
|
+
if (this.file !== null) {
|
|
140
|
+
writeSync(this.file.fd, segment)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
if (this.pendingBytes <= this.memoryBytes) {
|
|
144
|
+
this.segments.push(segment)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
// First threshold crossing: create the private run-scoped file and write
|
|
148
|
+
// each buffered segment exactly once, in order.
|
|
149
|
+
this.ownedDirectory = mkdtempSync(join(this.directory, "threadwire-record-"))
|
|
150
|
+
const path = join(this.ownedDirectory, "pending-record")
|
|
151
|
+
const fd = openSync(path, "wx", 0o600)
|
|
152
|
+
this.file = {fd, path}
|
|
153
|
+
for (const buffered of this.segments) writeSync(fd, buffered)
|
|
154
|
+
this.segments = []
|
|
155
|
+
writeSync(fd, segment)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Remove the current record's private spill directory, if one exists. */
|
|
159
|
+
removeOwnedDirectory() {
|
|
160
|
+
const directory = this.ownedDirectory
|
|
161
|
+
if (directory === null) return
|
|
162
|
+
rmSync(directory, {recursive: true, force: true})
|
|
163
|
+
this.ownedDirectory = null
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export class StdoutRecordTooLargeError extends Error {
|
|
168
|
+
constructor() {
|
|
169
|
+
super("Worker stdout record exceeded the configured byte capacity")
|
|
170
|
+
this.name = "StdoutRecordTooLargeError"
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @param {number} value @param {string} name */
|
|
175
|
+
function positiveSafeInteger(value, name) {
|
|
176
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive safe integer`)
|
|
177
|
+
return value
|
|
178
|
+
}
|
package/src/kimi-oauth-store.js
CHANGED
|
@@ -7,6 +7,7 @@ import {dirname, isAbsolute, join} from "node:path"
|
|
|
7
7
|
|
|
8
8
|
const MAX_CREDENTIAL_BYTES = 65_536
|
|
9
9
|
const MAX_TOKEN_BYTES = 8_192
|
|
10
|
+
const CURRENT_TOKEN_SAFETY_MARGIN_SECONDS = 30
|
|
10
11
|
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"
|
|
11
12
|
const TOKEN_URL = "https://auth.kimi.com/api/oauth/token"
|
|
12
13
|
const EXACT_KEYS = ["access_token", "expires_at", "expires_in", "refresh_token", "scope", "token_type"]
|
|
@@ -117,6 +118,19 @@ export async function openKimiOAuthStore(options) {
|
|
|
117
118
|
refreshInFlight = operation
|
|
118
119
|
return operation
|
|
119
120
|
},
|
|
121
|
+
/**
|
|
122
|
+
* Non-mutating read for observational callers: returns the already-loaded
|
|
123
|
+
* access token only when it stays valid beyond a fixed 30-second safety
|
|
124
|
+
* margin. It never fetches, refreshes, persists, renames, chmods, or
|
|
125
|
+
* tombstones; logged-out credentials fail with the fixed needs-login error
|
|
126
|
+
* and expired or near-expiry credentials fail with the fixed
|
|
127
|
+
* refresh-required error.
|
|
128
|
+
*/
|
|
129
|
+
async getCurrentAccessToken() {
|
|
130
|
+
if (credential.access_token.length === 0) throw needsLogin()
|
|
131
|
+
if (credential.expires_at - now() <= CURRENT_TOKEN_SAFETY_MARGIN_SECONDS) throw refreshRequired()
|
|
132
|
+
return credential.access_token
|
|
133
|
+
},
|
|
120
134
|
async health() {
|
|
121
135
|
return credential.access_token.length === 0
|
|
122
136
|
? {ready: false, needsLogin: true}
|
|
@@ -263,5 +277,7 @@ function record(value) { return typeof value === "object" && value !== null && !
|
|
|
263
277
|
function unavailable() { return new Error("Kimi OAuth credential file is unavailable") }
|
|
264
278
|
/** @returns {Error} */
|
|
265
279
|
function needsLogin() { return new Error("Kimi OAuth credential was rejected; re-login required") }
|
|
280
|
+
/** @returns {Error} */
|
|
281
|
+
function refreshRequired() { return new Error("Kimi OAuth credential requires refresh") }
|
|
266
282
|
/** @param {unknown} [cause] @returns {Error} */
|
|
267
283
|
function refreshUnavailable(cause) { return new Error("Kimi OAuth refresh is unavailable", cause === undefined ? undefined : {cause}) }
|