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
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/* eslint-disable jsdoc/require-jsdoc */
|
|
3
|
+
|
|
4
|
+
import {constants} from "node:fs"
|
|
5
|
+
import {open} from "node:fs/promises"
|
|
6
|
+
import {createServer} from "node:http"
|
|
7
|
+
import {bearerToken, createGrantStore, validateBrokerRequest, validateResponsesBody} from "./model-broker-policy.js"
|
|
8
|
+
import {abortable, readResponseCapped} from "./absolute-deadline.js"
|
|
9
|
+
|
|
10
|
+
const MAX_BODY_BYTES = 2_097_152
|
|
11
|
+
const MAX_SECRET_BYTES = 65_536
|
|
12
|
+
|
|
13
|
+
/** @param {{environment?: NodeJS.ProcessEnv, fetchImplementation?: typeof fetch}} [options] */
|
|
14
|
+
export async function startModelBroker(options = {}) {
|
|
15
|
+
const environment = options.environment ?? process.env
|
|
16
|
+
const host = environment.THREADWIRE_MODEL_BROKER_HOST ?? "0.0.0.0"
|
|
17
|
+
const port = portValue(environment.THREADWIRE_MODEL_BROKER_PORT, 8789)
|
|
18
|
+
const adminToken = safeSetting(environment.THREADWIRE_MODEL_BROKER_ADMIN_TOKEN, "THREADWIRE_MODEL_BROKER_ADMIN_TOKEN")
|
|
19
|
+
const upstream = upstreamUrl(environment.THREADWIRE_CODEX_UPSTREAM_URL)
|
|
20
|
+
let credential = await readSecretFile(environment.THREADWIRE_CODEX_CREDENTIAL_FILE)
|
|
21
|
+
let generation = 1
|
|
22
|
+
let reloading = false
|
|
23
|
+
let stopping = false
|
|
24
|
+
const shutdown = new AbortController()
|
|
25
|
+
const controlSockets = new Set()
|
|
26
|
+
const grants = createGrantStore()
|
|
27
|
+
/** @type {Map<string, import("node:http").Server>} */
|
|
28
|
+
const listeners = new Map()
|
|
29
|
+
/** @type {Map<string, {controller: AbortController, timer: NodeJS.Timeout, sockets: Set<import("node:net").Socket>}>} */
|
|
30
|
+
const grantRuntimes = new Map()
|
|
31
|
+
const fetchImplementation = options.fetchImplementation ?? fetch
|
|
32
|
+
const revokeGrant = async (token) => {
|
|
33
|
+
grants.revoke(token)
|
|
34
|
+
const runtime = grantRuntimes.get(token)
|
|
35
|
+
grantRuntimes.delete(token)
|
|
36
|
+
if (runtime !== undefined) {
|
|
37
|
+
clearTimeout(runtime.timer)
|
|
38
|
+
runtime.controller.abort(new Error("Broker grant expired"))
|
|
39
|
+
for (const socket of runtime.sockets) socket.destroy()
|
|
40
|
+
runtime.sockets.clear()
|
|
41
|
+
}
|
|
42
|
+
await closeListener(listeners, token, 1000)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const server = createServer(async (request, response) => {
|
|
46
|
+
try {
|
|
47
|
+
if (stopping) throw new Error("Model broker is shutting down")
|
|
48
|
+
if (request.method === "GET" && request.url === "/healthz") {
|
|
49
|
+
response.writeHead(200, {"content-type": "application/json"})
|
|
50
|
+
response.end(JSON.stringify({ok: true, generation}))
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (request.url === "/admin/grants" && request.method === "POST") {
|
|
54
|
+
requireAdmin(request.headers.authorization, adminToken)
|
|
55
|
+
if (reloading) throw new Error("Model broker reload is active")
|
|
56
|
+
const body = await readJson(request, shutdown.signal)
|
|
57
|
+
const grant = parseGrant(body)
|
|
58
|
+
const expiresAt = Date.now() + grant.ttlMs
|
|
59
|
+
const token = grants.issue(grant)
|
|
60
|
+
const controller = new AbortController()
|
|
61
|
+
const sockets = new Set()
|
|
62
|
+
const listener = createServer((workerRequest, workerResponse) => {
|
|
63
|
+
proxyWorkerRequest(workerRequest, workerResponse, {
|
|
64
|
+
grants, token, grant, credential: () => credential, upstream, fetchImplementation,
|
|
65
|
+
signal: controller.signal
|
|
66
|
+
}).catch((error) => {
|
|
67
|
+
if (workerResponse.destroyed) return
|
|
68
|
+
process.stderr.write(`threadwire-model-broker: ${error instanceof Error ? error.message : "request failed"}\n`)
|
|
69
|
+
const status = isClientError(error) ? 403 : 502
|
|
70
|
+
sendJson(workerResponse, status, {error: status === 403 ? "request denied" : "model broker unavailable"})
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
listener.on("connection", (socket) => {
|
|
74
|
+
if (controller.signal.aborted) {
|
|
75
|
+
socket.destroy()
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
sockets.add(socket)
|
|
79
|
+
socket.once("close", () => sockets.delete(socket))
|
|
80
|
+
})
|
|
81
|
+
try {
|
|
82
|
+
await listen(listener, 0, grant.brokerAddress)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
grants.revoke(token)
|
|
85
|
+
throw error
|
|
86
|
+
}
|
|
87
|
+
listeners.set(token, listener)
|
|
88
|
+
const expiryTimer = setTimeout(() => { void revokeGrant(token) }, Math.max(1, expiresAt - Date.now()))
|
|
89
|
+
expiryTimer.unref()
|
|
90
|
+
grantRuntimes.set(token, {controller, timer: expiryTimer, sockets})
|
|
91
|
+
const address = listener.address()
|
|
92
|
+
if (address === null || typeof address === "string") throw new Error("Invalid broker listener")
|
|
93
|
+
sendJson(response, 201, {token, generation, port: address.port})
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (request.url?.startsWith("/admin/grants/") && request.method === "DELETE") {
|
|
97
|
+
requireAdmin(request.headers.authorization, adminToken)
|
|
98
|
+
const token = decodeURIComponent(request.url.slice("/admin/grants/".length))
|
|
99
|
+
await revokeGrant(token)
|
|
100
|
+
response.writeHead(204)
|
|
101
|
+
response.end()
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (request.url?.startsWith("/admin/grants/") && request.url.endsWith("/activate") && request.method === "POST") {
|
|
105
|
+
requireAdmin(request.headers.authorization, adminToken)
|
|
106
|
+
const token = decodeURIComponent(request.url.slice("/admin/grants/".length, -"/activate".length))
|
|
107
|
+
grants.activate(token)
|
|
108
|
+
sendJson(response, 200, {ok: true})
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
if (request.url === "/admin/reload" && request.method === "POST") {
|
|
112
|
+
requireAdmin(request.headers.authorization, adminToken)
|
|
113
|
+
if (reloading) throw new Error("Model broker reload is active")
|
|
114
|
+
reloading = true
|
|
115
|
+
try {
|
|
116
|
+
const replacement = await readSecretFile(environment.THREADWIRE_CODEX_CREDENTIAL_FILE)
|
|
117
|
+
grants.clear()
|
|
118
|
+
await Promise.all([...listeners.keys()].map(revokeGrant))
|
|
119
|
+
credential = replacement
|
|
120
|
+
generation += 1
|
|
121
|
+
} finally {
|
|
122
|
+
reloading = false
|
|
123
|
+
}
|
|
124
|
+
sendJson(response, 200, {ok: true, generation})
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
sendJson(response, 403, {error: "request denied"})
|
|
128
|
+
} catch (error) {
|
|
129
|
+
const status = isClientError(error) ? 403 : 502
|
|
130
|
+
sendJson(response, status, {error: status === 403 ? "request denied" : "model broker unavailable"})
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
server.on("connection", (socket) => {
|
|
134
|
+
if (shutdown.signal.aborted) return socket.destroy()
|
|
135
|
+
controlSockets.add(socket)
|
|
136
|
+
socket.once("close", () => controlSockets.delete(socket))
|
|
137
|
+
})
|
|
138
|
+
await listen(server, port, host)
|
|
139
|
+
return {
|
|
140
|
+
server,
|
|
141
|
+
close: async () => {
|
|
142
|
+
stopping = true
|
|
143
|
+
shutdown.abort(new Error("Model broker is shutting down"))
|
|
144
|
+
for (const socket of controlSockets) socket.destroy()
|
|
145
|
+
await Promise.all([...listeners.keys()].map(revokeGrant))
|
|
146
|
+
await Promise.race([
|
|
147
|
+
new Promise((resolve) => server.close(() => resolve(undefined))),
|
|
148
|
+
new Promise((resolve) => {
|
|
149
|
+
const timer = setTimeout(resolve, 1000)
|
|
150
|
+
timer.unref()
|
|
151
|
+
})
|
|
152
|
+
])
|
|
153
|
+
},
|
|
154
|
+
activeListeners: () => listeners.size,
|
|
155
|
+
config: {host, port, upstream: upstream.toString()}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @param {unknown} value */
|
|
160
|
+
function parseGrant(value) {
|
|
161
|
+
if (!isRecord(value) || value.provider !== "codex" || typeof value.runId !== "string" || typeof value.networkId !== "string" || typeof value.model !== "string" || typeof value.brokerAddress !== "string") {
|
|
162
|
+
throw new Error("Invalid broker grant")
|
|
163
|
+
}
|
|
164
|
+
if (!/^(?:\d{1,3}\.){3}\d{1,3}$/u.test(value.brokerAddress)) throw new Error("Invalid broker grant")
|
|
165
|
+
const ttlMs = value.ttlMs
|
|
166
|
+
if (typeof ttlMs !== "number") throw new Error("Invalid broker grant")
|
|
167
|
+
return {provider: "codex", runId: value.runId, networkId: value.networkId, model: value.model, brokerAddress: value.brokerAddress, ttlMs}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function proxyWorkerRequest(request, response, options) {
|
|
171
|
+
throwIfAborted(options.signal)
|
|
172
|
+
const policyHeaders = {...request.headers}
|
|
173
|
+
delete policyHeaders.host
|
|
174
|
+
delete policyHeaders.connection
|
|
175
|
+
delete policyHeaders["transfer-encoding"]
|
|
176
|
+
validateBrokerRequest({method: request.method, path: request.url, headers: policyHeaders})
|
|
177
|
+
const suppliedToken = bearerToken(request.headers.authorization)
|
|
178
|
+
if (suppliedToken !== options.token) throw new Error("Broker grant token mismatch")
|
|
179
|
+
const grant = options.grants.authorize(suppliedToken, {
|
|
180
|
+
provider: "codex", networkId: options.grant.networkId, runId: options.grant.runId
|
|
181
|
+
})
|
|
182
|
+
const body = await readBody(request, options.signal)
|
|
183
|
+
throwIfAborted(options.signal)
|
|
184
|
+
options.grants.authorize(suppliedToken, {
|
|
185
|
+
provider: "codex", networkId: options.grant.networkId, runId: options.grant.runId
|
|
186
|
+
})
|
|
187
|
+
validateResponsesBody(body, grant.model)
|
|
188
|
+
throwIfAborted(options.signal)
|
|
189
|
+
const upstreamResponse = await abortable(options.fetchImplementation(new URL("/v1/responses", options.upstream), {
|
|
190
|
+
method: "POST",
|
|
191
|
+
redirect: "error",
|
|
192
|
+
headers: {
|
|
193
|
+
authorization: `Bearer ${options.credential()}`,
|
|
194
|
+
"content-type": "application/json",
|
|
195
|
+
"user-agent": "threadwire-model-broker/1"
|
|
196
|
+
},
|
|
197
|
+
body,
|
|
198
|
+
signal: options.signal
|
|
199
|
+
}), options.signal)
|
|
200
|
+
throwIfAborted(options.signal)
|
|
201
|
+
const bytes = await readResponseCapped(upstreamResponse, MAX_BODY_BYTES, options.signal)
|
|
202
|
+
throwIfAborted(options.signal)
|
|
203
|
+
response.writeHead(upstreamResponse.status, {"content-type": safeContentType(upstreamResponse.headers.get("content-type"))})
|
|
204
|
+
response.end(bytes)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function closeListener(listeners, token, timeoutMs) {
|
|
208
|
+
const listener = listeners.get(token)
|
|
209
|
+
listeners.delete(token)
|
|
210
|
+
if (listener === undefined) return
|
|
211
|
+
await Promise.race([
|
|
212
|
+
new Promise((resolve) => listener.close(() => resolve(undefined))),
|
|
213
|
+
new Promise((resolve) => {
|
|
214
|
+
const timer = setTimeout(resolve, timeoutMs)
|
|
215
|
+
timer.unref()
|
|
216
|
+
})
|
|
217
|
+
])
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function readSecretFile(path) {
|
|
221
|
+
const safePath = safeSetting(path, "THREADWIRE_CODEX_CREDENTIAL_FILE")
|
|
222
|
+
let handle
|
|
223
|
+
try {
|
|
224
|
+
handle = await open(safePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
225
|
+
const metadata = await handle.stat()
|
|
226
|
+
if (!metadata.isFile() || metadata.size < 1 || metadata.size > MAX_SECRET_BYTES || (metadata.mode & 0o022) !== 0) throw new Error()
|
|
227
|
+
const value = (await handle.readFile("utf8")).replace(/[\r\n]+$/u, "")
|
|
228
|
+
if (value.length === 0 || /[\r\n]/u.test(value)) throw new Error()
|
|
229
|
+
return value
|
|
230
|
+
} catch {
|
|
231
|
+
throw new Error("Codex credential file is unavailable")
|
|
232
|
+
} finally {
|
|
233
|
+
await handle?.close().catch(() => {})
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function readJson(request, signal) {
|
|
238
|
+
const body = await readBody(request, signal)
|
|
239
|
+
try {
|
|
240
|
+
return JSON.parse(body.toString("utf8"))
|
|
241
|
+
} catch {
|
|
242
|
+
throw new Error("Invalid request")
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function readBody(request, signal) {
|
|
247
|
+
const chunks = []
|
|
248
|
+
let size = 0
|
|
249
|
+
for await (const chunk of request) {
|
|
250
|
+
throwIfAborted(signal)
|
|
251
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
252
|
+
size += buffer.length
|
|
253
|
+
if (size > MAX_BODY_BYTES) throw new Error("Request exceeded capacity")
|
|
254
|
+
chunks.push(buffer)
|
|
255
|
+
}
|
|
256
|
+
throwIfAborted(signal)
|
|
257
|
+
return Buffer.concat(chunks)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function throwIfAborted(signal) {
|
|
261
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("Broker grant expired")
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function requireAdmin(value, expected) {
|
|
265
|
+
if (value !== `Bearer ${expected}`) throw new Error("Request denied")
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function upstreamUrl(value) {
|
|
269
|
+
const url = new URL(safeSetting(value, "THREADWIRE_CODEX_UPSTREAM_URL"))
|
|
270
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
271
|
+
throw new Error("THREADWIRE_CODEX_UPSTREAM_URL is invalid")
|
|
272
|
+
}
|
|
273
|
+
return url
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function safeSetting(value, name) {
|
|
277
|
+
if (typeof value !== "string" || value.length === 0 || /[\r\n]/u.test(value)) throw new Error(`${name} is required`)
|
|
278
|
+
return value
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function portValue(value, fallback) {
|
|
282
|
+
if (value === undefined) return fallback
|
|
283
|
+
const number = Number(value)
|
|
284
|
+
if (!/^\d+$/u.test(value) || !Number.isSafeInteger(number) || number < 1 || number > 65535) throw new Error("Invalid model broker port")
|
|
285
|
+
return number
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function safeContentType(value) {
|
|
289
|
+
return value && /^[\w.+-]+\/[\w.+-]+(?:;\s*charset=[\w-]+)?$/u.test(value) ? value : "application/json"
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function sendJson(response, status, value) {
|
|
293
|
+
response.writeHead(status, {"content-type": "application/json"})
|
|
294
|
+
response.end(JSON.stringify(value))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isClientError(error) {
|
|
298
|
+
return error instanceof Error && /denied|grant|Invalid request|capacity/u.test(error.message)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isRecord(value) {
|
|
302
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function listen(server, port, host) {
|
|
306
|
+
return new Promise((resolve, reject) => {
|
|
307
|
+
server.once("error", reject)
|
|
308
|
+
server.listen(port, host, () => {
|
|
309
|
+
server.removeListener("error", reject)
|
|
310
|
+
resolve(undefined)
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {readFile} from "node:fs/promises"
|
|
4
|
+
import {isAbsolute, relative} from "node:path"
|
|
5
|
+
|
|
6
|
+
/** @param {string} root @param {(path: string, encoding: "utf8") => Promise<string>} [reader] */
|
|
7
|
+
export async function assertNoNestedMounts(root, reader = readFile) {
|
|
8
|
+
const mountinfo = await reader("/proc/self/mountinfo", "utf8")
|
|
9
|
+
for (const line of mountinfo.split("\n")) {
|
|
10
|
+
if (line.length === 0) continue
|
|
11
|
+
const fields = line.split(" ")
|
|
12
|
+
const encoded = fields[4]
|
|
13
|
+
if (encoded === undefined) throw new Error("Mount topology unavailable")
|
|
14
|
+
const mountpoint = decodeMountinfo(encoded)
|
|
15
|
+
if (mountpoint !== root && within(root, mountpoint)) throw new Error("Nested worktree mounts are forbidden")
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @param {string} root @param {string} child */
|
|
20
|
+
function within(root, child) {
|
|
21
|
+
const path = relative(root, child)
|
|
22
|
+
return path !== "" && !path.startsWith("..") && !isAbsolute(path)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {string} value */
|
|
26
|
+
function decodeMountinfo(value) {
|
|
27
|
+
return value.replace(/\\(040|011|012|134)/gu, (_, octal) => String.fromCharCode(Number.parseInt(octal, 8)))
|
|
28
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {closeSync, fsyncSync, openSync, writeSync} from "node:fs"
|
|
4
|
+
import {dirname} from "node:path"
|
|
5
|
+
|
|
6
|
+
const filesystemOperations = {closeSync, fsyncSync, openSync, writeSync}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {object} FilesystemOperations
|
|
10
|
+
* @property {(fileDescriptor: number) => void} closeSync Close a transcript.
|
|
11
|
+
* @property {(fileDescriptor: number) => void} fsyncSync Sync a transcript.
|
|
12
|
+
* @property {(path: string, flags: string, mode?: number) => number} openSync Open a transcript or directory.
|
|
13
|
+
* @property {(fileDescriptor: number, buffer: Buffer, offset: number, length: number) => number} writeSync
|
|
14
|
+
* Write transcript bytes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export class NormalizedOutput {
|
|
18
|
+
/**
|
|
19
|
+
* @param {Pick<NodeJS.WritableStream, "write">} output
|
|
20
|
+
* @param {string | undefined} transcriptPath
|
|
21
|
+
* @param {FilesystemOperations} [filesystem]
|
|
22
|
+
*/
|
|
23
|
+
constructor(output, transcriptPath, filesystem = filesystemOperations) {
|
|
24
|
+
this.output = output
|
|
25
|
+
this.filesystem = filesystem
|
|
26
|
+
this.transcriptDirectory = transcriptPath === undefined ? undefined : dirname(transcriptPath)
|
|
27
|
+
this.fileDescriptor = transcriptPath === undefined
|
|
28
|
+
? undefined
|
|
29
|
+
: this.filesystem.openSync(transcriptPath, "wx", 0o600)
|
|
30
|
+
this.closed = false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {string} record */
|
|
34
|
+
write(record) {
|
|
35
|
+
if (this.closed) throw new Error("Normalized output is closed")
|
|
36
|
+
if (this.fileDescriptor !== undefined) {
|
|
37
|
+
const bytes = Buffer.from(record)
|
|
38
|
+
let offset = 0
|
|
39
|
+
while (offset < bytes.length) {
|
|
40
|
+
const written = this.filesystem.writeSync(
|
|
41
|
+
this.fileDescriptor,
|
|
42
|
+
bytes,
|
|
43
|
+
offset,
|
|
44
|
+
bytes.length - offset
|
|
45
|
+
)
|
|
46
|
+
if (written === 0) throw new Error("Transcript write made zero bytes of progress")
|
|
47
|
+
offset += written
|
|
48
|
+
}
|
|
49
|
+
this.filesystem.fsyncSync(this.fileDescriptor)
|
|
50
|
+
const directoryDescriptor = this.filesystem.openSync(
|
|
51
|
+
/** @type {string} */ (this.transcriptDirectory),
|
|
52
|
+
"r"
|
|
53
|
+
)
|
|
54
|
+
try {
|
|
55
|
+
this.filesystem.fsyncSync(directoryDescriptor)
|
|
56
|
+
} finally {
|
|
57
|
+
this.filesystem.closeSync(directoryDescriptor)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
this.output.write(record)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
close() {
|
|
64
|
+
if (this.closed) return
|
|
65
|
+
this.closed = true
|
|
66
|
+
if (this.fileDescriptor !== undefined) this.filesystem.closeSync(this.fileDescriptor)
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/notice-queue.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* A single unit of work handed to the transport: either a batched/chunked text
|
|
5
5
|
* message, or a tool-activity message that must be correlated by `activity.key`
|
|
6
6
|
* so its "finished" phase edits the exact Telegram message its "started" sent.
|
|
7
|
-
* @typedef {{message: string | import("./types.js").OutgoingMessage, activity?: {key: string, phase: "started" | "finished"}}} Delivery
|
|
7
|
+
* @typedef {{message: string | import("./types.js").OutgoingMessage, metricClass?: string, activity?: {key: string, phase: "started" | "finished"}}} Delivery
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const DEFAULT_MAX_LENGTH = 3500
|
|
@@ -13,10 +13,11 @@ const DEFAULT_MAX_PENDING_BYTES = 262_144
|
|
|
13
13
|
|
|
14
14
|
export class NoticeQueue {
|
|
15
15
|
/**
|
|
16
|
-
* @param {{sender: import("./types.js").NoticeSender, minimumIntervalMs?: number, batchWindowMs?: number, maxLength?: number, maxPendingNotices?: number, maxPendingBytes?: number, sleep?: (milliseconds: number) => Promise<void>, now?: () => number, random?: () => number}} options
|
|
16
|
+
* @param {{sender: import("./types.js").NoticeSender, onDelivered?: (metricClass: string, bytes: number) => void, minimumIntervalMs?: number, batchWindowMs?: number, maxLength?: number, maxPendingNotices?: number, maxPendingBytes?: number, sleep?: (milliseconds: number) => Promise<void>, now?: () => number, random?: () => number}} options
|
|
17
17
|
*/
|
|
18
18
|
constructor(options) {
|
|
19
19
|
this.sender = options.sender
|
|
20
|
+
this.onDelivered = options.onDelivered
|
|
20
21
|
this.minimumIntervalMs = nonnegativeSafeInteger(options.minimumIntervalMs ?? 1000, "minimumIntervalMs")
|
|
21
22
|
this.batchWindowMs = nonnegativeSafeInteger(options.batchWindowMs ?? 0, "batchWindowMs")
|
|
22
23
|
this.maxLength = positiveSafeInteger(options.maxLength ?? DEFAULT_MAX_LENGTH, "maxLength")
|
|
@@ -85,19 +86,19 @@ export class NoticeQueue {
|
|
|
85
86
|
takeBatch() {
|
|
86
87
|
const first = this.shiftPending()
|
|
87
88
|
if (!first) return {message: ""}
|
|
88
|
-
if ("activity" in first) return {message: this.capActivity(first), activity: first.activity}
|
|
89
|
-
if (first.format === "javascript-code") return {message: this.takeCodeBatch(first)}
|
|
89
|
+
if ("activity" in first) return {message: this.capActivity(first), activity: first.activity, ...metricClass(first)}
|
|
90
|
+
if (first.format === "javascript-code") return {message: this.takeCodeBatch(first), ...metricClass(first)}
|
|
90
91
|
let text = first.text
|
|
91
92
|
const batchSeparator = first.batchSeparator ?? "\n"
|
|
92
93
|
while (this.pending.length > 0) {
|
|
93
94
|
const next = this.pending[0]
|
|
94
|
-
if (!next || "activity" in next || next.format !== undefined || next.label !== first.label || (next.batchSeparator ?? "\n") !== batchSeparator) break
|
|
95
|
+
if (!next || "activity" in next || next.metricClass !== first.metricClass || next.format !== undefined || next.label !== first.label || (next.batchSeparator ?? "\n") !== batchSeparator) break
|
|
95
96
|
const combined = `${text}${batchSeparator}${next.text}`
|
|
96
97
|
if (combined.length > this.maxLength) break
|
|
97
98
|
this.shiftPending()
|
|
98
99
|
text = combined
|
|
99
100
|
}
|
|
100
|
-
if (text.length <= this.maxLength) return {message: text}
|
|
101
|
+
if (text.length <= this.maxLength) return {message: text, ...metricClass(first)}
|
|
101
102
|
const label = first.label ?? /^\[P\d+\] /u.exec(text)?.[0] ?? ""
|
|
102
103
|
if (label.length >= this.maxLength) throw new NoticeQueueChunkingError()
|
|
103
104
|
let end = safeChunkEnd(text, this.maxLength)
|
|
@@ -110,7 +111,7 @@ export class NoticeQueue {
|
|
|
110
111
|
if (end <= label.length) throw new NoticeQueueChunkingError()
|
|
111
112
|
const remainder = `${label}${text.slice(remainderStart)}`
|
|
112
113
|
this.unshiftPending({...first, text: remainder})
|
|
113
|
-
return {message: text.slice(0, end)}
|
|
114
|
+
return {message: text.slice(0, end), ...metricClass(first)}
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
/**
|
|
@@ -196,10 +197,12 @@ export class NoticeQueue {
|
|
|
196
197
|
async deliver(delivery) {
|
|
197
198
|
if (delivery.activity === undefined) {
|
|
198
199
|
await this.withRetry(() => this.sender.send(delivery.message))
|
|
200
|
+
this.noteDelivered(delivery)
|
|
199
201
|
return
|
|
200
202
|
}
|
|
201
203
|
if (delivery.activity.phase === "started") {
|
|
202
204
|
const sent = await this.withRetry(() => this.sender.send(delivery.message))
|
|
205
|
+
this.noteDelivered(delivery)
|
|
203
206
|
const messageId = deliveredMessageId(sent)
|
|
204
207
|
if (messageId !== undefined) this.activityMessageIds.set(delivery.activity.key, messageId)
|
|
205
208
|
return
|
|
@@ -219,11 +222,18 @@ export class NoticeQueue {
|
|
|
219
222
|
// swallow, so only an unrecoverable edit is dropped.
|
|
220
223
|
try {
|
|
221
224
|
await this.withRetry(() => edit(messageId, delivery.message))
|
|
225
|
+
this.noteDelivered(delivery)
|
|
222
226
|
} catch {
|
|
223
227
|
// Intentionally non-fatal: a failed completion edit never poisons the queue.
|
|
224
228
|
}
|
|
225
229
|
}
|
|
226
230
|
|
|
231
|
+
/** @param {Delivery} delivery */
|
|
232
|
+
noteDelivered(delivery) {
|
|
233
|
+
if (delivery.metricClass === undefined) return
|
|
234
|
+
this.onDelivered?.(delivery.metricClass, Buffer.byteLength(JSON.stringify(delivery.message), "utf8"))
|
|
235
|
+
}
|
|
236
|
+
|
|
227
237
|
/** @template T @param {() => Promise<T>} operation @returns {Promise<T>} */
|
|
228
238
|
async withRetry(operation) {
|
|
229
239
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
@@ -241,6 +251,11 @@ export class NoticeQueue {
|
|
|
241
251
|
}
|
|
242
252
|
}
|
|
243
253
|
|
|
254
|
+
/** @param {import("./types.js").RenderedNotice} notice */
|
|
255
|
+
function metricClass(notice) {
|
|
256
|
+
return notice.metricClass === undefined ? {} : {metricClass: notice.metricClass}
|
|
257
|
+
}
|
|
258
|
+
|
|
244
259
|
/** @param {import("./types.js").SentMessage | void} sent @returns {number | undefined} */
|
|
245
260
|
function deliveredMessageId(sent) {
|
|
246
261
|
if (sent !== undefined && Number.isSafeInteger(sent.messageId)) return sent.messageId
|
|
@@ -18,12 +18,11 @@ export function buildOpenCodeCommand(providerArguments, prompt, resumeSession, e
|
|
|
18
18
|
export function createOpenCodeSessionId() {
|
|
19
19
|
let emitted = false
|
|
20
20
|
return (record) => {
|
|
21
|
-
if (emitted
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return id
|
|
21
|
+
if (emitted) return undefined
|
|
22
|
+
const {sessionId, trusted} = recognizeOpenCodeRecord(record, () => [])
|
|
23
|
+
if (!trusted || sessionId === undefined) return undefined
|
|
24
|
+
emitted = true
|
|
25
|
+
return sessionId
|
|
27
26
|
}
|
|
28
27
|
}
|
|
29
28
|
|
|
@@ -31,29 +30,47 @@ export function createOpenCodeSessionId() {
|
|
|
31
30
|
export function createOpenCodeParser() {
|
|
32
31
|
let started = false
|
|
33
32
|
const activeToolKeys = new Set()
|
|
34
|
-
return (record) => filterRepeatedToolStarts(
|
|
33
|
+
return (record) => filterRepeatedToolStarts(recognizeOpenCodeRecord(record, () => {
|
|
35
34
|
if (started) return []
|
|
36
35
|
started = true
|
|
37
36
|
return [{type: "lifecycle", phase: "started", summary: "OpenCode worker started"}]
|
|
38
|
-
}), activeToolKeys)
|
|
37
|
+
}).events, activeToolKeys)
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
/** @param {unknown} record @returns {import("../types.js").WorkerEvent[]} */
|
|
42
41
|
export function parseOpenCodeEvent(record) {
|
|
43
|
-
return
|
|
42
|
+
return recognizeOpenCodeRecord(record, () => [{type: "lifecycle", phase: "started", summary: "OpenCode worker started"}]).events
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
/**
|
|
46
|
+
* @param {unknown} record
|
|
47
|
+
* @param {() => import("../types.js").WorkerEvent[]} start
|
|
48
|
+
* @returns {{trusted: boolean, sessionId: string | undefined, events: import("../types.js").WorkerEvent[]}}
|
|
49
|
+
*/
|
|
50
|
+
function recognizeOpenCodeRecord(record, start) {
|
|
51
|
+
if (!isRecord(record) || typeof record.type !== "string") return {trusted: false, sessionId: undefined, events: []}
|
|
52
|
+
const part = isRecord(record.part) ? record.part : undefined
|
|
53
|
+
if (record.type === "step_start" && part?.type === "step-start") {
|
|
54
|
+
return {trusted: true, sessionId: sessionIdForKnownRecord(record, part), events: start()}
|
|
55
|
+
}
|
|
56
|
+
if (record.type === "step_finish" && part?.type === "step-finish") {
|
|
57
|
+
return {trusted: true, sessionId: sessionIdForKnownRecord(record, part), events: []}
|
|
58
|
+
}
|
|
59
|
+
if (record.type === "error" && part?.type === "error") {
|
|
60
|
+
return {trusted: true, sessionId: sessionIdForKnownRecord(record, part), events: [{type: "lifecycle", phase: "failed", summary: "OpenCode worker failed"}]}
|
|
61
|
+
}
|
|
52
62
|
if (record.type === "text" && isRecord(record.part) && typeof record.part.text === "string") {
|
|
53
|
-
return
|
|
63
|
+
return {
|
|
64
|
+
trusted: true,
|
|
65
|
+
sessionId: sessionIdForKnownRecord(record, record.part),
|
|
66
|
+
events: [{type: "text-delta", text: record.part.text, streamId: stringValue(record.part.id, "opencode:assistant")}]
|
|
67
|
+
}
|
|
54
68
|
}
|
|
55
|
-
if (record.type === "tool_use" && isRecord(record.part))
|
|
56
|
-
|
|
69
|
+
if (record.type === "tool_use" && isRecord(record.part)) {
|
|
70
|
+
const events = parseTool(record.part)
|
|
71
|
+
if (events.length > 0) return {trusted: true, sessionId: sessionIdForKnownRecord(record, record.part), events}
|
|
72
|
+
}
|
|
73
|
+
return {trusted: false, sessionId: undefined, events: []}
|
|
57
74
|
}
|
|
58
75
|
|
|
59
76
|
/** @param {Record<string, unknown>} part @returns {import("../types.js").WorkerEvent[]} */
|
|
@@ -104,6 +121,13 @@ function stringValue(value, fallback) {
|
|
|
104
121
|
return typeof value === "string" ? value : fallback
|
|
105
122
|
}
|
|
106
123
|
|
|
124
|
+
/** @param {Record<string, unknown>} record @param {Record<string, unknown> | undefined} [part] */
|
|
125
|
+
function sessionIdForKnownRecord(record, part) {
|
|
126
|
+
if (typeof record.sessionID === "string") return record.sessionID
|
|
127
|
+
if (part !== undefined && typeof part.sessionID === "string") return part.sessionID
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
|
|
107
131
|
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
108
132
|
function isRecord(value) {
|
|
109
133
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u
|
|
4
|
+
const COLORS = new Set(["always", "never", "auto"])
|
|
5
|
+
|
|
6
|
+
/** @param {string[]} arguments_ */
|
|
7
|
+
export function validateRelayWriteProviderArguments(arguments_) {
|
|
8
|
+
/** @type {string[]} */
|
|
9
|
+
const normalized = []
|
|
10
|
+
let model = "gpt-5-codex"
|
|
11
|
+
for (let index = 0; index < arguments_.length; index += 1) {
|
|
12
|
+
const argument = /** @type {string} */ (arguments_[index])
|
|
13
|
+
if (argument === "--model" || argument === "-m") {
|
|
14
|
+
const value = arguments_[index + 1]
|
|
15
|
+
if (value === undefined || !MODEL_PATTERN.test(value)) throw new Error("Provider argument is unavailable in relay write mode")
|
|
16
|
+
model = value
|
|
17
|
+
normalized.push("--model", value)
|
|
18
|
+
index += 1
|
|
19
|
+
continue
|
|
20
|
+
}
|
|
21
|
+
if (argument.startsWith("--model=")) {
|
|
22
|
+
const value = argument.slice("--model=".length)
|
|
23
|
+
if (!MODEL_PATTERN.test(value)) throw new Error("Provider argument is unavailable in relay write mode")
|
|
24
|
+
model = value
|
|
25
|
+
normalized.push("--model", value)
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
if (argument === "--color") {
|
|
29
|
+
const value = arguments_[index + 1]
|
|
30
|
+
if (value === undefined || !COLORS.has(value)) throw new Error("Provider argument is unavailable in relay write mode")
|
|
31
|
+
normalized.push("--color", value)
|
|
32
|
+
index += 1
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (argument.startsWith("--color=")) {
|
|
36
|
+
const value = argument.slice("--color=".length)
|
|
37
|
+
if (!COLORS.has(value)) throw new Error("Provider argument is unavailable in relay write mode")
|
|
38
|
+
normalized.push("--color", value)
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
throw new Error("Provider argument is unavailable in relay write mode")
|
|
42
|
+
}
|
|
43
|
+
return {arguments: normalized, model}
|
|
44
|
+
}
|