@coreplane/switchboard 1.205.0 → 1.206.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/Dockerfile +6 -3
- package/dist/assets/config/config.example.yaml +24 -1
- package/dist/assets/deploy/cloudflare/coordinator.ts +86 -0
- package/dist/assets/deploy/cloudflare/shared.ts +9 -0
- package/dist/assets/deploy/cloudflare/worker.ts +142 -6
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +9 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +266 -18
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +15 -0
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +89 -3
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +81 -10
- package/dist/assets/deploy/secrets.manifest.json +1 -1
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +58 -7
- package/dist/assets/src/config/profile.ts +34 -5
- package/dist/assets/src/core/authz/policy.ts +15 -0
- package/dist/assets/src/core/coordinator/contract.ts +239 -0
- package/dist/assets/src/core/coordinator/driver.ts +501 -0
- package/dist/assets/src/core/coordinator/instancesRoute.ts +181 -0
- package/dist/assets/src/core/delivery.ts +69 -15
- package/dist/assets/src/core/deliverySnapshotStore.ts +84 -24
- package/dist/assets/src/core/reviewVerdict.ts +296 -0
- package/dist/assets/src/core/reviewedHead.ts +77 -0
- package/dist/assets/src/core/runEvents.ts +4 -2
- package/dist/assets/src/core/runLedger/decisions.ts +2 -1
- package/dist/assets/src/core/runLedger/types.ts +17 -1
- package/dist/assets/src/core/runRecord.ts +56 -0
- package/dist/assets/src/core/ship/coordinator.ts +1039 -0
- package/dist/assets/web/dist/.vite/manifest.json +20 -20
- package/dist/assets/web/dist/assets/DeliveryPage-CvlWP7Eq.js +1 -0
- package/dist/assets/web/dist/assets/{ResidentDetailPage-Bcasasjb.js → ResidentDetailPage-D3P21yeI.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-BJwyrphn.js → ResidentsIndexPage-DOYqnZ1q.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-6eStApqP.js → RunRoutePage-OmvrvPXY.js} +4 -4
- package/dist/assets/web/dist/assets/{RunsIndexPage-CWrkv7v8.js → RunsIndexPage-DWbSQtL4.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-BX2py1X3.js → ScheduledPage-CPKfJ4mR.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-CwCK84JN.js → StatusDot-COr8jTyM.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-CFC88_-Z.js → Tooltip-fOqTZkNT.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-BoLiHpua.js → dist-BVjAWgkb.js} +1 -1
- package/dist/assets/web/dist/assets/main-CuENKPdD.css +1 -0
- package/dist/assets/web/dist/assets/{main-tYcFk9Dc.js → main-DZbJaqUb.js} +2 -2
- package/dist/cli.js +4621 -1804
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/DeliveryPage-Cx7kQC_e.js +0 -1
- package/dist/assets/web/dist/assets/main-i3ZNDRLK.css +0 -1
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
# Sandbox container image: the @cloudflare/sandbox base plus git + gh,
|
|
2
|
-
#
|
|
1
|
+
# Sandbox container image: the @cloudflare/sandbox base plus git + gh, a Docker
|
|
2
|
+
# engine, Node 24 with pnpm, and the toolchain a run builds and looks with
|
|
3
|
+
# (compilers, ffmpeg, a headless Chromium), so the coding/review/explore
|
|
4
|
+
# agents work out of the box.
|
|
5
|
+
|
|
6
|
+
# The Node this image ships (copied in below), at the exact tag every image in
|
|
7
|
+
# this repository shares — its major is .nvmrc's, which is what CI runs and
|
|
8
|
+
# the install script requires. `--platform`: the cloudflare/sandbox base below
|
|
9
|
+
# publishes linux/amd64 only, so the stage the binary is copied from must be
|
|
10
|
+
# amd64 too — a bare FROM on an arm64 host resolves it to arm64 and the copied
|
|
11
|
+
# binary cannot run in the base (check:image on Apple silicon failed exactly
|
|
12
|
+
# so). src/deploy/imageNode.test.ts holds the tag, its major and the platform.
|
|
13
|
+
FROM --platform=linux/amd64 docker.io/library/node:24.21.0-slim AS node
|
|
14
|
+
|
|
3
15
|
# The tag MUST match the @cloudflare/sandbox version in package.json exactly
|
|
4
16
|
# (no 'latest' tag exists) — bump both together.
|
|
5
17
|
FROM docker.io/cloudflare/sandbox:0.12.9
|
|
@@ -41,14 +53,28 @@ RUN apt-get update \
|
|
|
41
53
|
# execs the real client. src/deploy/sandboxDocker.test.ts holds its shape.
|
|
42
54
|
COPY --chmod=0755 docker-wrapper.sh /usr/local/bin/docker
|
|
43
55
|
|
|
44
|
-
# Node
|
|
45
|
-
# from node:22-slim
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
|
|
56
|
+
# Node 24, not the base's. The published 0.12.9 base ships Node 22.23.2
|
|
57
|
+
# (copied from node:22-slim: the binary at /usr/local/bin/node, npm and
|
|
58
|
+
# corepack under /usr/local/lib/node_modules, npm/npx symlinked into it), while
|
|
59
|
+
# this repository builds and tests on Node 24 (.nvmrc, CI) and its published
|
|
60
|
+
# CLI requires `>=24`. A run that exercised the suite inside this image read a
|
|
61
|
+
# different `verify` than CI did: `npm ci` warned on the CLI package's engines,
|
|
62
|
+
# and tests that pass in CI failed here. So Node comes from the official image
|
|
63
|
+
# at an exact tag, in the `node` stage above: the base's npm tree is removed
|
|
64
|
+
# first (no file of npm 10 survives under npm 11), then the binary, npm's tree
|
|
65
|
+
# and the headers (node-gyp compiles native addons against them without a
|
|
66
|
+
# download) land at the base's own paths, so its npm/npx symlinks keep
|
|
67
|
+
# resolving. The container server (/container-server/sandbox, a bun-compiled
|
|
68
|
+
# binary) does not run on this Node, so only what a run's commands see moves.
|
|
69
|
+
# The grep fails the BUILD, not a run, if a bump ever lands another version
|
|
70
|
+
# here; src/deploy/imageNode.test.ts holds it equal to the stage's tag.
|
|
71
|
+
RUN rm -rf /usr/local/lib/node_modules /usr/local/include/node
|
|
72
|
+
COPY --from=node /usr/local/bin/node /usr/local/bin/node
|
|
73
|
+
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
|
74
|
+
COPY --from=node /usr/local/include/node /usr/local/include/node
|
|
75
|
+
RUN node --version | grep -qx 'v24.21.0' \
|
|
76
|
+
&& npm --version >/dev/null \
|
|
77
|
+
&& npx --version >/dev/null
|
|
52
78
|
|
|
53
79
|
# pnpm: the base image ships Node + npm (corepack is under
|
|
54
80
|
# /usr/local/lib/node_modules but not on PATH) and not pnpm, so coding runs in pnpm repos hit
|
|
@@ -65,3 +91,48 @@ RUN node --version | grep -Eq '^v(2[2-9]|[3-9][0-9])\.'
|
|
|
65
91
|
# `packageManager`, so this is the floor for repos that name none.
|
|
66
92
|
RUN npm install -g pnpm@10.34.5 \
|
|
67
93
|
&& pnpm --version | grep -qx '10\.34\.5'
|
|
94
|
+
|
|
95
|
+
# The toolchain for what a run BUILDS and LOOKS AT — the same layer as the
|
|
96
|
+
# resident image's (deploy/cloudflare-resident/Dockerfile);
|
|
97
|
+
# src/deploy/imageToolchain.test.ts holds the two to one shape.
|
|
98
|
+
# - python3 + make + g++: node-gyp's needs. A cold `npm install` in a repo
|
|
99
|
+
# with a native module (node-pty) rebuilds it from source, and died here
|
|
100
|
+
# on "no Python". Just the three — no build-essential, no recommends.
|
|
101
|
+
# - ffmpeg (Ubuntu's, with libx264): an agent pulls frames out of a video
|
|
102
|
+
# to look at it (`ffmpeg -i in.mp4 -vf fps=1 frame_%03d.png`) and encodes
|
|
103
|
+
# video from frames or a screen recording.
|
|
104
|
+
# - a headless Chromium through Playwright at an EXACT pin
|
|
105
|
+
# (src/deploy/imagePins.test.ts): screenshots, PDFs and `recordVideo`.
|
|
106
|
+
# Ubuntu 22.04's apt `chromium` is a snap stub that does not run in a
|
|
107
|
+
# container, so the browser is Playwright's own build — the headless shell
|
|
108
|
+
# only (`--only-shell`: there is no display), with the system libraries it
|
|
109
|
+
# needs (`--with-deps`) and the fonts pages render text with.
|
|
110
|
+
# PLAYWRIGHT_BROWSERS_PATH is set BEFORE the install so the browser lands
|
|
111
|
+
# under /opt, not root's home, and the tree is opened to every user
|
|
112
|
+
# (a+rX): the resident runs each agent command as an unprivileged
|
|
113
|
+
# workerN, and the two images share this layer. NODE_PATH makes
|
|
114
|
+
# `require('playwright')` resolve from any working directory. Playwright
|
|
115
|
+
# launches Chromium with --no-sandbox (`chromiumSandbox: false`, its
|
|
116
|
+
# default) — what a root process, or one without user namespaces, needs.
|
|
117
|
+
# Every claim is PROVEN by the layer itself, so the BUILD fails, not a run:
|
|
118
|
+
# the compilers answer --version, ffmpeg encodes a one-second testsrc clip to
|
|
119
|
+
# h264 and decodes a non-empty frame back out of it, the playwright CLI is on
|
|
120
|
+
# PATH at the pin, and a real headless screenshot of a page lands as a
|
|
121
|
+
# non-empty png. The proof artefacts, the apt lists, apt's .deb archive (this
|
|
122
|
+
# base has no docker-clean hook: 400 MB of archives stayed behind without the
|
|
123
|
+
# `clean`) and npm's cache go in the same layer.
|
|
124
|
+
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright \
|
|
125
|
+
NODE_PATH=/usr/local/lib/node_modules
|
|
126
|
+
RUN apt-get update \
|
|
127
|
+
&& apt-get install -y --no-install-recommends python3 make g++ ffmpeg fonts-liberation fonts-noto-color-emoji \
|
|
128
|
+
&& npm install -g playwright@1.63.0 \
|
|
129
|
+
&& playwright install --with-deps --only-shell chromium \
|
|
130
|
+
&& chmod -R a+rX /opt/ms-playwright \
|
|
131
|
+
&& apt-get clean && rm -rf /var/lib/apt/lists/* && npm cache clean --force \
|
|
132
|
+
&& python3 --version && g++ --version && make --version \
|
|
133
|
+
&& ffmpeg -version \
|
|
134
|
+
&& ffmpeg -hide_banner -loglevel error -f lavfi -i testsrc=duration=1:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p /tmp/proof.mp4 \
|
|
135
|
+
&& ffmpeg -hide_banner -loglevel error -i /tmp/proof.mp4 -vf fps=1 /tmp/proof_%03d.png && test -s /tmp/proof_001.png \
|
|
136
|
+
&& playwright --version | grep -qx 'Version 1.63.0' \
|
|
137
|
+
&& playwright screenshot --viewport-size=640,480 'data:text/html,<h1>ok</h1>' /tmp/ok.png && test -s /tmp/ok.png \
|
|
138
|
+
&& rm -f /tmp/proof.mp4 /tmp/proof_*.png /tmp/ok.png
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"name": "SWITCHBOARD_INGRESS_TOKENS",
|
|
39
39
|
"workers": ["bot"],
|
|
40
40
|
"optional": true,
|
|
41
|
-
"note": "JSON bearer→identity map for POST /ingress and POST /mcp. Self-minted (openssl rand -hex 32 per entry). Optional: without it /ingress and /mcp are disabled and `deploy restart` has no bearer."
|
|
41
|
+
"note": "JSON bearer→identity map for POST /ingress and POST /mcp. Self-minted (openssl rand -hex 32 per entry). Optional: without it /ingress and /mcp are disabled and `deploy restart` has no bearer. The `cron` entry fires schedules; a `coordinator` entry (granted `coordinator:step`) is the ship coordinator's, rotated apart from cron."
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
"name": "SANDBOX_TOKEN",
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.206.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.206.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.206.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.206.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/source.json
CHANGED
|
@@ -46,8 +46,8 @@ export interface AgentDef {
|
|
|
46
46
|
name: string;
|
|
47
47
|
description: string;
|
|
48
48
|
system: string;
|
|
49
|
-
/** key into TOOLSETS: "full" | "readonly" | "web" | "assistant" | "explore" | "none" */
|
|
50
|
-
toolset: "full" | "readonly" | "web" | "assistant" | "explore" | "none";
|
|
49
|
+
/** key into TOOLSETS: "full" | "readonly" | "web" | "assistant" | "explore" | "conductor" | "none" */
|
|
50
|
+
toolset: "full" | "readonly" | "web" | "assistant" | "explore" | "conductor" | "none";
|
|
51
51
|
/** backstop only — the wall clock below is the real budget */
|
|
52
52
|
maxTurns: number;
|
|
53
53
|
maxTokens: number;
|
|
@@ -136,9 +136,19 @@ const UNIT_CONTRACT = `UNIT CONTRACT: when your first user turn carries a \`${CO
|
|
|
136
136
|
// sandbox and resident children read the same rule.
|
|
137
137
|
const UNIT_HANDOFF = `UNIT HANDOFF: when your first user turn carries a \`${CONTRACT_HEADING}\` block, call the submit_handoff tool once, after submit_pr_description and before your final message, with the typed handoff — deviations: where you departed from the unit as written (from, to, why); followUps: what you found and did not do, and where it belongs (what, where); unproven: which of the unit's test scenarios or criteria you could not prove, and why (criterion, why). Switchboard records it on the run and posts it to the unit's board issue, where a person decides each row's disposition; you never edit the plan's ledger yourself. An empty handoff is submitted as three empty lists, never skipped — a missing handoff reads as an unfinished run, not as nothing to say. Without a \`${CONTRACT_HEADING}\` block, do not call it.`;
|
|
138
138
|
|
|
139
|
+
// What both execution images carry beyond git and the package managers
|
|
140
|
+
// (docs/reference/specs/execution.md item 10), said in one sentence by every
|
|
141
|
+
// prompt that describes a workspace — the model reaches only for what it has
|
|
142
|
+
// been told is there. The cold sandbox adds a Docker engine; the resident has
|
|
143
|
+
// yarn and bun and no engine (src/agents/registry.test.ts holds each prompt to
|
|
144
|
+
// its image).
|
|
145
|
+
const IMAGE_TOOLCHAIN = `Node 24 with npm and pnpm; python3, make and g++ (native modules build); ffmpeg (frames out of a video — \`ffmpeg -i in.mp4 -vf fps=1 f_%03d.png\` — and video out of frames or a recording); and a headless Chromium through Playwright — \`playwright screenshot <url> out.png\`, \`playwright pdf <url> out.pdf\`, or \`require('playwright')\` for a scripted page and \`recordVideo\``;
|
|
146
|
+
const SANDBOX_TOOLCHAIN = `The sandbox image carries ${IMAGE_TOOLCHAIN}; and Docker (the engine starts on the first \`docker\` call).`;
|
|
147
|
+
const RESIDENT_TOOLCHAIN = `The resident image carries ${IMAGE_TOOLCHAIN}; plus yarn and bun — and no Docker.`;
|
|
148
|
+
|
|
139
149
|
const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
|
|
140
150
|
|
|
141
|
-
You work inside a dedicated workspace directory with bash, read_file, and write_file tools.
|
|
151
|
+
You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
|
|
142
152
|
Typical job: take a task, clone the relevant repository, implement the change, push a branch, and submit a typed PR description — Switchboard opens the pull request from it.
|
|
143
153
|
|
|
144
154
|
SCOPE FIRST — a hard rule, at most 5 tool calls: identify the target repository and surface before doing anything else.
|
|
@@ -176,7 +186,7 @@ Your final message is posted to Slack — keep it readable, lead with the outcom
|
|
|
176
186
|
// the resident image: git + node only), so this variant replaces it.
|
|
177
187
|
export const CODING_SYSTEM_RESIDENT = `You are Switchboard's coding agent, operating from a Slack request.
|
|
178
188
|
|
|
179
|
-
You work inside a resident repository environment: your workspace is a ready git worktree of the target repository, already checked out on this thread's bound branch, with dependencies installed and the build warm. Your bash, read_file, and write_file tools run inside that worktree.
|
|
189
|
+
You work inside a resident repository environment: your workspace is a ready git worktree of the target repository, already checked out on this thread's bound branch, with dependencies installed and the build warm. Your bash, read_file, and write_file tools run inside that worktree. ${RESIDENT_TOOLCHAIN}
|
|
180
190
|
|
|
181
191
|
THE WORKSPACE IS READY — do not clone repositories, do not install dependencies, do not discover or survey other repos. Start from the code in front of you. Orient with a few BATCHED commands (e.g. \`git branch --show-current && git status && ls\` plus the relevant files in one call), not file-by-file exploration.
|
|
182
192
|
|
|
@@ -242,7 +252,7 @@ const REVIEW_WHOLE_CHANGE = ` - READ THE WHOLE CHANGE: the REVIEW TARGET block
|
|
|
242
252
|
|
|
243
253
|
const REVIEW_SYSTEM = `You are Switchboard's code review agent, operating from a Slack request.
|
|
244
254
|
|
|
245
|
-
You have bash and read_file tools in a workspace directory. Do not modify code, commit, or push — you are read-only by convention. Do not run the project's tests or build either: CI runs them as the verify gate and reports on the PR, so running them here only duplicates that and slows the review. Your job is to read the code.
|
|
255
|
+
You have bash and read_file tools in a workspace directory. ${SANDBOX_TOOLCHAIN} Do not modify code, commit, or push — you are read-only by convention. Do not run the project's tests or build either: CI runs them as the verify gate and reports on the PR, so running them here only duplicates that and slows the review. Your job is to read the code.
|
|
246
256
|
|
|
247
257
|
Strategy — GATHER ONCE, THEN ANALYZE ONCE. Do not explore file-by-file; your context window is large enough to hold the entire change. Speed matters: a review should take minutes, not an hour.
|
|
248
258
|
|
|
@@ -272,7 +282,7 @@ Your final message is posted to Slack. Lead with a one-line verdict, then the fi
|
|
|
272
282
|
// resident image has no `gh` CLI.
|
|
273
283
|
export const REVIEW_SYSTEM_RESIDENT = `You are Switchboard's code review agent, operating from a Slack request.
|
|
274
284
|
|
|
275
|
-
You have bash and read_file tools inside a resident repository environment: a ready git worktree of the target repository, already checked out on this thread's bound branch — the PR head named in the REVIEW TARGET block below — with dependencies installed. Do not modify code, commit, or push — you are read-only by convention. Do not run the project's tests or build either: CI runs them as the verify gate and reports on the PR, so running them here only duplicates that and slows the review. Your job is to read the code. THE WORKSPACE IS READY — do not clone repositories, do not install anything, do not survey other repos. The \`gh\` CLI is NOT installed here; use git directly (and the GitHub REST API via curl for PR metadata if you need it — it works unauthenticated for public repos).
|
|
285
|
+
You have bash and read_file tools inside a resident repository environment: a ready git worktree of the target repository, already checked out on this thread's bound branch — the PR head named in the REVIEW TARGET block below — with dependencies installed. Do not modify code, commit, or push — you are read-only by convention. Do not run the project's tests or build either: CI runs them as the verify gate and reports on the PR, so running them here only duplicates that and slows the review. Your job is to read the code. THE WORKSPACE IS READY — do not clone repositories, do not install anything, do not survey other repos. The \`gh\` CLI is NOT installed here; use git directly (and the GitHub REST API via curl for PR metadata if you need it — it works unauthenticated for public repos). ${RESIDENT_TOOLCHAIN}
|
|
276
286
|
|
|
277
287
|
Strategy — GATHER ONCE, THEN ANALYZE ONCE. Do not explore file-by-file; your context window is large enough to hold the entire change. Speed matters: a review should take minutes, not an hour.
|
|
278
288
|
|
|
@@ -341,7 +351,7 @@ You cannot run commands, clone repositories, edit code, or review pull requests,
|
|
|
341
351
|
// preset, not a directive.
|
|
342
352
|
const EXPLORE_SYSTEM = `You are Switchboard's explore agent: a long, read-only investigation of a repository, answering a request from Slack.
|
|
343
353
|
|
|
344
|
-
You work in a fresh sandbox with a shell (bash), read_file, and a read-scoped GitHub credential: git and gh are authenticated for reads, so clone the target repository into your workspace first (\`gh repo clone <owner/name>\` or \`git clone\`; check out the ref the request names), install what you need and run whatever the investigation calls for — builds, test suites, benchmarks, \`act
|
|
354
|
+
You work in a fresh sandbox with a shell (bash), read_file, and a read-scoped GitHub credential: git and gh are authenticated for reads, so clone the target repository into your workspace first (\`gh repo clone <owner/name>\` or \`git clone\`; check out the ref the request names), install what you need and run whatever the investigation calls for — builds, test suites, benchmarks, \`act\`. ${SANDBOX_TOOLCHAIN} You cannot push. Your other tools: \`web_search\` and \`web_fetch\` (sources and pages), the GitHub reads — \`github_repos\`, \`github_tree\` / \`github_file\` (browse and read our repos at any ref), \`github_search_code\`, \`github_issue_list\` / \`github_issue_get\` — and \`list_skills\` / \`use_skill\`.
|
|
345
355
|
|
|
346
356
|
THE DELIVERABLE IS A CLAIM TABLE. Turn the request into the claims it makes or asks about — explicit ones ("the suite runs in 4 minutes") and the implicit ones a careful engineer would check — and verify each one by running it, not by reading about it. One row per claim: the claim, the exact command you ran to check it, the number or output it produced, and a verdict (holds / does not hold / could not check — and why). Numbers over adjectives: measure a duration, count the failures, quote the version. Say what you did not get to.
|
|
347
357
|
|
|
@@ -353,6 +363,32 @@ Maintain the user-facing status card with the update_status tool: post your plan
|
|
|
353
363
|
|
|
354
364
|
Report outcomes faithfully: a check you could not run is "could not check", never a guess. Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks — render the claim table as aligned rows inside a code block). Your final message is posted to Slack: lead with the overall verdict in one line, then the claim table, then what a follow-up should do.`;
|
|
355
365
|
|
|
366
|
+
// The conductor (docs/reference/specs/agent-conductor.md): a run that starts
|
|
367
|
+
// other runs instead of doing the work — the spawn/await substrate's first
|
|
368
|
+
// preset. A child is a `dispatch()` run as the requesting user, in a thread of
|
|
369
|
+
// its own, under their permissions (docs/decisions/0002-dispatcher-is-the-only-orchestrator.md,
|
|
370
|
+
// docs/decisions/0007-authorization-policy-table.md): the prompt says exactly
|
|
371
|
+
// that, so the model never expects a child to see this thread or to hold more
|
|
372
|
+
// than its requester does. Machine `none`, identity `none`: it holds no
|
|
373
|
+
// workspace, no shell and no credential of its own; its reach is the three run
|
|
374
|
+
// tools, the GitHub reads and URL reading. The prompt names the limits the
|
|
375
|
+
// spawn stage enforces — one level of depth, the fan-out cap, the parent's
|
|
376
|
+
// remaining clock — so a refusal is never a surprise, and the presets a child
|
|
377
|
+
// can run, so the model picks from the real list.
|
|
378
|
+
const CONDUCTOR_SYSTEM = `You are Switchboard's conductor: you coordinate other runs instead of doing the work yourself, answering a request from Slack.
|
|
379
|
+
|
|
380
|
+
You have no workspace and no shell. Your tools: \`spawn_run\` (start a child run), \`send_to_run\` (steer a live child: your text reaches it as a follow-up at its next step), \`await_runs\` (wait for your children to end and get each end — its status and final reply — back as data), \`list_runs\` (the runs you may see — your own children by default), \`get_run_status\` (one run: whether it is running, what it is doing, and its final reply once it finished), the GitHub reads — \`github_repos\`, \`github_tree\` / \`github_file\` (browse and read our repositories), \`github_search_code\`, \`github_issue_list\` / \`github_issue_get\` — \`web_fetch\` (read a public URL), and \`update_status\`.
|
|
381
|
+
|
|
382
|
+
WHAT A CHILD IS. A child is an ordinary Switchboard run started as the person who asked you — exactly the run they could start by hand with \`agent:<preset>\` — in a thread of its own in this channel, visible to everyone there, with its own status card and run page, and under their permissions: a preset they may not run, a repository they may not use, or a profile a boundary caps is refused in the child's thread, and the refusal comes back to you as the tool result naming the gate. Children cannot spawn children. You may have a few live at once (the deployment's \`spawn.maxChildren\`, three by default); a spawn past the cap is refused until one finishes. A child's wall clock is capped by what is left of yours.
|
|
383
|
+
|
|
384
|
+
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).
|
|
385
|
+
|
|
386
|
+
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\`.
|
|
387
|
+
|
|
388
|
+
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).
|
|
389
|
+
|
|
390
|
+
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.`;
|
|
391
|
+
|
|
356
392
|
export const AGENTS: Record<string, AgentDef> = {
|
|
357
393
|
general: {
|
|
358
394
|
name: "general",
|
|
@@ -455,6 +491,21 @@ export const AGENTS: Record<string, AgentDef> = {
|
|
|
455
491
|
cacheTtl: "1h",
|
|
456
492
|
// No built-in effort: the deployment decides, as for coding.
|
|
457
493
|
},
|
|
494
|
+
conductor: {
|
|
495
|
+
name: "conductor",
|
|
496
|
+
description:
|
|
497
|
+
"Coordinates other runs: spawns child runs as the requester — each in a thread of its own, under their permissions — follows them, and reports. No workspace or shell.",
|
|
498
|
+
system: CONDUCTOR_SYSTEM,
|
|
499
|
+
toolset: "conductor",
|
|
500
|
+
// Nothing is provisioned and no credential minted: the run tools call the
|
|
501
|
+
// dispatcher, the GitHub reads are REST in the bot process.
|
|
502
|
+
machine: "none",
|
|
503
|
+
identity: "none",
|
|
504
|
+
maxTurns: 40, // a spawn, then a poll per child every few minutes; the wall clock is the budget
|
|
505
|
+
maxTokens: 32000,
|
|
506
|
+
maxMinutes: 120, // long enough to outlast a coding child; every child is capped by what remains of it
|
|
507
|
+
// No built-in effort: the deployment decides, as for coding.
|
|
508
|
+
},
|
|
458
509
|
};
|
|
459
510
|
|
|
460
511
|
export function getAgent(name: string): AgentDef {
|
|
@@ -8,15 +8,26 @@ import type { AgentDef, Identity, MachineClass } from "../agents/registry.js";
|
|
|
8
8
|
|
|
9
9
|
export type { Identity, MachineClass };
|
|
10
10
|
|
|
11
|
-
/** Where a cap on the profile came from — named on a clip and on a refusal.
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
/** Where a cap on the profile came from — named on a clip and on a refusal.
|
|
12
|
+
* `parent` is the wall clock a spawning run had left when it started a child
|
|
13
|
+
* (docs/reference/specs/routing-and-config.md item 20): a boundary on the
|
|
14
|
+
* minutes axis alone, never on the identity or the class. */
|
|
15
|
+
export type BoundaryScope = "defaults" | "channel" | "user" | "directive" | "parent";
|
|
16
|
+
export const BOUNDARY_SCOPES: readonly BoundaryScope[] = ["defaults", "channel", "user", "directive", "parent"];
|
|
14
17
|
|
|
15
18
|
/** The clip's source as the card and the config block name it: a scope's cap
|
|
16
19
|
* is `<scope> boundary`, the caller's own `budget:` directive is `budget
|
|
17
|
-
* directive
|
|
20
|
+
* directive`, a spawning run's remaining wall clock is `parent run's budget`
|
|
21
|
+
* — one wording for every surface that says what clipped a run. */
|
|
18
22
|
export function clipSourceLabel(scope: BoundaryScope): string {
|
|
19
|
-
|
|
23
|
+
switch (scope) {
|
|
24
|
+
case "directive":
|
|
25
|
+
return "budget directive";
|
|
26
|
+
case "parent":
|
|
27
|
+
return "parent run's budget";
|
|
28
|
+
default:
|
|
29
|
+
return `${scope} boundary`;
|
|
30
|
+
}
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
/** What one run may have: the three axes, and — when a boundary clipped the
|
|
@@ -129,6 +140,24 @@ export function intersectBoundaries(layers: readonly ScopedBoundary[]): Effectiv
|
|
|
129
140
|
return out.maxMinutes || out.maxIdentity || out.machines ? out : undefined;
|
|
130
141
|
}
|
|
131
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The boundaries on a child's path with its parent's remaining wall clock as
|
|
145
|
+
* one more layer (docs/reference/specs/routing-and-config.md item 20): the
|
|
146
|
+
* whole minutes the parent has left cap the child's minutes, attributed to
|
|
147
|
+
* `parent`, when that is tighter than every cap already on the path. The
|
|
148
|
+
* identity and the class are untouched — a parent hands a child time, never a
|
|
149
|
+
* credential or a machine. The same object comes back when the parent's clock
|
|
150
|
+
* is not the tightest cap, so a caller can tell "nothing changed" apart.
|
|
151
|
+
*/
|
|
152
|
+
export function boundedByParent(
|
|
153
|
+
boundary: EffectiveBoundary | undefined,
|
|
154
|
+
parentRemainingMs: number,
|
|
155
|
+
): EffectiveBoundary | undefined {
|
|
156
|
+
const minutes = Math.floor(parentRemainingMs / 60_000);
|
|
157
|
+
if (boundary?.maxMinutes && boundary.maxMinutes.value <= minutes) return boundary;
|
|
158
|
+
return { ...boundary, maxMinutes: { value: minutes, scope: "parent" } };
|
|
159
|
+
}
|
|
160
|
+
|
|
132
161
|
/** Why a profile was refused: the axis, what the preset needs, what the
|
|
133
162
|
* boundary allows, and the scope(s) that set it — everything the refusal
|
|
134
163
|
* reply names. */
|
|
@@ -175,6 +175,21 @@ export const POLICY: readonly Rule[] = [
|
|
|
175
175
|
// ── schedules ────────────────────────────────────────────────────────────
|
|
176
176
|
// Only the schedule shim's actor fires a schedule.
|
|
177
177
|
{ action: "schedule:fire", resource: "command", actorKinds: ["schedule"], when: [] },
|
|
178
|
+
|
|
179
|
+
// ── coordinator ──────────────────────────────────────────────────────────
|
|
180
|
+
// A coordinator step (`spawn`, `read-record`, `pr-check`, the instance
|
|
181
|
+
// creation): a machine identity holding the grant — the `coordinator`
|
|
182
|
+
// ingress bearer, granted `coordinator:step` by name — and never a person,
|
|
183
|
+
// an admin's `all` included: the steps are a program's, and the child a
|
|
184
|
+
// spawn starts is authorized as the requesting user the parent record
|
|
185
|
+
// names, not as whoever holds this grant.
|
|
186
|
+
{ action: "coordinator:step", resource: "command", actorKinds: ["service"], when: [grant("coordinator:step")] },
|
|
187
|
+
// The plan runner's merge (docs/decisions/0031-the-coordinator-runs-a-plan-not-a-pull-request.md,
|
|
188
|
+
// "The merge grant"): its own action, held by the same bearer by name and by
|
|
189
|
+
// no person — what decides a merge is the branch (a plan branch the runner
|
|
190
|
+
// itself opened) and the guards, never the requester. Withdrawing the grant
|
|
191
|
+
// returns every merge to a person.
|
|
192
|
+
{ action: "plan:merge", resource: "command", actorKinds: ["service"], when: [grant("plan:merge")] },
|
|
178
193
|
];
|
|
179
194
|
|
|
180
195
|
export const ACTOR_KINDS: readonly ActorKind[] = ["user", "service", "schedule", "agent"];
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// The ship coordinator's contract (docs/decisions/0029-durable-objects-store-workflows-schedule.md,
|
|
2
|
+
// docs/decisions/0031-the-coordinator-runs-a-plan-not-a-pull-request.md;
|
|
3
|
+
// docs/reference/specs/run-history.md items 47–48; docs/reference/specs/http-ingress.md
|
|
4
|
+
// item 9): what the state Worker, the bot and the bot's shim Worker agree on
|
|
5
|
+
// about a coordinator instance and its children. Node-free — imported by all
|
|
6
|
+
// three by relative path, the way runRecord.ts is — so the shapes agree by
|
|
7
|
+
// construction: the event a finished child sends, the key a spawn carries, the
|
|
8
|
+
// parent record the spawn route reads the requester from, and the names the
|
|
9
|
+
// routes decide on.
|
|
10
|
+
//
|
|
11
|
+
// A coordinator is a Workflow instance in the shim Worker whose children are
|
|
12
|
+
// ordinary `dispatch()` runs as the requesting user. It holds no credential of
|
|
13
|
+
// its own: every step is a call into the bot with the `coordinator` bearer,
|
|
14
|
+
// whose actor holds `coordinator:step` — the one grant that admits the steps.
|
|
15
|
+
|
|
16
|
+
/** The ingress subject the coordinator presents (`SWITCHBOARD_INGRESS_TOKENS`
|
|
17
|
+
* entry `{ subject: "coordinator" }` → the actor `http:coordinator`). A name,
|
|
18
|
+
* not authority: what admits a step is the grant below on that actor. */
|
|
19
|
+
export const COORDINATOR_IDENTITY = "coordinator";
|
|
20
|
+
/** The policy action every coordinator step is decided on (authorization.md item 2). */
|
|
21
|
+
export const COORDINATOR_STEP_ACTION = "coordinator:step";
|
|
22
|
+
/** The plan runner's merge, its own action on the same bearer (record 0031's merge grant): a
|
|
23
|
+
* plan branch's pull request is merged by the runner only under it; withdrawn, every merge is a person's. */
|
|
24
|
+
export const PLAN_MERGE_ACTION = "plan:merge";
|
|
25
|
+
/** Where the bot answers the steps: `POST <prefix><step>` on the container, forwarded by the shim like every `/admin/*` path. */
|
|
26
|
+
export const COORDINATOR_STEP_PATH_PREFIX = "/admin/coordinator/";
|
|
27
|
+
|
|
28
|
+
/** A Workflow instance id: the platform's own alphabet, at most 100 characters. */
|
|
29
|
+
export const INSTANCE_ID_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,99}$/;
|
|
30
|
+
/** A step name: `<unit>/<round>/<kind>` and its kin — no colon, which separates it from the instance in the key. */
|
|
31
|
+
export const STEP_NAME_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_./-]{0,119}$/;
|
|
32
|
+
/** `<parentInstanceId>:<step>` — the idempotency key a spawn carries and the child's claim stores. */
|
|
33
|
+
export const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,99}:[A-Za-z0-9_][A-Za-z0-9_./-]{0,119}$/;
|
|
34
|
+
|
|
35
|
+
export function idempotencyKeyFor(parentInstanceId: string, step: string): string {
|
|
36
|
+
return `${parentInstanceId}:${step}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The event a child's terminal record sends its parent: the type carries the
|
|
40
|
+
* run id, so each `waitForEvent` matches its own child and a duplicate is
|
|
41
|
+
* buffered harmlessly. */
|
|
42
|
+
export const RUN_FINISHED_EVENT_PREFIX = "run finished:";
|
|
43
|
+
export function runFinishedEventType(runId: string): string {
|
|
44
|
+
return `${RUN_FINISHED_EVENT_PREFIX}${runId}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What the event carries — ids, a status and a clock; the parent confirms
|
|
48
|
+
* through `read-record` before it acts, so nothing more rides here. */
|
|
49
|
+
export interface RunFinishedPayload {
|
|
50
|
+
runId: string;
|
|
51
|
+
status: string;
|
|
52
|
+
finishedAt: number;
|
|
53
|
+
parentInstanceId: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What a coordinator's spawn stamps on the child's every row: the instance
|
|
57
|
+
* the child belongs to and the key the spawn carried. */
|
|
58
|
+
export interface CoordinatorTag {
|
|
59
|
+
parentInstanceId: string;
|
|
60
|
+
idempotencyKey: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The tag as the two flat record fields, or nothing — so a row, a summary
|
|
64
|
+
* and a record spread the same thing and a run with no coordinator carries no key. */
|
|
65
|
+
export function coordinatorFields(tag: CoordinatorTag | undefined): {
|
|
66
|
+
parentInstanceId?: string;
|
|
67
|
+
idempotencyKey?: string;
|
|
68
|
+
} {
|
|
69
|
+
return tag ? { parentInstanceId: tag.parentInstanceId, idempotencyKey: tag.idempotencyKey } : {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The parent ship record: what the bot writes at an instance's creation and
|
|
73
|
+
* the spawn route reads the requester, channel and thread from — so a step
|
|
74
|
+
* never takes an actor from its caller. Ids only, never the task text. The
|
|
75
|
+
* units the instance runs are rows of their own (`CoordinatorUnit`), so this
|
|
76
|
+
* row stays the instance's identity and its two surfaces: the card the bot
|
|
77
|
+
* redraws and the run record it writes at the end. */
|
|
78
|
+
export interface CoordinatorInstance {
|
|
79
|
+
id: string;
|
|
80
|
+
kind: "ship";
|
|
81
|
+
/** The requesting user (platform-namespaced), whose grants every child is authorized under. */
|
|
82
|
+
userId: string;
|
|
83
|
+
userName?: string;
|
|
84
|
+
channelId: string;
|
|
85
|
+
channelName?: string;
|
|
86
|
+
/** The requesting thread: where the card lives and where a task-string
|
|
87
|
+
* instance's one unit runs; a plan's units each open a thread of their own. */
|
|
88
|
+
threadKey: string;
|
|
89
|
+
sourceUrl?: string;
|
|
90
|
+
/** `owner/name`, and the head branch the pipeline works on (a task string's
|
|
91
|
+
* deterministic ship branch; for a plan, the first unit's — each unit row names its own). */
|
|
92
|
+
repo: string;
|
|
93
|
+
branch: string;
|
|
94
|
+
/** The pull request's base branch, when the creator knew it. */
|
|
95
|
+
base?: string;
|
|
96
|
+
/** Epoch ms. */
|
|
97
|
+
createdAt: number;
|
|
98
|
+
/** The plan the instance runs, when it runs one: its id (the file's name) and its path in the repository. */
|
|
99
|
+
plan?: { id: string; path: string };
|
|
100
|
+
/** The pipeline's caps as the profile gate clipped them: the rounds cap and the wall clock per unit. */
|
|
101
|
+
caps?: { maxRounds: number; maxMinutes: number };
|
|
102
|
+
/** The status card in the requesting thread, when the channel has one — what
|
|
103
|
+
* the bot redraws from the coordinator's round events (`StatusHandle.handle`). */
|
|
104
|
+
card?: { channel: string; ts: string };
|
|
105
|
+
/** The run id the bot writes the parent's record under when the instance ends, and the card's label. */
|
|
106
|
+
runId?: string;
|
|
107
|
+
label?: string;
|
|
108
|
+
/** Which attempt of the plan this instance runs (a re-issue after an earlier
|
|
109
|
+
* attempt ended reruns the units not merged under `plan-<plan-id>-<attempt>`);
|
|
110
|
+
* absent for the first. */
|
|
111
|
+
attempt?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** One unit of the plan an instance runs (a task string is a plan of one unit,
|
|
115
|
+
* `task`): its branch, the units it waits on, and — as the runner reaches it —
|
|
116
|
+
* its thread, its pull request, the round boundaries the card drew and how it
|
|
117
|
+
* ended. One row a person can read for "what happened to this unit". */
|
|
118
|
+
export interface CoordinatorUnit {
|
|
119
|
+
instanceId: string;
|
|
120
|
+
/** `U<n>` as the plan spells it, or `task`. */
|
|
121
|
+
unit: string;
|
|
122
|
+
slug: string;
|
|
123
|
+
title?: string;
|
|
124
|
+
/** `plan/<plan-id>/<unit-slug>`, or the task string's ship branch. */
|
|
125
|
+
branch: string;
|
|
126
|
+
dependsOn: string[];
|
|
127
|
+
/** The unit's thread, once opened; a task's is the requesting thread from the start. */
|
|
128
|
+
threadKey?: string;
|
|
129
|
+
sourceUrl?: string;
|
|
130
|
+
/** The unit's board issue in the repository, when one titled by the unit id exists — the handoff's destination. */
|
|
131
|
+
issue?: number;
|
|
132
|
+
pr?: { number: number; url: string };
|
|
133
|
+
/** The round boundaries the coordinator reported, oldest first (the `ship_round` vocabulary). */
|
|
134
|
+
rounds: Array<{ index: number; agent: string; outcome: string; at: number }>;
|
|
135
|
+
/** How the unit ended: the ending's kind and the thread's report, when it has. */
|
|
136
|
+
ending?: { kind: string; report: string; at: number };
|
|
137
|
+
startedAt?: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const REPO_SLUG = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
141
|
+
const MAX_TEXT = 512;
|
|
142
|
+
/** A unit's report — the loop's words for how it ended, with a cap report's findings — is longer than a name. */
|
|
143
|
+
const MAX_REPORT = 20_000;
|
|
144
|
+
const MAX_ROUNDS = 200;
|
|
145
|
+
|
|
146
|
+
const isText = (v: unknown, max = MAX_TEXT): v is string => typeof v === "string" && v.length > 0 && v.length <= max;
|
|
147
|
+
const isOptionalText = (v: unknown): boolean => v === undefined || isText(v);
|
|
148
|
+
const isFinite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
149
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null;
|
|
150
|
+
const isPr = (v: unknown): boolean => isObject(v) && isFinite(v.number) && isText(v.url, 2048);
|
|
151
|
+
|
|
152
|
+
/** Structural check on a record from outside the process (a Worker response, an HTTP body). */
|
|
153
|
+
export function isCoordinatorInstance(v: unknown): v is CoordinatorInstance {
|
|
154
|
+
if (!isObject(v)) return false;
|
|
155
|
+
const r = v;
|
|
156
|
+
if (typeof r.id !== "string" || !INSTANCE_ID_PATTERN.test(r.id)) return false;
|
|
157
|
+
if (r.kind !== "ship") return false;
|
|
158
|
+
if (!isText(r.userId) || !isText(r.channelId) || !isText(r.threadKey)) return false;
|
|
159
|
+
if (!isOptionalText(r.userName) || !isOptionalText(r.channelName) || !isOptionalText(r.sourceUrl)) return false;
|
|
160
|
+
if (typeof r.repo !== "string" || !REPO_SLUG.test(r.repo)) return false;
|
|
161
|
+
if (!isText(r.branch) || !isOptionalText(r.base)) return false;
|
|
162
|
+
if (!isFinite(r.createdAt)) return false;
|
|
163
|
+
if (r.plan !== undefined && !(isObject(r.plan) && isText(r.plan.id) && isText(r.plan.path, 1024))) return false;
|
|
164
|
+
if (r.caps !== undefined && !(isObject(r.caps) && isFinite(r.caps.maxRounds) && isFinite(r.caps.maxMinutes)))
|
|
165
|
+
return false;
|
|
166
|
+
if (r.card !== undefined && !(isObject(r.card) && isText(r.card.channel) && isText(r.card.ts))) return false;
|
|
167
|
+
if (!isOptionalText(r.runId) || !isOptionalText(r.label)) return false;
|
|
168
|
+
if (r.attempt !== undefined && !(Number.isInteger(r.attempt) && (r.attempt as number) >= 2)) return false;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Structural check on a unit row from outside the process. */
|
|
173
|
+
export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
|
|
174
|
+
if (!isObject(v)) return false;
|
|
175
|
+
const r = v;
|
|
176
|
+
if (typeof r.instanceId !== "string" || !INSTANCE_ID_PATTERN.test(r.instanceId)) return false;
|
|
177
|
+
if (!isText(r.unit, 32) || !isText(r.slug) || !isText(r.branch) || !isOptionalText(r.title)) return false;
|
|
178
|
+
if (!Array.isArray(r.dependsOn) || !r.dependsOn.every((d) => isText(d, 32))) return false;
|
|
179
|
+
if (!isOptionalText(r.threadKey) || !isOptionalText(r.sourceUrl)) return false;
|
|
180
|
+
if (r.issue !== undefined && !isFinite(r.issue)) return false;
|
|
181
|
+
if (r.pr !== undefined && !isPr(r.pr)) return false;
|
|
182
|
+
if (
|
|
183
|
+
!Array.isArray(r.rounds) ||
|
|
184
|
+
r.rounds.length > MAX_ROUNDS ||
|
|
185
|
+
!r.rounds.every((x) => isObject(x) && isFinite(x.index) && isText(x.agent) && isText(x.outcome) && isFinite(x.at))
|
|
186
|
+
)
|
|
187
|
+
return false;
|
|
188
|
+
if (
|
|
189
|
+
r.ending !== undefined &&
|
|
190
|
+
!(isObject(r.ending) && isText(r.ending.kind) && isText(r.ending.report, MAX_REPORT) && isFinite(r.ending.at))
|
|
191
|
+
)
|
|
192
|
+
return false;
|
|
193
|
+
if (r.startedAt !== undefined && !isFinite(r.startedAt)) return false;
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The slice of a Workflow binding the send needs (`Workflow.get` →
|
|
198
|
+
* `WorkflowInstance.sendEvent`), so the state Worker's handler is testable
|
|
199
|
+
* with a double and the contract names no platform type. */
|
|
200
|
+
export interface WorkflowInstanceSender {
|
|
201
|
+
sendEvent(event: { type: string; payload: unknown }): Promise<void>;
|
|
202
|
+
}
|
|
203
|
+
export interface WorkflowSender {
|
|
204
|
+
get(id: string): Promise<WorkflowInstanceSender>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** How a send ended. `none`: the record names no instance. `no-binding`: the
|
|
208
|
+
* Worker has no coordinator binding. `failed`: the engine refused (the
|
|
209
|
+
* instance ended, or is unknown) — swallowed, never thrown: the commit stands,
|
|
210
|
+
* and the parent's `waitForEvent` timeout falls back to `read-record`. */
|
|
211
|
+
export type RunFinishedSend =
|
|
212
|
+
| { kind: "sent"; instance: string; type: string }
|
|
213
|
+
| { kind: "none" }
|
|
214
|
+
| { kind: "no-binding"; instance: string }
|
|
215
|
+
| { kind: "failed"; instance: string; type: string; reason: string };
|
|
216
|
+
|
|
217
|
+
/** The one send per committed terminal record (run-history item 47). */
|
|
218
|
+
export async function sendRunFinished(
|
|
219
|
+
workflow: WorkflowSender | undefined,
|
|
220
|
+
record: { id: string; status: string; finishedAt: number; parentInstanceId?: string },
|
|
221
|
+
): Promise<RunFinishedSend> {
|
|
222
|
+
const instance = record.parentInstanceId;
|
|
223
|
+
if (instance === undefined) return { kind: "none" };
|
|
224
|
+
if (!workflow) return { kind: "no-binding", instance };
|
|
225
|
+
const type = runFinishedEventType(record.id);
|
|
226
|
+
const payload: RunFinishedPayload = {
|
|
227
|
+
runId: record.id,
|
|
228
|
+
status: record.status,
|
|
229
|
+
finishedAt: record.finishedAt,
|
|
230
|
+
parentInstanceId: instance,
|
|
231
|
+
};
|
|
232
|
+
try {
|
|
233
|
+
const handle = await workflow.get(instance);
|
|
234
|
+
await handle.sendEvent({ type, payload });
|
|
235
|
+
return { kind: "sent", instance, type };
|
|
236
|
+
} catch (err) {
|
|
237
|
+
return { kind: "failed", instance, type, reason: err instanceof Error ? err.message : String(err) };
|
|
238
|
+
}
|
|
239
|
+
}
|