@coreplane/switchboard 1.211.0 → 1.213.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/README.md +5 -3
- package/dist/assets/config/config.example.yaml +44 -0
- package/dist/assets/deploy/cloudflare/worker.ts +6 -0
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +24 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +421 -38
- package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +9 -1
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +18 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +19 -5
- package/dist/assets/deploy/secrets.manifest.json +18 -0
- package/dist/assets/package-lock.json +5 -3
- package/dist/assets/package.json +2 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +50 -7
- package/dist/assets/src/core/redact.ts +11 -1
- package/dist/assets/src/core/runEvents.ts +60 -6
- package/dist/assets/src/core/runFriction.ts +6 -4
- package/dist/assets/src/core/trace/workerTrace.ts +4 -0
- package/dist/assets/src/execution/binaryRead.ts +56 -14
- package/dist/assets/src/execution/residentRefresh.ts +151 -5
- package/dist/assets/src/execution/residentStepReport.ts +4 -0
- package/dist/assets/web/dist/.vite/manifest.json +19 -19
- package/dist/assets/web/dist/assets/{ResidentDetailPage-CM5nWw-Z.js → ResidentDetailPage-CvwjhjlG.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-BKBnuvp3.js → ResidentsIndexPage-DoN6XuNx.js} +1 -1
- package/dist/assets/web/dist/assets/RunRoutePage-FzLNexjF.js +12 -0
- package/dist/assets/web/dist/assets/{RunsIndexPage-w2jJP5xu.js → RunsIndexPage-DhSUbF9k.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-D3-k9DPz.js → ScheduledPage-Bq0Yg4nT.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-BmFHnV8m.js → StatusDot-D8Wwt1KC.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DoThP2fW.js → Tooltip-DpDK7jWZ.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-YrRKtxsS.js → dist-B7BVkB7x.js} +1 -1
- package/dist/assets/web/dist/assets/{main-D6nzMf0k.js → main-BFkEOy3K.js} +2 -2
- package/dist/assets/web/dist/assets/main-DCH3Mezs.css +1 -0
- package/dist/cli.js +2704 -603
- package/package.json +2 -1
- package/dist/assets/web/dist/assets/RunRoutePage-Bu4CwEzt.js +0 -12
- package/dist/assets/web/dist/assets/main-CuENKPdD.css +0 -1
|
@@ -30,7 +30,15 @@
|
|
|
30
30
|
// binding's bucket below — the SDK signs URLs for this name, the offboard
|
|
31
31
|
// sweep deletes through the binding. Public config, not secrets.
|
|
32
32
|
"CLOUDFLARE_ACCOUNT_ID": "{{account}}",
|
|
33
|
-
"BACKUP_BUCKET_NAME": "{{script}}-cache"
|
|
33
|
+
"BACKUP_BUCKET_NAME": "{{script}}-cache",
|
|
34
|
+
// The wake budget (docs/reference/specs/resident-repos.md item 64): how long
|
|
35
|
+
// the SDK's physical start waits for the container's control port to accept
|
|
36
|
+
// a request before the first connect — WAKE_PORT_READY_MS in
|
|
37
|
+
// src/execution/residentRefresh.ts, 3 min in place of the SDK's 90 s, so a
|
|
38
|
+
// slow boot (a 2.65 GB image on a cold host) is not read as an unreachable
|
|
39
|
+
// runtime. The env name and its bounds (10 s to 600 s) are the SDK's; a test
|
|
40
|
+
// pins this value to the constant.
|
|
41
|
+
"SANDBOX_PORT_TIMEOUT_MS": "180000"
|
|
34
42
|
},
|
|
35
43
|
"containers": [
|
|
36
44
|
{
|
|
@@ -136,3 +136,21 @@ RUN apt-get update \
|
|
|
136
136
|
&& playwright --version | grep -qx 'Version 1.63.0' \
|
|
137
137
|
&& playwright screenshot --viewport-size=640,480 'data:text/html,<h1>ok</h1>' /tmp/ok.png && test -s /tmp/ok.png \
|
|
138
138
|
&& rm -f /tmp/proof.mp4 /tmp/proof_*.png /tmp/ok.png
|
|
139
|
+
|
|
140
|
+
# pi — the coding harness the bot can start INSIDE this container in place of
|
|
141
|
+
# its native turn loop (docs/reference/specs/harness-pi.md): `pi --mode rpc`,
|
|
142
|
+
# one process per run in the thread's worktree, driven by the bot over its
|
|
143
|
+
# JSONL protocol, with the run's model-proxy bearer as its only key (never a
|
|
144
|
+
# model key: the bearer buys calls through the bot, docs/reference/specs/
|
|
145
|
+
# model-proxy.md). Installed globally with this image's Node — pi needs
|
|
146
|
+
# >= 22.19.0 and the image ships 24 — so every user finds it on PATH, at an
|
|
147
|
+
# EXACT pin held by src/deploy/imagePiHarness.test.ts (the pnpm lesson: a
|
|
148
|
+
# floating tag would move the harness's protocol with the build date, and a
|
|
149
|
+
# pi minor changes RPC events and extension hooks). Proven by the layer, so
|
|
150
|
+
# the BUILD fails, not a run: the pi on PATH answers the pin, and its own help
|
|
151
|
+
# names the RPC mode the harness drives. Dark until a deployment sets
|
|
152
|
+
# `harness: { coding: pi }`: nothing here starts pi on its own.
|
|
153
|
+
RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 \
|
|
154
|
+
&& npm cache clean --force \
|
|
155
|
+
&& pi --version | grep -qx '0.85.1' \
|
|
156
|
+
&& pi --help | grep -q -- '--mode <mode>'
|
|
@@ -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 ?? "") });
|
|
@@ -75,6 +75,24 @@
|
|
|
75
75
|
"optional": true,
|
|
76
76
|
"note": "The secret half of R2_ACCESS_KEY_ID (same token). Both or neither."
|
|
77
77
|
},
|
|
78
|
+
{
|
|
79
|
+
"name": "ARTIFACTS_R2_ACCESS_KEY_ID",
|
|
80
|
+
"workers": ["bot"],
|
|
81
|
+
"optional": true,
|
|
82
|
+
"note": "R2 API token (S3 access key id) scoped Object Read & Write to the artifacts bucket ONLY (`artifacts.r2.bucket` in config.yaml; docs/reference/specs/execution.md item 20): the bot signs presigned PUT/GET URLs and HEADs objects with it; it never carries a file. Optional: absent with no `artifacts:` section means no store. Created in the Cloudflare dashboard (R2 → Manage API tokens); rotate = new token, `deploy secrets bot`, `deploy restart`."
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"name": "ARTIFACTS_R2_SECRET_ACCESS_KEY",
|
|
86
|
+
"workers": ["bot"],
|
|
87
|
+
"optional": true,
|
|
88
|
+
"note": "The secret half of ARTIFACTS_R2_ACCESS_KEY_ID (same token). Both or neither."
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"name": "ARTIFACTS_COPY_TOKEN",
|
|
92
|
+
"workers": ["bot"],
|
|
93
|
+
"optional": true,
|
|
94
|
+
"note": "Shared bearer between the bot and its own Worker's `POST /artifacts/copy` route, which streams an inbound Slack file into the artifacts bucket (the Worker holds the R2 binding and the Slack token; the bot only asks). Self-minted (`openssl rand -hex 32`); the Worker checks it, the container presents it. Required whenever `artifacts:` is configured — the bot fails fast at startup without it."
|
|
95
|
+
},
|
|
78
96
|
{
|
|
79
97
|
"name": "MCP_CREDENTIAL_KEY",
|
|
80
98
|
"workers": ["bot"],
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.213.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.213.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"web",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@anthropic-ai/sdk": "^0.124.0",
|
|
23
23
|
"@slack/bolt": "^5.1.0",
|
|
24
|
+
"aws4fetch": "^1.0.20",
|
|
24
25
|
"e2b": "^2.46.1",
|
|
25
26
|
"mdast-util-from-markdown": "^2.0.3",
|
|
26
27
|
"undici": "^8.10.2",
|
|
@@ -18999,11 +19000,12 @@
|
|
|
18999
19000
|
},
|
|
19000
19001
|
"packages/switchboard": {
|
|
19001
19002
|
"name": "@coreplane/switchboard",
|
|
19002
|
-
"version": "1.
|
|
19003
|
+
"version": "1.213.0",
|
|
19003
19004
|
"license": "Apache-2.0",
|
|
19004
19005
|
"dependencies": {
|
|
19005
19006
|
"@anthropic-ai/sdk": "^0.124.0",
|
|
19006
19007
|
"@slack/bolt": "^5.1.0",
|
|
19008
|
+
"aws4fetch": "^1.0.20",
|
|
19007
19009
|
"e2b": "^2.46.1",
|
|
19008
19010
|
"mdast-util-from-markdown": "^2.0.3",
|
|
19009
19011
|
"undici": "^8.10.2",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.213.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",
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
"dependencies": {
|
|
82
82
|
"@anthropic-ai/sdk": "^0.124.0",
|
|
83
83
|
"@slack/bolt": "^5.1.0",
|
|
84
|
+
"aws4fetch": "^1.0.20",
|
|
84
85
|
"e2b": "^2.46.1",
|
|
85
86
|
"mdast-util-from-markdown": "^2.0.3",
|
|
86
87
|
"undici": "^8.10.2",
|
package/dist/assets/source.json
CHANGED
|
@@ -42,6 +42,15 @@ export function machineNeedsRepo(machine: MachineClass): boolean {
|
|
|
42
42
|
export const IDENTITIES = ["none", "read", "write"] as const;
|
|
43
43
|
export type Identity = (typeof IDENTITIES)[number];
|
|
44
44
|
|
|
45
|
+
/** The loops a preset's runs can be driven by (docs/reference/specs/harness-pi.md
|
|
46
|
+
* item 1): `native`, the in-process turn loop (`src/runner.ts`), or `pi`, the
|
|
47
|
+
* pi coding agent in the run's own execution container, driven over its RPC
|
|
48
|
+
* protocol and bridged onto the run's events. A deployment's `harness:` block
|
|
49
|
+
* overrides a preset's own declaration (`effectiveHarness`,
|
|
50
|
+
* src/core/harness/select.ts). */
|
|
51
|
+
export const HARNESSES = ["native", "pi"] as const;
|
|
52
|
+
export type Harness = (typeof HARNESSES)[number];
|
|
53
|
+
|
|
45
54
|
/** The pace that marks a run as looping rather than working: a model turn
|
|
46
55
|
* every ten seconds, sustained for the whole wall clock. A busy run takes
|
|
47
56
|
* 20–40 s a turn (a model think plus a tool call), so a run that averages six
|
|
@@ -99,16 +108,22 @@ export interface AgentDef {
|
|
|
99
108
|
/** Whether the request router (docs/reference/specs/routing-and-config.md
|
|
100
109
|
* item 21) may pick this preset for a plain message. Absent means yes: the
|
|
101
110
|
* router's table is rendered from this registry. `false` keeps a preset
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* runs)
|
|
111
|
+
* out of the table — structurally: it is absent from the table the model
|
|
112
|
+
* is shown and refused as a single route even if the model names it.
|
|
113
|
+
* `ship` (it holds the merge grant) opts out for good; `conductor` (it
|
|
114
|
+
* starts other runs) opts out of the table and is reached through the
|
|
115
|
+
* router's compound form alone, with its parts named. */
|
|
106
116
|
routable?: false;
|
|
107
117
|
/** System prompt variant for resident-repo runs (docs/reference/specs/resident-repos.md):
|
|
108
118
|
* the workspace is a ready worktree — no cloning, no installs, no repo
|
|
109
119
|
* discovery, no gh CLI. Selected by the dispatcher AFTER executor
|
|
110
120
|
* resolution via RunOptions.system; the shared AgentDef is never mutated. */
|
|
111
121
|
residentSystem?: string;
|
|
122
|
+
/** Which loop drives the preset's runs (`HARNESSES`): the native loop
|
|
123
|
+
* unless declared, and whatever a deployment's `harness.<preset>` says
|
|
124
|
+
* over that. Only a preset with a workspace can run on pi — pi is a process
|
|
125
|
+
* in the run's execution container. */
|
|
126
|
+
harness?: Harness;
|
|
112
127
|
}
|
|
113
128
|
|
|
114
129
|
// Every PR the coding agent ships carries a rich description by default —
|
|
@@ -426,12 +441,34 @@ WHAT A CHILD IS. A child is an ordinary Switchboard run started as the person wh
|
|
|
426
441
|
|
|
427
442
|
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).
|
|
428
443
|
|
|
444
|
+
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.
|
|
445
|
+
|
|
429
446
|
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\`.
|
|
430
447
|
|
|
431
448
|
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).
|
|
432
449
|
|
|
433
450
|
Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks). Your final message is posted to Slack: lead with the outcome, then one line per child — its preset, its thread, its status and its result in a sentence — and what is still running, if anything.`;
|
|
434
451
|
|
|
452
|
+
/** The compound answer's preset — the one preset absent from the router's
|
|
453
|
+
* table that a plain message still reaches: a message with two or more
|
|
454
|
+
* independent asks routes to it with the parts named
|
|
455
|
+
* (docs/reference/specs/routing-and-config.md item 21). Its def below opts
|
|
456
|
+
* out of the table (`routable: false`); the router names it only in the
|
|
457
|
+
* compound form. */
|
|
458
|
+
export const COMPOUND_PRESET = "conductor";
|
|
459
|
+
|
|
460
|
+
/** How a plain message reaches a preset (docs/reference/specs/routing-and-config.md
|
|
461
|
+
* item 21), read off its def: `routed` — a row of the router's table, picked
|
|
462
|
+
* for a single ask; `compound` — the compound form alone, a message with
|
|
463
|
+
* several independent asks; `directive` — never picked, only `agent:<name>`.
|
|
464
|
+
* `help` renders its lines from this, so its words follow the registry. */
|
|
465
|
+
export type PresetDoor = "routed" | "compound" | "directive";
|
|
466
|
+
|
|
467
|
+
export function presetDoor(def: AgentDef): PresetDoor {
|
|
468
|
+
if (def.routable !== false) return "routed";
|
|
469
|
+
return def.name === COMPOUND_PRESET ? "compound" : "directive";
|
|
470
|
+
}
|
|
471
|
+
|
|
435
472
|
export const AGENTS: Record<string, AgentDef> = {
|
|
436
473
|
general: {
|
|
437
474
|
name: "general",
|
|
@@ -463,6 +500,9 @@ export const AGENTS: Record<string, AgentDef> = {
|
|
|
463
500
|
// `config set channel efforts.coding=…`, or `effort:` per request).
|
|
464
501
|
machine: "repo-resident",
|
|
465
502
|
identity: "write", // pushes branches and opens pull requests
|
|
503
|
+
// The native loop until the pi series moves this preset; a deployment
|
|
504
|
+
// flips it early with `harness: { coding: pi }` (docs/reference/specs/harness-pi.md).
|
|
505
|
+
harness: "native",
|
|
466
506
|
},
|
|
467
507
|
review: {
|
|
468
508
|
name: "review",
|
|
@@ -543,9 +583,12 @@ export const AGENTS: Record<string, AgentDef> = {
|
|
|
543
583
|
// dispatcher, the GitHub reads are REST in the bot process.
|
|
544
584
|
machine: "none",
|
|
545
585
|
identity: "none",
|
|
546
|
-
// Never
|
|
547
|
-
//
|
|
548
|
-
//
|
|
586
|
+
// Never a row of the router's table: a plain message is never routed to a
|
|
587
|
+
// conductor that decides the split itself. The compound form is its one
|
|
588
|
+
// door (docs/reference/specs/routing-and-config.md item 21): the router
|
|
589
|
+
// names the parts and their presets, each checked against the same table
|
|
590
|
+
// and the requester's allowlist, and the brief tells the conductor to
|
|
591
|
+
// spawn exactly those — so no child runs that the record did not name.
|
|
549
592
|
routable: false,
|
|
550
593
|
maxTokens: 32000,
|
|
551
594
|
...loopBudget(120), // long enough to outlast a coding child; every child is capped by what remains of it
|
|
@@ -46,6 +46,14 @@ const REDACT: Array<{ re: RegExp; replace: string }> = [
|
|
|
46
46
|
const SECRET_COMPONENT =
|
|
47
47
|
/^(secret|token|password|passwd|pwd|credential|credentials|key|apikey|auth|session|sessionid|cookie)$/i;
|
|
48
48
|
|
|
49
|
+
// An identifier component that marks the whole identifier as an ERROR CODE, not
|
|
50
|
+
// a secret name: `github-token-mint-failed: HTTP 422 …` names a failure about a
|
|
51
|
+
// token, and the word after the colon is diagnosis, never a credential. Without
|
|
52
|
+
// this guard the assignment pass redacted `HTTP` out of that message
|
|
53
|
+
// (`github-token-mint-failed: «redacted» 422 …`), garbling the one line that
|
|
54
|
+
// tells an operator what went wrong.
|
|
55
|
+
const ERROR_CODE_COMPONENT = /^(failed|failure|error|err|refused|denied|invalid|missing|expired|unset|mismatch)$/i;
|
|
56
|
+
|
|
49
57
|
/** Redact the VALUE of any `<name> = value` / `<name>: value` where the name has
|
|
50
58
|
* a secret-marking component. Handles quoted values (with spaces) and unquoted,
|
|
51
59
|
* and a quoted NAME (`"password": "…"` in pasted JSON — the closing quote sits
|
|
@@ -64,7 +72,9 @@ function redactNamedAssignments(text: string): string {
|
|
|
64
72
|
// decides whether it names a secret (`_SECRET` → ["", "SECRET"]).
|
|
65
73
|
/(?<![A-Za-z0-9_-])([A-Za-z0-9_-]+)("?)(\s*[=:]\s*)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s]{4,})/g,
|
|
66
74
|
(whole, id: string, close: string, sep: string, val: string) => {
|
|
67
|
-
|
|
75
|
+
const parts = id.split(/[_-]/);
|
|
76
|
+
if (!parts.some((p) => SECRET_COMPONENT.test(p))) return whole;
|
|
77
|
+
if (parts.some((p) => ERROR_CODE_COMPONENT.test(p))) return whole;
|
|
68
78
|
const quote = val[0] === '"' || val[0] === "'" ? val[0] : "";
|
|
69
79
|
return `${id}${close}${sep}${quote}«redacted»${quote}`;
|
|
70
80
|
},
|
|
@@ -118,7 +118,23 @@ export type RunNoteKind =
|
|
|
118
118
|
* post-step beside the thread's Slack-only note, so the record says the
|
|
119
119
|
* verdict is Slack-only and a coordinator reading it never asks GitHub
|
|
120
120
|
* for a review that was never sent. */
|
|
121
|
-
| "review_not_posted"
|
|
121
|
+
| "review_not_posted"
|
|
122
|
+
/** A pi run's context was compacted (docs/reference/specs/harness-pi.md item
|
|
123
|
+
* 6): pi summarized its older turns into one entry and the model reads the
|
|
124
|
+
* summary from here on; the transcript keeps the originals, so the record
|
|
125
|
+
* is a superset of the model's context. The summary names the token counts
|
|
126
|
+
* before and after. Published by the pi bridge. */
|
|
127
|
+
| "compacted"
|
|
128
|
+
/** The pi harness itself failed in a way the run must show (harness-pi.md
|
|
129
|
+
* item 6): the extension threw, pi asked a dialog no one answers (answered
|
|
130
|
+
* cancelled), or pi emitted an event kind this build's bridge does not
|
|
131
|
+
* know — named, so a pi bump is visible in the first run's record. Published
|
|
132
|
+
* by the pi bridge. */
|
|
133
|
+
| "harness_error"
|
|
134
|
+
/** The harness's gate refused a tool call the model asked for (harness-pi.md
|
|
135
|
+
* item 7): the summary names the tool and the rule; the model read the same
|
|
136
|
+
* reason as the tool's result. Published by the bot's authorize route. */
|
|
137
|
+
| "tool_refused";
|
|
122
138
|
|
|
123
139
|
/** Every `RunNoteKind`, as a value (a reader that filters notes by kind uses
|
|
124
140
|
* this; adding a kind to the union without adding it here is a type error). */
|
|
@@ -139,6 +155,9 @@ export const RUN_NOTE_KINDS = [
|
|
|
139
155
|
"cold_sandbox",
|
|
140
156
|
"pr_not_opened",
|
|
141
157
|
"review_not_posted",
|
|
158
|
+
"compacted",
|
|
159
|
+
"harness_error",
|
|
160
|
+
"tool_refused",
|
|
142
161
|
] as const satisfies readonly RunNoteKind[];
|
|
143
162
|
type _EveryKindListed = [RunNoteKind] extends [(typeof RUN_NOTE_KINDS)[number]] ? true : never;
|
|
144
163
|
const _everyKindListed: _EveryKindListed = true;
|
|
@@ -383,6 +402,27 @@ export type RunEvent =
|
|
|
383
402
|
seq?: number;
|
|
384
403
|
at?: number;
|
|
385
404
|
}
|
|
405
|
+
/** A file moved through the artifact store (docs/reference/specs/execution.md item 20,
|
|
406
|
+
* record 0033): one the run received from its thread (`in`, staged before
|
|
407
|
+
* the turn) or one it sent (`out`, `attach_file`). The record keeps the
|
|
408
|
+
* store KEY and the facts a page needs to list the file — never a URL: the
|
|
409
|
+
* run page's proxy route (`/runs/:id/artifacts/<key>`, live-view.md item 26)
|
|
410
|
+
* mints a signed GET per request, so a stored record never carries a
|
|
411
|
+
* credential that expires or leaks, and the parser refuses a payload that
|
|
412
|
+
* tries. A side fact beside the tool pair that moved the file (like
|
|
413
|
+
* `skill_use`), never a step; the friction analyzer ignores it. */
|
|
414
|
+
| {
|
|
415
|
+
type: "artifact";
|
|
416
|
+
direction: "in" | "out";
|
|
417
|
+
/** The store key (`src/artifacts/keys.ts`): `runs/<runId>/out/<seq>-<basename>` or `threads/<thread>/in/<ts>/<i>-<basename>`. */
|
|
418
|
+
key: string;
|
|
419
|
+
/** The file's name as the person sees it (the Slack filename, the tool's `name`). */
|
|
420
|
+
name: string;
|
|
421
|
+
size: number;
|
|
422
|
+
contentType: string;
|
|
423
|
+
seq?: number;
|
|
424
|
+
at?: number;
|
|
425
|
+
}
|
|
386
426
|
/** A skill was loaded into the model's context (docs/reference/specs/skills.md). Emitted
|
|
387
427
|
* by the `use_skill` tool on a successful load — alongside, not instead of,
|
|
388
428
|
* its `tool_call`/`tool_result` pair — so skill use is a first-class fact in
|
|
@@ -489,11 +529,25 @@ export type RunEvent =
|
|
|
489
529
|
/** The request router's decision (docs/reference/specs/routing-and-config.md
|
|
490
530
|
* item 21): the preset a plain message was routed to, the one-line reason
|
|
491
531
|
* the router gave (redacted, capped — the same text the card's `routed:`
|
|
492
|
-
* line carries) and the model that decided.
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
|
|
532
|
+
* line carries) and the model that decided. A compound route is `preset:
|
|
533
|
+
* "conductor"` with `parts` — one per child the conductor was told to
|
|
534
|
+
* spawn: its preset and its text, the child's whole prompt. A compound the
|
|
535
|
+
* parse refused is recorded too, on the run that fell to the default:
|
|
536
|
+
* `preset` is `defaults.agent` and `reason` reads `compound_rejected:
|
|
537
|
+
* <why>` (the run's `run_meta.agentSource` stays `default`). Published by
|
|
538
|
+
* the dispatcher straight to the registry right after `run_meta`, once per
|
|
539
|
+
* run the router answered; absent on every run a directive, a sticky
|
|
540
|
+
* preset or a scope chose. Head material, like `run_meta`. Additive:
|
|
541
|
+
* unknown → ignored. */
|
|
542
|
+
| {
|
|
543
|
+
type: "route";
|
|
544
|
+
preset: string;
|
|
545
|
+
reason: string;
|
|
546
|
+
model: string;
|
|
547
|
+
parts?: ReadonlyArray<{ preset: string; text: string }>;
|
|
548
|
+
seq?: number;
|
|
549
|
+
at?: number;
|
|
550
|
+
}
|
|
497
551
|
/** The span records (docs/reference/specs/tracing.md): published, counted and stored like
|
|
498
552
|
* every other event, read as timing and never as content. */
|
|
499
553
|
| SpanStartEvent
|
|
@@ -384,12 +384,14 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
|
|
|
384
384
|
return;
|
|
385
385
|
}
|
|
386
386
|
// Side facts about the run, not steps: skill_use rides beside a use_skill
|
|
387
|
-
// call that already produced its own tool pair
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
387
|
+
// call that already produced its own tool pair, and artifact beside the
|
|
388
|
+
// attach_file call (or the dispatcher's staging) that moved the file;
|
|
389
|
+
// review_artifact, pr_description, pr_opened, review_posted, the ship_round
|
|
390
|
+
// boundaries and the router's route are published by the dispatcher/pipeline
|
|
391
|
+
// outside the model loop entirely. Counting any of them would distort the story.
|
|
391
392
|
if (
|
|
392
393
|
ev.type === "skill_use" ||
|
|
394
|
+
ev.type === "artifact" ||
|
|
393
395
|
ev.type === "review_artifact" ||
|
|
394
396
|
ev.type === "pr_description" ||
|
|
395
397
|
ev.type === "pr_opened" ||
|
|
@@ -55,6 +55,10 @@ export function shimRoute(pathname: string): string | undefined {
|
|
|
55
55
|
// The model proxy's two routes (docs/reference/specs/model-proxy.md): a bounded
|
|
56
56
|
// request per model call, forwarded to the container like everything else.
|
|
57
57
|
if (pathname === "/v1/messages" || pathname === "/v1/chat/completions") return "model-proxy";
|
|
58
|
+
// The pi harness's three routes (docs/reference/specs/harness-pi.md item 7): a
|
|
59
|
+
// run's extension asking for its tools, a verdict, a relayed tool's result.
|
|
60
|
+
if (pathname === "/harness/tools" || pathname === "/harness/authorize" || pathname === "/harness/tool")
|
|
61
|
+
return "harness";
|
|
58
62
|
if (pathname === "/docs" || pathname.startsWith("/docs/")) return "docs";
|
|
59
63
|
if (pathname === "/" || pathname === "/index.html") return "page";
|
|
60
64
|
return "other";
|
|
@@ -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
|