@coreplane/switchboard 1.210.0 → 1.212.0
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/dist/assets/deploy/cloudflare-resident/worker.ts +68 -20
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +19 -5
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/project.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +22 -0
- package/dist/assets/src/core/runEvents.ts +33 -1
- package/dist/assets/src/core/runFriction.ts +6 -5
- package/dist/assets/src/core/trace/streamSpans.ts +3 -0
- package/dist/assets/src/execution/binaryRead.ts +56 -14
- package/dist/assets/src/execution/residentStepReport.ts +4 -0
- package/dist/assets/web/dist/.vite/manifest.json +18 -18
- package/dist/assets/web/dist/assets/{ResidentDetailPage-CV3wfAIP.js → ResidentDetailPage-CM5nWw-Z.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-CLkWc50b.js → ResidentsIndexPage-BKBnuvp3.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-CQYRfQ_B.js → RunRoutePage-Bu4CwEzt.js} +4 -4
- package/dist/assets/web/dist/assets/{RunsIndexPage-BLRPp_gk.js → RunsIndexPage-w2jJP5xu.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-C-VO4Ddl.js → ScheduledPage-D3-k9DPz.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-CIAoBB5Y.js → StatusDot-BmFHnV8m.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DEL1ic4g.js → Tooltip-DoThP2fW.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-CawBR4t8.js → dist-YrRKtxsS.js} +1 -1
- package/dist/assets/web/dist/assets/{main-CctUbVOl.js → main-D6nzMf0k.js} +2 -2
- package/dist/cli.js +698 -385
- package/package.json +1 -1
|
@@ -96,11 +96,14 @@ import {
|
|
|
96
96
|
} from "../../src/execution/residentDepCache.js";
|
|
97
97
|
import { parseWorktreeCleanliness, worktreeCleanlinessScript } from "../../src/execution/residentCleanliness.js";
|
|
98
98
|
import {
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
base64LengthOf,
|
|
100
|
+
chunkPlan,
|
|
101
101
|
MAX_READ_BYTES,
|
|
102
|
+
parseByteSize,
|
|
103
|
+
readChunkCommandFor,
|
|
102
104
|
readCommandFor,
|
|
103
105
|
readEncodingOf,
|
|
106
|
+
statCommandFor,
|
|
104
107
|
type Base64ReadAnswer,
|
|
105
108
|
type ReadEncoding,
|
|
106
109
|
} from "../../src/execution/binaryRead.js";
|
|
@@ -1692,7 +1695,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
1692
1695
|
private async run(
|
|
1693
1696
|
argv: readonly string[],
|
|
1694
1697
|
opts: { cwd?: string; timeoutMs?: number; env?: Record<string, string> } = {},
|
|
1695
|
-
): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
|
|
1698
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean; truncated?: boolean }> {
|
|
1696
1699
|
const timeout = opts.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
|
|
1697
1700
|
const launch = {
|
|
1698
1701
|
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
|
@@ -1727,7 +1730,15 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
1727
1730
|
}
|
|
1728
1731
|
try {
|
|
1729
1732
|
const out = await proc.output({ encoding: "utf8", timeout: timeout + 30_000 });
|
|
1730
|
-
|
|
1733
|
+
// `truncated` is the SDK saying the process log stream was cut past its
|
|
1734
|
+
// own retention — the output here is a prefix, whatever our caps say.
|
|
1735
|
+
return {
|
|
1736
|
+
stdout: out.stdout,
|
|
1737
|
+
stderr: out.stderr,
|
|
1738
|
+
exitCode: out.exitCode,
|
|
1739
|
+
timedOut: out.timedOut,
|
|
1740
|
+
truncated: out.truncated,
|
|
1741
|
+
};
|
|
1731
1742
|
} catch (err) {
|
|
1732
1743
|
if (isRuntimeReplacement(err)) {
|
|
1733
1744
|
this.swapIncarnation(); // the container this incarnation's memos described is gone
|
|
@@ -3931,7 +3942,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
3931
3942
|
timeoutMs: number,
|
|
3932
3943
|
capBytes?: number,
|
|
3933
3944
|
capFiles?: { out: string; err: string },
|
|
3934
|
-
): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
|
|
3945
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean; truncated?: boolean }> {
|
|
3935
3946
|
const injected = { GIT_TERMINAL_PROMPT: "0" };
|
|
3936
3947
|
validateEnvNames(injected);
|
|
3937
3948
|
const body = capBytes
|
|
@@ -3954,7 +3965,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
3954
3965
|
command: string,
|
|
3955
3966
|
timeoutMs: number,
|
|
3956
3967
|
charCap: number,
|
|
3957
|
-
): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
|
|
3968
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean; truncated?: boolean }> {
|
|
3958
3969
|
const capBytes = capBytesFor(charCap);
|
|
3959
3970
|
const files = execCapFiles();
|
|
3960
3971
|
const r = await this.threadRun(user, worktreePath, command, timeoutMs, capBytes, files);
|
|
@@ -5161,7 +5172,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5161
5172
|
if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
|
|
5162
5173
|
throw err;
|
|
5163
5174
|
}
|
|
5164
|
-
const truncated = r.stdout.length > EXEC_OUTPUT_CAP || r.stderr.length > EXEC_OUTPUT_CAP;
|
|
5175
|
+
const truncated = r.stdout.length > EXEC_OUTPUT_CAP || r.stderr.length > EXEC_OUTPUT_CAP || r.truncated === true;
|
|
5165
5176
|
const notes: string[] = [];
|
|
5166
5177
|
if (r.timedOut)
|
|
5167
5178
|
notes.push(
|
|
@@ -5201,31 +5212,68 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5201
5212
|
const resolved = confineThreadPath(pre.binding.worktreePath, path);
|
|
5202
5213
|
if (!resolved)
|
|
5203
5214
|
return { error: `path-escape: ${JSON.stringify(path)} does not stay inside the thread worktree`, status: 400 };
|
|
5204
|
-
|
|
5215
|
+
if (encoding === "base64") return this.readThreadBytes(pre.binding, resolved);
|
|
5205
5216
|
let r: Awaited<ReturnType<ResidentDO["threadRun"]>>;
|
|
5206
5217
|
try {
|
|
5207
5218
|
r = await this.threadRun(
|
|
5208
5219
|
pre.binding.user,
|
|
5209
5220
|
pre.binding.worktreePath,
|
|
5210
|
-
readCommandFor(
|
|
5221
|
+
readCommandFor(resolved),
|
|
5211
5222
|
DEFAULT_EXEC_TIMEOUT_MS,
|
|
5212
|
-
capBytesFor(
|
|
5223
|
+
capBytesFor(READ_CONTENT_CAP),
|
|
5213
5224
|
);
|
|
5214
5225
|
} catch (err) {
|
|
5215
5226
|
if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
|
|
5216
5227
|
throw err;
|
|
5217
5228
|
}
|
|
5218
5229
|
if (r.exitCode !== 0 || r.timedOut) return { error: `read-failed: ${describeStepFailure(r)}`, status: 404 };
|
|
5219
|
-
const truncated = r.stdout.length >
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5230
|
+
const truncated = r.stdout.length > READ_CONTENT_CAP || r.truncated === true;
|
|
5231
|
+
return { content: truncated ? r.stdout.slice(0, READ_CONTENT_CAP) : r.stdout, truncated };
|
|
5232
|
+
}
|
|
5233
|
+
|
|
5234
|
+
/** The bytes of a confined file as base64, as the thread user, in chunks
|
|
5235
|
+
* (src/execution/binaryRead.ts): one command's stdout crosses the SDK's
|
|
5236
|
+
* process log stream, which cuts a stream past a retention limit far below
|
|
5237
|
+
* the binary cap and says so only through `truncated` — a 12 MB file once
|
|
5238
|
+
* came back as 1.7 MB and was handed on as complete. So: `stat` first (the
|
|
5239
|
+
* cap is judged on the size, before any read), then chunks small enough
|
|
5240
|
+
* that no stream is ever cut; a chunk the SDK still flags, or one whose
|
|
5241
|
+
* length is not what the size promised, fails the read by name. The answer
|
|
5242
|
+
* carries the size for the client to check the decoded bytes against. */
|
|
5243
|
+
private async readThreadBytes(
|
|
5244
|
+
binding: { user: string; worktreePath: string },
|
|
5245
|
+
resolved: string,
|
|
5246
|
+
): Promise<Base64ReadAnswer | ThreadErr> {
|
|
5247
|
+
const run = (command: string, capChars: number) =>
|
|
5248
|
+
this.threadRun(binding.user, binding.worktreePath, command, DEFAULT_EXEC_TIMEOUT_MS, capBytesFor(capChars));
|
|
5249
|
+
try {
|
|
5250
|
+
const stat = await run(statCommandFor(resolved), 64);
|
|
5251
|
+
if (stat.exitCode !== 0 || stat.timedOut)
|
|
5252
|
+
return { error: `read-failed: ${describeStepFailure(stat)}`, status: 404 };
|
|
5253
|
+
const size = parseByteSize(stat.stdout);
|
|
5254
|
+
if (size === null)
|
|
5255
|
+
return { error: `read-failed: stat answered ${JSON.stringify(stat.stdout.slice(0, 64))}`, status: 500 };
|
|
5256
|
+
if (size > MAX_READ_BYTES) return { encoding: "base64", tooLarge: true };
|
|
5257
|
+
const chunks = chunkPlan(size);
|
|
5258
|
+
const parts: string[] = [];
|
|
5259
|
+
for (const [i, chunk] of chunks.entries()) {
|
|
5260
|
+
const expected = base64LengthOf(chunk.length);
|
|
5261
|
+
const r = await run(readChunkCommandFor(resolved, chunk), expected);
|
|
5262
|
+
if (r.exitCode !== 0 || r.timedOut) return { error: `read-failed: ${describeStepFailure(r)}`, status: 404 };
|
|
5263
|
+
const piece = r.stdout.trimEnd();
|
|
5264
|
+
if (r.truncated === true || piece.length !== expected) {
|
|
5265
|
+
return {
|
|
5266
|
+
error: `read-inconsistent: chunk ${i + 1} of ${chunks.length} arrived as ${piece.length} of ${expected} base64 chars${r.truncated ? " (the SDK cut the output stream)" : ""}`,
|
|
5267
|
+
status: 409,
|
|
5268
|
+
};
|
|
5269
|
+
}
|
|
5270
|
+
parts.push(piece);
|
|
5271
|
+
}
|
|
5272
|
+
return { encoding: "base64", content: parts.join(""), size };
|
|
5273
|
+
} catch (err) {
|
|
5274
|
+
if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
|
|
5275
|
+
throw err;
|
|
5227
5276
|
}
|
|
5228
|
-
return { content: truncated ? r.stdout.slice(0, cap) : r.stdout, truncated };
|
|
5229
5277
|
}
|
|
5230
5278
|
|
|
5231
5279
|
/** POST /write: content travels via the SDK file API into the thread's
|
|
@@ -5652,7 +5700,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5652
5700
|
timedOut: r.timedOut,
|
|
5653
5701
|
});
|
|
5654
5702
|
const ok = r.exitCode === 0 && !r.timedOut;
|
|
5655
|
-
const truncated = r.stdout.length > EXEC_OUTPUT_CAP || r.stderr.length > EXEC_OUTPUT_CAP;
|
|
5703
|
+
const truncated = r.stdout.length > EXEC_OUTPUT_CAP || r.stderr.length > EXEC_OUTPUT_CAP || r.truncated === true;
|
|
5656
5704
|
const notes: string[] = [];
|
|
5657
5705
|
if (r.timedOut) notes.push(`command timed out after ${OP_EXEC_TIMEOUT_MS}ms`);
|
|
5658
5706
|
if (truncated) notes.push(`output truncated to ${EXEC_OUTPUT_CAP} chars per stream`);
|
|
@@ -21,7 +21,9 @@ import { BASH_TIMEOUT_MAX_MS, clampBashTimeout } from "../../src/execution/bashT
|
|
|
21
21
|
import {
|
|
22
22
|
base64ByteLength,
|
|
23
23
|
MAX_READ_BYTES,
|
|
24
|
+
parseByteSize,
|
|
24
25
|
readEncodingOf,
|
|
26
|
+
statCommandFor,
|
|
25
27
|
type Base64ReadAnswer,
|
|
26
28
|
} from "../../src/execution/binaryRead.js";
|
|
27
29
|
import {
|
|
@@ -278,13 +280,25 @@ export default {
|
|
|
278
280
|
if (typeof encoding !== "string") return json({ error: encoding.error }, 400);
|
|
279
281
|
const path = abs(String(body.path ?? ""));
|
|
280
282
|
if (encoding === "base64") {
|
|
283
|
+
// The size first, from `stat`, so the cap is judged before any
|
|
284
|
+
// read and the client can hold the decoded bytes to it — an SDK
|
|
285
|
+
// read that came back short would otherwise pass as the file.
|
|
286
|
+
const stat = await withSessionRecovery(sandbox, () => sandbox.exec(statCommandFor(path)));
|
|
287
|
+
if ((stat.exitCode ?? 0) !== 0) {
|
|
288
|
+
return json({ error: `read-failed: ${String(stat.stderr ?? stat.stdout ?? "").trim()}` }, 404);
|
|
289
|
+
}
|
|
290
|
+
const size = parseByteSize(String(stat.stdout ?? ""));
|
|
291
|
+
if (size === null) return json({ error: `read-failed: stat answered ${JSON.stringify(stat.stdout)}` }, 500);
|
|
292
|
+
if (size > MAX_READ_BYTES) return json({ encoding: "base64", tooLarge: true } satisfies Base64ReadAnswer);
|
|
281
293
|
const file = await withSessionRecovery(sandbox, () => sandbox.readFile(path, { encoding: "base64" }));
|
|
282
294
|
const content = typeof file === "string" ? file : (file?.content ?? "");
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
295
|
+
const got = base64ByteLength(content);
|
|
296
|
+
if (got !== size) {
|
|
297
|
+
// 409, not 5xx: the client retries a 5xx twice over 30 s, and a
|
|
298
|
+
// short read is answered by the caller re-reading, not by waiting.
|
|
299
|
+
return json({ error: `read-inconsistent: ${path} is ${size} bytes but the read returned ${got}` }, 409);
|
|
300
|
+
}
|
|
301
|
+
return json({ encoding: "base64", content, size } satisfies Base64ReadAnswer);
|
|
288
302
|
}
|
|
289
303
|
const file = await withSessionRecovery(sandbox, () => sandbox.readFile(path));
|
|
290
304
|
return json({ content: typeof file === "string" ? file : (file?.content ?? "") });
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.212.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.212.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"web",
|
|
@@ -18999,7 +18999,7 @@
|
|
|
18999
18999
|
},
|
|
19000
19000
|
"packages/switchboard": {
|
|
19001
19001
|
"name": "@coreplane/switchboard",
|
|
19002
|
-
"version": "1.
|
|
19002
|
+
"version": "1.212.0",
|
|
19003
19003
|
"license": "Apache-2.0",
|
|
19004
19004
|
"dependencies": {
|
|
19005
19005
|
"@anthropic-ai/sdk": "^0.124.0",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.212.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
|
|
6
6
|
"license": "Apache-2.0",
|
package/dist/assets/project.json
CHANGED
|
@@ -212,7 +212,7 @@
|
|
|
212
212
|
"when": "Part of `check:consistency`."
|
|
213
213
|
},
|
|
214
214
|
"load": {
|
|
215
|
-
"does": "Load harness: `-- history|resident|sandbox|e2e|cards|provider|pi`.",
|
|
215
|
+
"does": "Load harness: `-- history|resident|sandbox|e2e|cards|provider|pi|route`.",
|
|
216
216
|
"when": "Capacity receipts (docs/reference/specs/load-harness.md)."
|
|
217
217
|
}
|
|
218
218
|
}
|
package/dist/assets/source.json
CHANGED
|
@@ -96,6 +96,15 @@ export interface AgentDef {
|
|
|
96
96
|
* worktree flag and the token the sandbox env and the `repo-cold` vet mint
|
|
97
97
|
* read this, through the run's effective profile. */
|
|
98
98
|
identity: Identity;
|
|
99
|
+
/** Whether the request router (docs/reference/specs/routing-and-config.md
|
|
100
|
+
* item 21) may pick this preset for a plain message. Absent means yes: the
|
|
101
|
+
* router's table is rendered from this registry. `false` keeps a preset
|
|
102
|
+
* out of the table — structurally: it is absent from the table the model
|
|
103
|
+
* is shown and refused as a single route even if the model names it.
|
|
104
|
+
* `ship` (it holds the merge grant) opts out for good; `conductor` (it
|
|
105
|
+
* starts other runs) opts out of the table and is reached through the
|
|
106
|
+
* router's compound form alone, with its parts named. */
|
|
107
|
+
routable?: false;
|
|
99
108
|
/** System prompt variant for resident-repo runs (docs/reference/specs/resident-repos.md):
|
|
100
109
|
* the workspace is a ready worktree — no cloning, no installs, no repo
|
|
101
110
|
* discovery, no gh CLI. Selected by the dispatcher AFTER executor
|
|
@@ -418,6 +427,8 @@ WHAT A CHILD IS. A child is an ordinary Switchboard run started as the person wh
|
|
|
418
427
|
|
|
419
428
|
THE PRESETS a child can run: \`research\` (a question the web or our repositories answer), \`coding\` (implement a change and open a pull request; needs the repository), \`review\` (review a pull request; needs its URL), \`explore\` (a long, read-only investigation with a shell; needs the repository), \`general\` (a quick answer with the GitHub tools), \`ship\` (coding, review and fixes until a pull request is merge-ready; needs the repository).
|
|
420
429
|
|
|
430
|
+
ROUTED COMPOUNDS. A request may arrive already split: the router found independent parts, and the message ends with the line "Routed as a compound request: N independent parts" followed by a numbered list, one part per line as \`<preset>\`: <text>. Spawn exactly those children — one \`spawn_run\` per line, the preset as listed, the line's text as the child's prompt (it already stands alone; add the repository where the preset needs one) — then \`await_runs\` them all and compile. Never merge, drop or add a part; a part whose spawn is refused is reported as refused, by the gate's name.
|
|
431
|
+
|
|
421
432
|
HOW TO WORK. Fan out, await, compile. Read the request and split it into children only where the parts are independent; a request one preset answers is one child. Spawn each child with a self-contained prompt — everything it needs, since it sees none of this thread — and the repository where the preset needs one. Then call \`await_runs\` once with every child's id: it returns when all of them have ended, or earlier — at the edge of your own budget, at a stop, or when a follow-up lands in this thread — and \`ended\` says which; a child still running at the cut keeps running (name it in your answer, or await again after a follow-up). Steer a child with \`send_to_run\` when the request changes or a child is heading the wrong way. A child that ended — finished, failed, interrupted by a restart — is reported as it ended and never restarted; spawn a new child if the work still matters. Then compile: one answer from the write-ups \`await_runs\` returned. Never do a child's job yourself, and never claim a child finished or found something you did not read from \`await_runs\` or \`get_run_status\`.
|
|
422
433
|
|
|
423
434
|
Maintain the user-facing status card with the update_status tool: one item per child (○ pending, ✱ running, ✓ finished — only once await_runs or get_run_status said so).
|
|
@@ -488,6 +499,10 @@ export const AGENTS: Record<string, AgentDef> = {
|
|
|
488
499
|
toolset: "full",
|
|
489
500
|
machine: "repo-resident",
|
|
490
501
|
identity: "write",
|
|
502
|
+
// Never routed: ship is the plan runner and holds the merge grant, so a
|
|
503
|
+
// wrong route into it is code landing on main, not a stray pull request.
|
|
504
|
+
// A request that wants it names it — `agent:ship`.
|
|
505
|
+
routable: false,
|
|
491
506
|
maxTurns: 1,
|
|
492
507
|
maxTokens: 16000,
|
|
493
508
|
maxMinutes: 120,
|
|
@@ -531,6 +546,13 @@ export const AGENTS: Record<string, AgentDef> = {
|
|
|
531
546
|
// dispatcher, the GitHub reads are REST in the bot process.
|
|
532
547
|
machine: "none",
|
|
533
548
|
identity: "none",
|
|
549
|
+
// Never a row of the router's table: a plain message is never routed to a
|
|
550
|
+
// conductor that decides the split itself. The compound form is its one
|
|
551
|
+
// door (docs/reference/specs/routing-and-config.md item 21): the router
|
|
552
|
+
// names the parts and their presets, each checked against the same table
|
|
553
|
+
// and the requester's allowlist, and the brief tells the conductor to
|
|
554
|
+
// spawn exactly those — so no child runs that the record did not name.
|
|
555
|
+
routable: false,
|
|
534
556
|
maxTokens: 32000,
|
|
535
557
|
...loopBudget(120), // long enough to outlast a coding child; every child is capped by what remains of it
|
|
536
558
|
// No built-in effort: the deployment decides, as for coding.
|
|
@@ -144,6 +144,13 @@ type _EveryKindListed = [RunNoteKind] extends [(typeof RUN_NOTE_KINDS)[number]]
|
|
|
144
144
|
const _everyKindListed: _EveryKindListed = true;
|
|
145
145
|
void _everyKindListed;
|
|
146
146
|
|
|
147
|
+
/** How a run's preset was chosen (docs/reference/specs/routing-and-config.md
|
|
148
|
+
* items 1–3 and 21), as `run_meta.agentSource` records it: a directive on
|
|
149
|
+
* the message, the thread's sticky preset, the user or the channel scope's
|
|
150
|
+
* `agent`, `defaults.agent`, or the request router. The replay harness
|
|
151
|
+
* (`load route`) reads it to tell a requester's own choice from a fallback. */
|
|
152
|
+
export type AgentSource = "directive" | "sticky" | "user" | "channel" | "default" | "route";
|
|
153
|
+
|
|
147
154
|
/** How an operator asked a run to stop: `soft` — take no new steps and
|
|
148
155
|
* wrap up through the normal finale; `hard` — abort the in-flight call now, no
|
|
149
156
|
* finale, tear the workspace down. */
|
|
@@ -204,13 +211,14 @@ export function isSpanRecord(e: { type: string }): e is SpanStartEvent | SpanEnd
|
|
|
204
211
|
}
|
|
205
212
|
|
|
206
213
|
/** The protected head (docs/reference/specs/tracing.md; live-view item 2) — is this event head material? The root's start, `slack.receive` and the
|
|
207
|
-
* `dispatch.*` span pairs, `input`, `context`, `run_meta`, and the
|
|
214
|
+
* `dispatch.*` span pairs, `input`, `context`, `run_meta`, `route`, and the
|
|
208
215
|
* `mcp_unavailable` / `spans_dropped` notes. */
|
|
209
216
|
export function isHeadMaterial(event: RunEvent): boolean {
|
|
210
217
|
switch (event.type) {
|
|
211
218
|
case "input":
|
|
212
219
|
case "context":
|
|
213
220
|
case "run_meta":
|
|
221
|
+
case "route":
|
|
214
222
|
return true;
|
|
215
223
|
case "run_note":
|
|
216
224
|
return event.kind === "mcp_unavailable" || event.kind === "spans_dropped" || event.kind === "cold_sandbox";
|
|
@@ -361,6 +369,8 @@ export type RunEvent =
|
|
|
361
369
|
| {
|
|
362
370
|
type: "run_meta";
|
|
363
371
|
agent: string;
|
|
372
|
+
/** How `agent` was chosen (`AgentSource`); absent on a command run and on records written before it existed. */
|
|
373
|
+
agentSource?: AgentSource;
|
|
364
374
|
/** Absent on a command run, which resolves no model. */
|
|
365
375
|
model?: string;
|
|
366
376
|
/** The request's trace id (docs/reference/specs/tracing.md), once the root exists. */
|
|
@@ -476,6 +486,28 @@ export type RunEvent =
|
|
|
476
486
|
* straight to the registry (like `pr_opened`), never through the runner.
|
|
477
487
|
* Additive: unknown → ignored. */
|
|
478
488
|
| { type: "ship_round"; index: number; agent: string; outcome: ShipRoundOutcome; seq?: number; at?: number }
|
|
489
|
+
/** The request router's decision (docs/reference/specs/routing-and-config.md
|
|
490
|
+
* item 21): the preset a plain message was routed to, the one-line reason
|
|
491
|
+
* the router gave (redacted, capped — the same text the card's `routed:`
|
|
492
|
+
* line carries) and the model that decided. A compound route is `preset:
|
|
493
|
+
* "conductor"` with `parts` — one per child the conductor was told to
|
|
494
|
+
* spawn: its preset and its text, the child's whole prompt. A compound the
|
|
495
|
+
* parse refused is recorded too, on the run that fell to the default:
|
|
496
|
+
* `preset` is `defaults.agent` and `reason` reads `compound_rejected:
|
|
497
|
+
* <why>` (the run's `run_meta.agentSource` stays `default`). Published by
|
|
498
|
+
* the dispatcher straight to the registry right after `run_meta`, once per
|
|
499
|
+
* run the router answered; absent on every run a directive, a sticky
|
|
500
|
+
* preset or a scope chose. Head material, like `run_meta`. Additive:
|
|
501
|
+
* unknown → ignored. */
|
|
502
|
+
| {
|
|
503
|
+
type: "route";
|
|
504
|
+
preset: string;
|
|
505
|
+
reason: string;
|
|
506
|
+
model: string;
|
|
507
|
+
parts?: ReadonlyArray<{ preset: string; text: string }>;
|
|
508
|
+
seq?: number;
|
|
509
|
+
at?: number;
|
|
510
|
+
}
|
|
479
511
|
/** The span records (docs/reference/specs/tracing.md): published, counted and stored like
|
|
480
512
|
* every other event, read as timing and never as content. */
|
|
481
513
|
| SpanStartEvent
|
|
@@ -370,7 +370,7 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
|
|
|
370
370
|
// final answer (`answer`) — are the run's story, not its steps: none counts
|
|
371
371
|
// toward `eventCount`.
|
|
372
372
|
let narrativeEvents = 0;
|
|
373
|
-
let sideFactEvents = 0; // skill_use / review_artifact / pr_description / pr_opened / review_posted / ship_round: facts about the run, not steps
|
|
373
|
+
let sideFactEvents = 0; // skill_use / review_artifact / pr_description / pr_opened / review_posted / ship_round / route: facts about the run, not steps
|
|
374
374
|
let spanEvents = 0; // span_start / span_end (docs/reference/specs/tracing.md): timing records, not steps
|
|
375
375
|
let wrapUp: { index: number; at?: number } | undefined;
|
|
376
376
|
events.forEach((ev, index) => {
|
|
@@ -385,16 +385,17 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
|
|
|
385
385
|
}
|
|
386
386
|
// Side facts about the run, not steps: skill_use rides beside a use_skill
|
|
387
387
|
// call that already produced its own tool pair; review_artifact,
|
|
388
|
-
// pr_description, pr_opened, review_posted
|
|
389
|
-
// are published by the dispatcher/pipeline outside the
|
|
390
|
-
// entirely. Counting any of them would distort the story.
|
|
388
|
+
// pr_description, pr_opened, review_posted, the ship_round boundaries and
|
|
389
|
+
// the router's route are published by the dispatcher/pipeline outside the
|
|
390
|
+
// model loop entirely. Counting any of them would distort the story.
|
|
391
391
|
if (
|
|
392
392
|
ev.type === "skill_use" ||
|
|
393
393
|
ev.type === "review_artifact" ||
|
|
394
394
|
ev.type === "pr_description" ||
|
|
395
395
|
ev.type === "pr_opened" ||
|
|
396
396
|
ev.type === "review_posted" ||
|
|
397
|
-
ev.type === "ship_round"
|
|
397
|
+
ev.type === "ship_round" ||
|
|
398
|
+
ev.type === "route"
|
|
398
399
|
) {
|
|
399
400
|
sideFactEvents++;
|
|
400
401
|
return;
|
|
@@ -38,6 +38,7 @@ export const STREAMED_SPANS = [
|
|
|
38
38
|
"dispatch.refuse",
|
|
39
39
|
"dispatch.ship_preflight",
|
|
40
40
|
"dispatch.ledger_claim",
|
|
41
|
+
"dispatch.route",
|
|
41
42
|
"run.agent",
|
|
42
43
|
"run.command",
|
|
43
44
|
"run.reading_diff",
|
|
@@ -89,6 +90,7 @@ const GETTING_READY: ReadonlySet<string> = new Set([
|
|
|
89
90
|
"dispatch.refuse",
|
|
90
91
|
"dispatch.ship_preflight",
|
|
91
92
|
"dispatch.ledger_claim",
|
|
93
|
+
"dispatch.route",
|
|
92
94
|
]);
|
|
93
95
|
const FINISHING_UP: ReadonlySet<string> = new Set([
|
|
94
96
|
"run.observe_workspace",
|
|
@@ -145,6 +147,7 @@ export const PARENTS: Readonly<Record<string, readonly string[]>> = {
|
|
|
145
147
|
"dispatch.refuse": ["request"],
|
|
146
148
|
"dispatch.ship_preflight": ["request"],
|
|
147
149
|
"dispatch.ledger_claim": ["request"],
|
|
150
|
+
"dispatch.route": ["request"],
|
|
148
151
|
"run.agent": ["request", "ship.round", "run.settle_reviewed_head", "run.description_turn"],
|
|
149
152
|
"run.command": ["request"],
|
|
150
153
|
"run.reading_diff": ["request"],
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// the whole file as bytes, for a tool that hands a workspace artifact — a
|
|
3
3
|
// screenshot, a PDF — to somewhere that needs the bytes, not a text view. Both
|
|
4
4
|
// remote executors ask their Worker's `/read` route for `encoding: "base64"`;
|
|
5
|
-
// the Worker answers `{
|
|
6
|
-
// contract both ends share: the caps, the request/answer shape, the
|
|
7
|
-
//
|
|
8
|
-
// imports.
|
|
5
|
+
// the Worker answers `{ encoding: "base64", content, size }`. This module is
|
|
6
|
+
// the contract both ends share: the caps, the request/answer shape, the
|
|
7
|
+
// resident's stat and chunk commands. It is bundled into the Workers too, so it
|
|
8
|
+
// stays free of Node imports.
|
|
9
9
|
|
|
10
10
|
/** The most bytes one `readBytes` hands over. A binary cannot be truncated
|
|
11
11
|
* the way text output is, so a larger file is refused by name, never trimmed.
|
|
@@ -28,11 +28,10 @@ export function readEncodingOf(body: Record<string, unknown>): ReadEncoding | {
|
|
|
28
28
|
return { error: `encoding must be "base64" or absent, got ${JSON.stringify(body.encoding)}` };
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
/** The command a resident runs for a read of an already-confined path
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return encoding === "base64" ? `base64 -w0 -- ${resolvedPath}` : `cat -- ${resolvedPath}`;
|
|
31
|
+
/** The command a resident runs for a text read of an already-confined path.
|
|
32
|
+
* Bytes never go through one command: see `chunkPlan`. */
|
|
33
|
+
export function readCommandFor(resolvedPath: string): string {
|
|
34
|
+
return `cat -- ${resolvedPath}`;
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
/** How many bytes a base64 string decodes to, padding discounted. */
|
|
@@ -43,11 +42,54 @@ export function base64ByteLength(b64: string): number {
|
|
|
43
42
|
return Math.floor((trimmed.length * 3) / 4) - padding;
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
/** A Worker's answer to a base64 read: the bytes
|
|
47
|
-
* file over the cap — HTTP 200 either way. The clients
|
|
48
|
-
* an in-body `error` as a sick Worker (fail-fast counts
|
|
49
|
-
* the model's mistake, not infrastructure, so it travels
|
|
50
|
-
|
|
45
|
+
/** A Worker's answer to a base64 read: the bytes with the file's size, or the
|
|
46
|
+
* named refusal of a file over the cap — HTTP 200 either way. The clients
|
|
47
|
+
* classify a non-2xx or an in-body `error` as a sick Worker (fail-fast counts
|
|
48
|
+
* it); a large file is the model's mistake, not infrastructure, so it travels
|
|
49
|
+
* as a plain field. `size` is the file's byte count from a `stat` taken before
|
|
50
|
+
* the read: the client refuses an answer whose decoded length differs, so a
|
|
51
|
+
* stream cut in transit can never pass as the file. */
|
|
52
|
+
export type Base64ReadAnswer =
|
|
53
|
+
{ encoding: "base64"; content: string; size: number } | { encoding: "base64"; tooLarge: true };
|
|
54
|
+
|
|
55
|
+
/** The command that measures a confined file, as the thread user: one number. */
|
|
56
|
+
export function statCommandFor(resolvedPath: string): string {
|
|
57
|
+
return `stat -c %s -- ${resolvedPath}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `stat -c %s`'s output as a byte count; null for anything that is not one. */
|
|
61
|
+
export function parseByteSize(stdout: string): number | null {
|
|
62
|
+
const m = /^\s*(\d{1,15})\s*$/.exec(stdout);
|
|
63
|
+
return m ? Number(m[1]) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Bytes per chunk of a resident's base64 read. A command's stdout crosses the
|
|
67
|
+
* sandbox SDK's process log stream, which cuts a stream past a retention
|
|
68
|
+
* limit the typings do not name (observed: about 2.3 MB) and reports the cut
|
|
69
|
+
* only as a `truncated` flag — so no single command may carry the whole file.
|
|
70
|
+
* One MiB less one: a multiple of 3, so each chunk's base64 has no padding and
|
|
71
|
+
* the pieces concatenate into the encoding of the whole. */
|
|
72
|
+
export const READ_CHUNK_BYTES = 1_048_575;
|
|
73
|
+
|
|
74
|
+
/** The chunks that cover a `size`-byte file, in order; none for an empty file. */
|
|
75
|
+
export function chunkPlan(size: number, chunkBytes = READ_CHUNK_BYTES): Array<{ offset: number; length: number }> {
|
|
76
|
+
const chunks: Array<{ offset: number; length: number }> = [];
|
|
77
|
+
for (let offset = 0; offset < size; offset += chunkBytes) {
|
|
78
|
+
chunks.push({ offset, length: Math.min(chunkBytes, size - offset) });
|
|
79
|
+
}
|
|
80
|
+
return chunks;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One chunk of a confined file as unwrapped base64: `tail -c +N` seeks on a
|
|
84
|
+
* regular file, `head -c` bounds the piece. */
|
|
85
|
+
export function readChunkCommandFor(resolvedPath: string, chunk: { offset: number; length: number }): string {
|
|
86
|
+
return `tail -c +${chunk.offset + 1} -- ${resolvedPath} | head -c ${chunk.length} | base64 -w0`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The base64 length `bytes` encode to (four chars per three bytes, padded). */
|
|
90
|
+
export function base64LengthOf(bytes: number): number {
|
|
91
|
+
return Math.ceil(bytes / 3) * 4;
|
|
92
|
+
}
|
|
51
93
|
|
|
52
94
|
/** One message for a file over the cap, for every implementation. `bytes` is
|
|
53
95
|
* the size when the reader could measure it; a resident sees only that its
|
|
@@ -24,6 +24,10 @@ export interface StepResult {
|
|
|
24
24
|
stderr: string;
|
|
25
25
|
exitCode: number;
|
|
26
26
|
timedOut: boolean;
|
|
27
|
+
/** The sandbox SDK cut the process's output stream past its own retention
|
|
28
|
+
* limit (one its typings do not name; about 2.3 MB observed): what is here
|
|
29
|
+
* is a prefix. Distinct from the DO's char caps, which slice what arrived. */
|
|
30
|
+
truncated?: boolean;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/** Chars kept per stream in the STORED reason — it travels into DO storage,
|