@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.
Files changed (45) hide show
  1. package/dist/assets/Dockerfile +6 -3
  2. package/dist/assets/config/config.example.yaml +24 -1
  3. package/dist/assets/deploy/cloudflare/coordinator.ts +86 -0
  4. package/dist/assets/deploy/cloudflare/shared.ts +9 -0
  5. package/dist/assets/deploy/cloudflare/worker.ts +142 -6
  6. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +9 -0
  7. package/dist/assets/deploy/cloudflare-memory/worker.ts +266 -18
  8. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +15 -0
  9. package/dist/assets/deploy/cloudflare-resident/Dockerfile +89 -3
  10. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +81 -10
  11. package/dist/assets/deploy/secrets.manifest.json +1 -1
  12. package/dist/assets/package-lock.json +3 -3
  13. package/dist/assets/package.json +1 -1
  14. package/dist/assets/source.json +3 -3
  15. package/dist/assets/src/agents/registry.ts +58 -7
  16. package/dist/assets/src/config/profile.ts +34 -5
  17. package/dist/assets/src/core/authz/policy.ts +15 -0
  18. package/dist/assets/src/core/coordinator/contract.ts +239 -0
  19. package/dist/assets/src/core/coordinator/driver.ts +501 -0
  20. package/dist/assets/src/core/coordinator/instancesRoute.ts +181 -0
  21. package/dist/assets/src/core/delivery.ts +69 -15
  22. package/dist/assets/src/core/deliverySnapshotStore.ts +84 -24
  23. package/dist/assets/src/core/reviewVerdict.ts +296 -0
  24. package/dist/assets/src/core/reviewedHead.ts +77 -0
  25. package/dist/assets/src/core/runEvents.ts +4 -2
  26. package/dist/assets/src/core/runLedger/decisions.ts +2 -1
  27. package/dist/assets/src/core/runLedger/types.ts +17 -1
  28. package/dist/assets/src/core/runRecord.ts +56 -0
  29. package/dist/assets/src/core/ship/coordinator.ts +1039 -0
  30. package/dist/assets/web/dist/.vite/manifest.json +20 -20
  31. package/dist/assets/web/dist/assets/DeliveryPage-CvlWP7Eq.js +1 -0
  32. package/dist/assets/web/dist/assets/{ResidentDetailPage-Bcasasjb.js → ResidentDetailPage-D3P21yeI.js} +1 -1
  33. package/dist/assets/web/dist/assets/{ResidentsIndexPage-BJwyrphn.js → ResidentsIndexPage-DOYqnZ1q.js} +1 -1
  34. package/dist/assets/web/dist/assets/{RunRoutePage-6eStApqP.js → RunRoutePage-OmvrvPXY.js} +4 -4
  35. package/dist/assets/web/dist/assets/{RunsIndexPage-CWrkv7v8.js → RunsIndexPage-DWbSQtL4.js} +1 -1
  36. package/dist/assets/web/dist/assets/{ScheduledPage-BX2py1X3.js → ScheduledPage-CPKfJ4mR.js} +1 -1
  37. package/dist/assets/web/dist/assets/{StatusDot-CwCK84JN.js → StatusDot-COr8jTyM.js} +1 -1
  38. package/dist/assets/web/dist/assets/{Tooltip-CFC88_-Z.js → Tooltip-fOqTZkNT.js} +1 -1
  39. package/dist/assets/web/dist/assets/{dist-BoLiHpua.js → dist-BVjAWgkb.js} +1 -1
  40. package/dist/assets/web/dist/assets/main-CuENKPdD.css +1 -0
  41. package/dist/assets/web/dist/assets/{main-tYcFk9Dc.js → main-DZbJaqUb.js} +2 -2
  42. package/dist/cli.js +4621 -1804
  43. package/package.json +1 -1
  44. package/dist/assets/web/dist/assets/DeliveryPage-Cx7kQC_e.js +0 -1
  45. package/dist/assets/web/dist/assets/main-i3ZNDRLK.css +0 -1
@@ -11,7 +11,12 @@ import {
11
11
  import { tokenize } from "../../src/core/memory/scorer.ts";
12
12
  import { FIRING_DETAIL_MAX, isScheduleFiring, type ScheduleFiring } from "../../src/core/schedules.ts";
13
13
  import { REPO_SLUG } from "../../src/core/delivery.ts";
14
- import { isDeliverySnapshot, type DeliverySnapshot } from "../../src/core/deliverySnapshotStore.ts";
14
+ import {
15
+ isDeliverySnapshot,
16
+ isDeliverySnapshotPatch,
17
+ type DeliverySnapshot,
18
+ type DeliverySnapshotPatch,
19
+ } from "../../src/core/deliverySnapshotStore.ts";
15
20
  import {
16
21
  applyRetention,
17
22
  clampRetentionPolicy,
@@ -43,6 +48,16 @@ import {
43
48
  reclaimPhase,
44
49
  selectReclaim,
45
50
  } from "../../src/core/runLedger/decisions.ts";
51
+ import {
52
+ IDEMPOTENCY_KEY_PATTERN,
53
+ INSTANCE_ID_PATTERN,
54
+ isCoordinatorInstance,
55
+ isCoordinatorUnit,
56
+ sendRunFinished,
57
+ type CoordinatorInstance,
58
+ type CoordinatorUnit,
59
+ type RunFinishedSend,
60
+ } from "../../src/core/coordinator/contract.ts";
46
61
  import {
47
62
  GEN_PATTERN,
48
63
  type ClaimRequest,
@@ -131,6 +146,12 @@ export interface Env {
131
146
  RUN_TRANSCRIPTS: DurableObjectNamespace<RunTranscriptDO>;
132
147
  /** Delivery snapshots (delivery item 10): ONE DeliveryDO (named "delivery"), one snapshot per repository. */
133
148
  DELIVERY: DurableObjectNamespace<DeliveryDO>;
149
+ /** The ship coordinator Workflow in the bot's shim Worker (run-history item
150
+ * 47): where `RunHistoryDO.finish` sends `run finished:<runId>` for a record
151
+ * carrying `parentInstanceId`. Optional: this Worker deploys without it (the
152
+ * binding is a cross-script one, and the class must exist on the bot before
153
+ * the state Worker may name it), and a finish then commits with no event. */
154
+ SHIP_COORDINATOR?: Workflow;
134
155
  MEMORY_TOKEN?: string;
135
156
  }
136
157
 
@@ -149,6 +170,8 @@ const SCHEDULES_OBJECT = "schedules";
149
170
  const MAX_LIMIT = 50;
150
171
  /** Candidates per write batch (the reflection pass emits ≤6). */
151
172
  const MAX_BATCH = 50;
173
+ /** Unit rows one put may carry: a plan has tens of units, never hundreds. */
174
+ const MAX_UNITS_PER_PUT = 200;
152
175
  const MAX_TEXT_CHARS = 4000;
153
176
  const MAX_KEYWORDS = 20;
154
177
  const MAX_KEYWORD_CHARS = 64;
@@ -713,11 +736,14 @@ function parseStored<T>(text: string, guard: (v: unknown) => v is T): T | null {
713
736
 
714
737
  // The delivery page's snapshot (docs/reference/specs/delivery.md item 10): the
715
738
  // merged pull requests' facts over the snapshot window, as GitHub gave them,
716
- // and when they were read. A busy repository's window is a few MB — the review
739
+ // and when they were read. A busy repository's window is many MB — the review
717
740
  // bodies and every workflow run of every branch — so a snapshot is stored as
718
741
  // one row per pull request under a meta row, never as one JSON value (the
719
742
  // per-row limit is 2 MB). A put replaces the repository's snapshot whole, in
720
- // one transaction; a get reassembles it in pull request number order.
743
+ // one transaction — the first read; a merge applies a refresh — the rows it
744
+ // re-read replace theirs by number, the rows that aged out go, the meta is
745
+ // replaced — so the hourly write is the change, not the window; a get
746
+ // reassembles the snapshot in pull request number order.
721
747
 
722
748
  /** One pull request's facts may not exceed a fraction of the row limit; the fence names the pull request. */
723
749
  const MAX_PULL_REQUEST_FACTS_BYTES = 1024 * 1024;
@@ -766,6 +792,38 @@ export class DeliveryDO extends DurableObject<Env> {
766
792
  return prs.length;
767
793
  }
768
794
 
795
+ /** Apply a refresh to the repository's snapshot in one transaction; the rows now stored, or null
796
+ * when the repository has no snapshot to merge into (a partial snapshot would claim a
797
+ * completeness it lacks — the caller writes whole instead). */
798
+ async merge(patch: DeliverySnapshotPatch): Promise<number | null> {
799
+ const { upsert, drop, ...meta } = patch;
800
+ return this.ctx.storage.transactionSync(() => {
801
+ const stored = this.sql.exec(`SELECT 1 FROM snapshots WHERE repo = ?`, patch.repo).toArray().length > 0;
802
+ if (!stored) return null;
803
+ for (const pr of upsert) {
804
+ this.sql.exec(
805
+ `INSERT OR REPLACE INTO pull_requests (repo, number, facts) VALUES (?, ?, ?)`,
806
+ patch.repo,
807
+ pr.number,
808
+ JSON.stringify(pr),
809
+ );
810
+ }
811
+ for (const number of drop) {
812
+ this.sql.exec(`DELETE FROM pull_requests WHERE repo = ? AND number = ?`, patch.repo, number);
813
+ }
814
+ this.sql.exec(
815
+ `INSERT OR REPLACE INTO snapshots (repo, snapshot_at, meta) VALUES (?, ?, ?)`,
816
+ patch.repo,
817
+ patch.snapshotAt,
818
+ JSON.stringify(meta),
819
+ );
820
+ const count = this.sql
821
+ .exec<{ n: number }>(`SELECT COUNT(*) AS n FROM pull_requests WHERE repo = ?`, patch.repo)
822
+ .toArray()[0];
823
+ return count?.n ?? 0;
824
+ });
825
+ }
826
+
769
827
  /** The repository's snapshot, pull requests in number order; null when none was stored. */
770
828
  async get(repo: string): Promise<DeliverySnapshot | null> {
771
829
  const row = this.sql.exec<{ meta: string }>(`SELECT meta FROM snapshots WHERE repo = ?`, repo).toArray()[0];
@@ -778,7 +836,13 @@ export class DeliveryDO extends DurableObject<Env> {
778
836
  }
779
837
  }
780
838
 
781
- const DELIVERY_ROUTES = new Set(["/delivery/get", "/delivery/put"]);
839
+ const DELIVERY_ROUTES = new Set(["/delivery/get", "/delivery/put", "/delivery/merge"]);
840
+
841
+ /** The pull request whose facts exceed the row fence, if any — checked before the transaction. */
842
+ function oversizedFacts(prs: readonly DeliverySnapshot["prs"][number][]): number | undefined {
843
+ const encoder = new TextEncoder();
844
+ return prs.find((pr) => encoder.encode(JSON.stringify(pr)).byteLength > MAX_PULL_REQUEST_FACTS_BYTES)?.number;
845
+ }
782
846
 
783
847
  async function handleDelivery(pathname: string, body: unknown, env: Env): Promise<Response> {
784
848
  const b = (typeof body === "object" && body !== null ? body : {}) as Record<string, unknown>;
@@ -797,19 +861,38 @@ async function handleDelivery(pathname: string, body: unknown, env: Env): Promis
797
861
  { error: "snapshot must be a DeliverySnapshot (repo, snapshotAt, range, prs[], truncated, completeFrom)" },
798
862
  400,
799
863
  );
800
- const encoder = new TextEncoder();
801
- const oversized = b.snapshot.prs.find(
802
- (pr) => encoder.encode(JSON.stringify(pr)).byteLength > MAX_PULL_REQUEST_FACTS_BYTES,
803
- );
804
- if (oversized)
864
+ const oversized = oversizedFacts(b.snapshot.prs);
865
+ if (oversized !== undefined)
805
866
  return json(
806
- { error: `pull request ${oversized.number}'s facts must be at most ${MAX_PULL_REQUEST_FACTS_BYTES} bytes` },
867
+ { error: `pull request ${oversized}'s facts must be at most ${MAX_PULL_REQUEST_FACTS_BYTES} bytes` },
807
868
  413,
808
869
  );
809
870
  const prs = await dO.put(b.snapshot);
810
871
  console.log(`[delivery/put] ${b.snapshot.repo} <- ${prs} pull requests as of ${b.snapshot.snapshotAt}`);
811
872
  return json({ ok: true, prs });
812
873
  }
874
+ if (pathname === "/delivery/merge") {
875
+ if (!isDeliverySnapshotPatch(b.patch))
876
+ return json(
877
+ {
878
+ error:
879
+ "patch must be a DeliverySnapshotPatch (repo, snapshotAt, range, truncated, completeFrom, upsert[], drop[])",
880
+ },
881
+ 400,
882
+ );
883
+ const oversized = oversizedFacts(b.patch.upsert);
884
+ if (oversized !== undefined)
885
+ return json(
886
+ { error: `pull request ${oversized}'s facts must be at most ${MAX_PULL_REQUEST_FACTS_BYTES} bytes` },
887
+ 413,
888
+ );
889
+ const prs = await dO.merge(b.patch);
890
+ if (prs === null) return json({ error: `no snapshot for ${b.patch.repo} to merge into` }, 404);
891
+ console.log(
892
+ `[delivery/merge] ${b.patch.repo} <- ${b.patch.upsert.length} pull requests re-read, ${b.patch.drop.length} dropped, ${prs} stored as of ${b.patch.snapshotAt}`,
893
+ );
894
+ return json({ ok: true, prs });
895
+ }
813
896
  return json({ error: "not found" }, 404);
814
897
  }
815
898
 
@@ -1090,6 +1173,102 @@ export class RunHistoryDO extends DurableObject<Env> {
1090
1173
  PRIMARY KEY (run_id, kind)
1091
1174
  );
1092
1175
  `);
1176
+ // The coordinator's parent records (run-history item 49): one row per
1177
+ // instance, written by the bot at the instance's creation and read by the
1178
+ // spawn route for the requester, channel and thread every child acts as.
1179
+ this.sql.exec(`
1180
+ CREATE TABLE IF NOT EXISTS coordinator_instances (
1181
+ instance_id TEXT PRIMARY KEY,
1182
+ json TEXT NOT NULL,
1183
+ created_at INTEGER NOT NULL
1184
+ );
1185
+ `);
1186
+ // The units of the plan an instance runs (run-history item 50): one row per
1187
+ // (instance, unit), replaced whole as the runner reaches the unit; the
1188
+ // rowid keeps the order the rows were first written — the plan's.
1189
+ this.sql.exec(`
1190
+ CREATE TABLE IF NOT EXISTS coordinator_units (
1191
+ instance_id TEXT NOT NULL,
1192
+ unit TEXT NOT NULL,
1193
+ json TEXT NOT NULL,
1194
+ updated_at INTEGER NOT NULL,
1195
+ PRIMARY KEY (instance_id, unit)
1196
+ );
1197
+ `);
1198
+ }
1199
+
1200
+ // ---- the coordinator's parent records (run-history item 49) -----------------
1201
+
1202
+ /** Idempotent for the same record; a different record under a taken id is refused. */
1203
+ async putInstance(instance: CoordinatorInstance): Promise<{ ok: true } | { ok: false; reason: "exists" }> {
1204
+ let out: { ok: true } | { ok: false; reason: "exists" } = { ok: true };
1205
+ this.ctx.storage.transactionSync(() => {
1206
+ const text = JSON.stringify(instance);
1207
+ const existing = this.sql
1208
+ .exec<{ json: string }>(`SELECT json FROM coordinator_instances WHERE instance_id = ?`, instance.id)
1209
+ .toArray()[0];
1210
+ if (existing) {
1211
+ if (existing.json !== text) out = { ok: false, reason: "exists" };
1212
+ return;
1213
+ }
1214
+ this.sql.exec(
1215
+ `INSERT INTO coordinator_instances (instance_id, json, created_at) VALUES (?, ?, ?)`,
1216
+ instance.id,
1217
+ text,
1218
+ instance.createdAt,
1219
+ );
1220
+ });
1221
+ return out;
1222
+ }
1223
+
1224
+ /** The record written over whatever the id holds and the id's unit rows
1225
+ * dropped, in one transaction — an attempt starting over: the leftover of one
1226
+ * whose Workflow instance was never created, once the shim said so. */
1227
+ async replaceInstance(instance: CoordinatorInstance): Promise<{ ok: true }> {
1228
+ this.ctx.storage.transactionSync(() => {
1229
+ this.sql.exec(
1230
+ `INSERT INTO coordinator_instances (instance_id, json, created_at) VALUES (?, ?, ?)
1231
+ ON CONFLICT(instance_id) DO UPDATE SET json = excluded.json, created_at = excluded.created_at`,
1232
+ instance.id,
1233
+ JSON.stringify(instance),
1234
+ instance.createdAt,
1235
+ );
1236
+ this.sql.exec(`DELETE FROM coordinator_units WHERE instance_id = ?`, instance.id);
1237
+ });
1238
+ return { ok: true };
1239
+ }
1240
+
1241
+ async getInstance(id: string): Promise<CoordinatorInstance | null> {
1242
+ const row = this.sql
1243
+ .exec<{ json: string }>(`SELECT json FROM coordinator_instances WHERE instance_id = ?`, id)
1244
+ .toArray()[0];
1245
+ return row ? (JSON.parse(row.json) as CoordinatorInstance) : null;
1246
+ }
1247
+
1248
+ // ---- the units of the plan an instance runs (run-history item 50) -----------
1249
+
1250
+ /** Each row replaced whole under its (instance, unit); a replace keeps the row's place. */
1251
+ async putUnits(units: CoordinatorUnit[], now: number): Promise<{ ok: true }> {
1252
+ this.ctx.storage.transactionSync(() => {
1253
+ for (const u of units) {
1254
+ this.sql.exec(
1255
+ `INSERT INTO coordinator_units (instance_id, unit, json, updated_at) VALUES (?, ?, ?, ?)
1256
+ ON CONFLICT(instance_id, unit) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at`,
1257
+ u.instanceId,
1258
+ u.unit,
1259
+ JSON.stringify(u),
1260
+ now,
1261
+ );
1262
+ }
1263
+ });
1264
+ return { ok: true };
1265
+ }
1266
+
1267
+ async listUnits(instanceId: string): Promise<CoordinatorUnit[]> {
1268
+ return this.sql
1269
+ .exec<{ json: string }>(`SELECT json FROM coordinator_units WHERE instance_id = ? ORDER BY rowid`, instanceId)
1270
+ .toArray()
1271
+ .map((r) => JSON.parse(r.json) as CoordinatorUnit);
1093
1272
  }
1094
1273
 
1095
1274
  // ---- the live-run ledger (run-history items 28–34) --------------------------
@@ -1117,6 +1296,7 @@ export class RunHistoryDO extends DurableObject<Env> {
1117
1296
  agent: existing.meta.agent,
1118
1297
  startedAt: existing.startedAt,
1119
1298
  ownerGen: existing.ownerGen,
1299
+ idempotencyKey: existing.meta.idempotencyKey,
1120
1300
  }
1121
1301
  : undefined,
1122
1302
  req,
@@ -1298,13 +1478,17 @@ export class RunHistoryDO extends DurableObject<Env> {
1298
1478
  return out;
1299
1479
  }
1300
1480
 
1301
- /** The finished record replaces the live rows in ONE transaction (item 33). Fenced. */
1481
+ /** The finished record replaces the live rows in ONE transaction (item 33).
1482
+ * Fenced. Then, for a record carrying `parentInstanceId`, ONE `run
1483
+ * finished:<runId>` to that coordinator instance (item 47) — after the
1484
+ * commit, never inside it, and never able to undo it: a refused send (the
1485
+ * instance ended, no binding) is the answer's `event`, not an error. */
1302
1486
  async finish(
1303
1487
  runId: string,
1304
1488
  gen: string,
1305
1489
  record: RunRecord,
1306
1490
  proposal?: RunPolicyProposal,
1307
- ): Promise<FenceResult & { stored?: boolean }> {
1491
+ ): Promise<FenceResult & { stored?: boolean; event?: RunFinishedSend["kind"] }> {
1308
1492
  let out: FenceResult & { stored?: boolean } = { ok: true };
1309
1493
  this.ctx.storage.transactionSync(() => {
1310
1494
  const fence = checkFence(this.liveRow(runId), gen);
@@ -1316,9 +1500,13 @@ export class RunHistoryDO extends DurableObject<Env> {
1316
1500
  this.deleteLiveRows([runId]);
1317
1501
  out = { ok: true, stored: put.stored };
1318
1502
  });
1503
+ if (!out.ok) return out;
1319
1504
  if ((await this.ctx.storage.getAlarm()) === null)
1320
1505
  await this.ctx.storage.setAlarm(systemClock() + RUN_SWEEP_INTERVAL_MS);
1321
- return out;
1506
+ const event = await sendRunFinished(this.env.SHIP_COORDINATOR, record);
1507
+ if (event.kind === "failed")
1508
+ console.warn(`[runs/finish] ${runId} → run finished not delivered to ${event.instance}: ${event.reason}`);
1509
+ return { ...out, event: event.kind };
1322
1510
  }
1323
1511
 
1324
1512
  /** The live rows go with no record (item 42): a reserved run that never
@@ -2270,6 +2458,11 @@ export class RunTranscriptDO extends DurableObject<Env> {
2270
2458
  }
2271
2459
 
2272
2460
  const LEDGER_ROUTES = new Set([
2461
+ "/runs/coordinator/put",
2462
+ "/runs/coordinator/replace",
2463
+ "/runs/coordinator/get",
2464
+ "/runs/coordinator/units/put",
2465
+ "/runs/coordinator/units/list",
2273
2466
  "/runs/claim",
2274
2467
  "/runs/heartbeat",
2275
2468
  "/runs/append",
@@ -2293,14 +2486,15 @@ const LEDGER_ROUTES = new Set([
2293
2486
 
2294
2487
  /** Routes whose bodies may carry a record, a transcript chunk, or an event batch. */
2295
2488
  const WIDE_BODY_ROUTES = new Set(["/runs/put", "/runs/finish", "/runs/append", "/runs/transcript/write"]);
2296
- /** A delivery snapshot: every merged pull request's reviews and its branch's workflow runs over
2297
- * the window — a busy repository's runs to a few MB (measured: 291 pull requests, 2.1 MB). */
2298
- const MAX_SNAPSHOT_BODY_BYTES = 8 * 1024 * 1024;
2489
+ /** A delivery snapshot written whole, or a refresh's patch: every merged pull request's reviews and
2490
+ * its branch's workflow runs — about 7 KB a pull request (measured: 291 pull requests, 2.1 MB), so
2491
+ * a first read at the listing cap is under 6 MB and a busy repository's whole window many MB. */
2492
+ const MAX_SNAPSHOT_BODY_BYTES = 16 * 1024 * 1024;
2299
2493
 
2300
2494
  /** The request body ceiling per route, decided after routing and before the parse. */
2301
2495
  function bodyFenceFor(pathname: string): number {
2302
2496
  if (WIDE_BODY_ROUTES.has(pathname)) return MAX_RUN_PUT_BODY_BYTES;
2303
- if (pathname === "/delivery/put") return MAX_SNAPSHOT_BODY_BYTES;
2497
+ if (pathname === "/delivery/put" || pathname === "/delivery/merge") return MAX_SNAPSHOT_BODY_BYTES;
2304
2498
  return MAX_BODY_BYTES;
2305
2499
  }
2306
2500
 
@@ -2332,6 +2526,20 @@ function parseClaim(b: Record<string, unknown>): Validated<ClaimRequest> {
2332
2526
  if (typeof r.startedAt !== "number" || !Number.isFinite(r.startedAt))
2333
2527
  return invalid("run.startedAt must be a number");
2334
2528
  if (typeof r.meta !== "object" || r.meta === null) return invalid("run.meta must be an object");
2529
+ // A coordinator's tag (run-history item 48) is stored at the claim and read
2530
+ // by the finish's send and the refusal a second claim meets: shaped or
2531
+ // refused, and both fields or neither — one alone is no tag.
2532
+ const meta = r.meta as Record<string, unknown>;
2533
+ if ((meta.parentInstanceId === undefined) !== (meta.idempotencyKey === undefined))
2534
+ return invalid("run.meta.parentInstanceId and run.meta.idempotencyKey come together or not at all");
2535
+ if (meta.parentInstanceId !== undefined) {
2536
+ if (typeof meta.parentInstanceId !== "string" || !INSTANCE_ID_PATTERN.test(meta.parentInstanceId))
2537
+ return invalid("run.meta.parentInstanceId must be a Workflow instance id");
2538
+ }
2539
+ if (meta.idempotencyKey !== undefined) {
2540
+ if (typeof meta.idempotencyKey !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(meta.idempotencyKey))
2541
+ return invalid("run.meta.idempotencyKey must be <parentInstanceId>:<step>");
2542
+ }
2335
2543
  if (typeof r.system !== "string") return invalid("run.system must be a string");
2336
2544
  if (!Array.isArray(r.tools)) return invalid("run.tools must be an array");
2337
2545
  if (r.card !== undefined && r.card !== null) {
@@ -2482,6 +2690,44 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
2482
2690
  return json(r);
2483
2691
  }
2484
2692
 
2693
+ // The coordinator's parent records (run-history item 49): the record whole,
2694
+ // validated by the shared contract; a read by instance id.
2695
+ if (pathname === "/runs/coordinator/put") {
2696
+ if (!isCoordinatorInstance(b.instance))
2697
+ return json({ error: "instance must be a coordinator instance record" }, 400);
2698
+ const r = await stub.putInstance(b.instance);
2699
+ console.log(`[runs/coordinator/put] ${key.value} ${b.instance.id} → ${r.ok ? "stored" : r.reason}`);
2700
+ return r.ok ? json(r) : json(r, 409);
2701
+ }
2702
+ if (pathname === "/runs/coordinator/replace") {
2703
+ if (!isCoordinatorInstance(b.instance))
2704
+ return json({ error: "instance must be a coordinator instance record" }, 400);
2705
+ const r = await stub.replaceInstance(b.instance);
2706
+ console.log(`[runs/coordinator/replace] ${key.value} ${b.instance.id} → replaced`);
2707
+ return json(r);
2708
+ }
2709
+ if (pathname === "/runs/coordinator/get") {
2710
+ if (typeof b.id !== "string" || !INSTANCE_ID_PATTERN.test(b.id))
2711
+ return json({ error: "id must be a Workflow instance id" }, 400);
2712
+ return json({ instance: await stub.getInstance(b.id) });
2713
+ }
2714
+ // The units of the plan an instance runs (run-history item 50): rows
2715
+ // validated by the shared contract, each replaced whole; a list by instance.
2716
+ if (pathname === "/runs/coordinator/units/put") {
2717
+ if (!Array.isArray(b.units) || b.units.length === 0 || b.units.length > MAX_UNITS_PER_PUT)
2718
+ return json({ error: `units must be a non-empty array of at most ${MAX_UNITS_PER_PUT} unit rows` }, 400);
2719
+ if (!b.units.every(isCoordinatorUnit)) return json({ error: "every unit must be a coordinator unit row" }, 400);
2720
+ const units = b.units as CoordinatorUnit[];
2721
+ const r = await stub.putUnits(units, now);
2722
+ console.log(`[runs/coordinator/units/put] ${key.value} ${units[0]!.instanceId} ${units.length} row(s)`);
2723
+ return json(r);
2724
+ }
2725
+ if (pathname === "/runs/coordinator/units/list") {
2726
+ if (typeof b.instanceId !== "string" || !INSTANCE_ID_PATTERN.test(b.instanceId))
2727
+ return json({ error: "instanceId must be a Workflow instance id" }, 400);
2728
+ return json({ units: await stub.listUnits(b.instanceId) });
2729
+ }
2730
+
2485
2731
  const runId = parseRunId(b.runId);
2486
2732
  if (!runId.ok) return json({ error: runId.error }, 400);
2487
2733
  if (pathname === "/runs/inbox") {
@@ -2545,7 +2791,9 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
2545
2791
  if (!parsed.ok) return json({ error: parsed.error }, 400);
2546
2792
  if (parsed.value.record.id !== runId.value) return json({ error: "record.id must equal runId" }, 400);
2547
2793
  const r = await stub.finish(runId.value, g.value, parsed.value.record, parsed.value.proposal);
2548
- console.log(`[runs/finish] ${key.value} ${runId.value} ok=${r.ok}${r.ok ? ` stored=${r.stored}` : ` ${r.reason}`}`);
2794
+ console.log(
2795
+ `[runs/finish] ${key.value} ${runId.value} ok=${r.ok}${r.ok ? ` stored=${r.stored} event=${r.event}` : ` ${r.reason}`}`,
2796
+ );
2549
2797
  return r.ok ? json(r) : json(r, 409);
2550
2798
  }
2551
2799
  return json({ error: "not found" }, 404);
@@ -38,6 +38,21 @@
38
38
  { "name": "DELIVERY", "class_name": "DeliveryDO" }
39
39
  ]
40
40
  },
41
+ // The bot shim's ship coordinator Workflow, bound across scripts by the bot's
42
+ // script name (docs/reference/specs/run-history.md item 47): `RunHistoryDO.finish`
43
+ // sends it `run finished:<runId>` for a record carrying `parentInstanceId`.
44
+ // The name is the bot's own Workflow name (`<bot script>-ship-coordinator`,
45
+ // deploy/cloudflare/wrangler.template.jsonc). The class is deployed with the
46
+ // bot, which deploys after this Worker — so this binding lands one release
47
+ // after the class did, never in the same one.
48
+ "workflows": [
49
+ {
50
+ "name": "{{bot.script}}-ship-coordinator",
51
+ "binding": "SHIP_COORDINATOR",
52
+ "class_name": "ShipCoordinator",
53
+ "script_name": "{{bot.script}}"
54
+ }
55
+ ],
41
56
  // SQLite-backed DOs (durable across restarts — AGENTS.md invariant 6).
42
57
  "migrations": [
43
58
  { "tag": "v1", "new_sqlite_classes": ["MemoryDO"] },
@@ -1,6 +1,16 @@
1
1
  # Resident container image: the @cloudflare/sandbox base (Ubuntu 22.04; already
2
- # ships git, curl, jq, Node 24, npm, bun, cloudflared) plus GitHub tooling and
3
- # a pool of unprivileged users.
2
+ # ships git, curl, jq, npm, bun, cloudflared and a Node 22 this image replaces
3
+ # with Node 24 below) plus GitHub tooling and a pool of unprivileged users.
4
+
5
+ # The Node this image ships (copied in below), at the exact tag every image in
6
+ # this repository shares — its major is .nvmrc's, which is what CI runs and
7
+ # the install script requires. `--platform`: the cloudflare/sandbox base below
8
+ # publishes linux/amd64 only, so the stage the binary is copied from must be
9
+ # amd64 too — a bare FROM on an arm64 host resolves it to arm64 and the copied
10
+ # binary cannot run in the base (check:image on Apple silicon failed exactly
11
+ # so). src/deploy/imageNode.test.ts holds the tag, its major and the platform.
12
+ FROM --platform=linux/amd64 docker.io/library/node:24.21.0-slim AS node
13
+
4
14
  # The tag MUST match the @cloudflare/sandbox version in package.json exactly
5
15
  # (no 'latest' tag exists on the @next line) — bump both together.
6
16
  FROM docker.io/cloudflare/sandbox:0.13.0-next.751.1
@@ -32,7 +42,29 @@ ENV CI=1 \
32
42
  UV_THREADPOOL_SIZE=4 \
33
43
  NODE_OPTIONS=--max-old-space-size=1536
34
44
 
35
- # pnpm + yarn: the base ships Node 24 + npm + bun but neither pnpm nor yarn, so a
45
+ # Node 24, not the base's. The 0.13.0-next base ships Node 22.23.2 like the
46
+ # 0.12.9 one (copied from node:22-slim: the binary at /usr/local/bin/node, npm
47
+ # and corepack under /usr/local/lib/node_modules, npm/npx symlinked into it),
48
+ # while this repository builds and tests on Node 24 (.nvmrc, CI) and its
49
+ # published CLI requires `>=24` — so a repository validated here read a
50
+ # different `verify` than CI did. Node comes from the official image at an
51
+ # exact tag, in the `node` stage above, the same way the sandbox image does it
52
+ # (deploy/cloudflare-sandbox/Dockerfile): the base's npm tree removed first,
53
+ # then the binary, npm's tree and the headers at the base's own paths, so its
54
+ # npm/npx symlinks keep resolving; the container server is a bun-compiled
55
+ # binary and does not run on this Node. The grep fails the BUILD if a bump
56
+ # lands another version; src/deploy/imageNode.test.ts holds it equal to the
57
+ # stage's tag. This layer sits before the package managers below so they are
58
+ # installed by this Node's npm, into this tree.
59
+ RUN rm -rf /usr/local/lib/node_modules /usr/local/include/node
60
+ COPY --from=node /usr/local/bin/node /usr/local/bin/node
61
+ COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
62
+ COPY --from=node /usr/local/include/node /usr/local/include/node
63
+ RUN node --version | grep -qx 'v24.21.0' \
64
+ && npm --version >/dev/null \
65
+ && npx --version >/dev/null
66
+
67
+ # pnpm + yarn: the base ships npm + bun but neither pnpm nor yarn, so a
36
68
  # repo whose onboard table is detected as pnpm or yarn (resident-repos item 52 —
37
69
  # every manager `detectCommands` can emit must exist here, or the table fails
38
70
  # deterministically at provision) couldn't be provisioned (install → build).
@@ -74,6 +106,53 @@ RUN apt-get update \
74
106
  && rm -rf /var/lib/apt/lists/* \
75
107
  && command -v unsquashfs >/dev/null
76
108
 
109
+ # The toolchain for what a run BUILDS and LOOKS AT — the same layer as the
110
+ # sandbox image's (deploy/cloudflare-sandbox/Dockerfile);
111
+ # src/deploy/imageToolchain.test.ts holds the two to one shape.
112
+ # - python3 + make + g++: node-gyp's needs. A cold `npm install` in a repo
113
+ # with a native module (node-pty) rebuilds it from source, and died on
114
+ # "no Python". Just the three — no build-essential, no recommends.
115
+ # - ffmpeg (Ubuntu's, with libx264): an agent pulls frames out of a video
116
+ # to look at it (`ffmpeg -i in.mp4 -vf fps=1 frame_%03d.png`) and encodes
117
+ # video from frames or a screen recording.
118
+ # - a headless Chromium through Playwright at an EXACT pin
119
+ # (src/deploy/imagePins.test.ts): screenshots, PDFs and `recordVideo`.
120
+ # Ubuntu 22.04's apt `chromium` is a snap stub that does not run in a
121
+ # container, so the browser is Playwright's own build — the headless shell
122
+ # only (`--only-shell`: there is no display), with the system libraries it
123
+ # needs (`--with-deps`) and the fonts pages render text with.
124
+ # PLAYWRIGHT_BROWSERS_PATH is set BEFORE the install so the browser lands
125
+ # under /opt, not root's home, and the tree is opened to every user
126
+ # (a+rX): every agent command here runs as an unprivileged workerN (the
127
+ # pool below), through `su` without `-`, which keeps this environment.
128
+ # NODE_PATH makes `require('playwright')` resolve from any working
129
+ # directory. Playwright launches Chromium with --no-sandbox
130
+ # (`chromiumSandbox: false`, its default) — what a process without user
131
+ # namespaces needs, root or not.
132
+ # Every claim is PROVEN by the layer itself, so the BUILD fails, not a run:
133
+ # the compilers answer --version, ffmpeg encodes a one-second testsrc clip to
134
+ # h264 and decodes a non-empty frame back out of it, the playwright CLI is on
135
+ # PATH at the pin, and a real headless screenshot of a page lands as a
136
+ # non-empty png — here as root; again as worker1 once the pool exists, below.
137
+ # The proof artefacts, the apt lists, apt's .deb archive (this base has no
138
+ # docker-clean hook: 400 MB of archives stayed behind without the `clean`)
139
+ # and npm's cache go in the same layer.
140
+ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright \
141
+ NODE_PATH=/usr/local/lib/node_modules
142
+ RUN apt-get update \
143
+ && apt-get install -y --no-install-recommends python3 make g++ ffmpeg fonts-liberation fonts-noto-color-emoji \
144
+ && npm install -g playwright@1.63.0 \
145
+ && playwright install --with-deps --only-shell chromium \
146
+ && chmod -R a+rX /opt/ms-playwright \
147
+ && apt-get clean && rm -rf /var/lib/apt/lists/* && npm cache clean --force \
148
+ && python3 --version && g++ --version && make --version \
149
+ && ffmpeg -version \
150
+ && ffmpeg -hide_banner -loglevel error -f lavfi -i testsrc=duration=1:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p /tmp/proof.mp4 \
151
+ && ffmpeg -hide_banner -loglevel error -i /tmp/proof.mp4 -vf fps=1 /tmp/proof_%03d.png && test -s /tmp/proof_001.png \
152
+ && playwright --version | grep -qx 'Version 1.63.0' \
153
+ && playwright screenshot --viewport-size=640,480 'data:text/html,<h1>ok</h1>' /tmp/ok.png && test -s /tmp/ok.png \
154
+ && rm -f /tmp/proof.mp4 /tmp/proof_*.png /tmp/ok.png
155
+
77
156
  # Agent commands never run as root (repo code runs unprivileged, one user per
78
157
  # thread — docs/reference/specs/resident-repos.md). The SDK's exec has no user/uid option
79
158
  # (verified against 0.13.0-next.751.1), so the container server itself runs as
@@ -89,3 +168,10 @@ RUN set -eux; \
89
168
  for i in $(seq 1 17); do useradd -m -u "$((2000 + i))" -s /bin/bash "worker$i"; chmod 700 "/home/worker$i"; done; \
90
169
  if dpkg -s sudo >/dev/null 2>&1; then apt-get remove -y --purge sudo; fi; \
91
170
  passwd -l root
171
+
172
+ # The browser proof once more, as worker1 — exactly how a thread's commands
173
+ # run (`su -s /bin/bash workerN -c …`, root's environment kept). The layer
174
+ # above proved it as root; this one proves an unprivileged user can read the
175
+ # browser tree under /opt and launch it, so the BUILD fails if a chmod or the
176
+ # path ever regresses, not a run.
177
+ RUN su -s /bin/bash worker1 -c "playwright screenshot --viewport-size=640,480 'data:text/html,<h1>ok</h1>' /tmp/ok.png && test -s /tmp/ok.png && rm -f /tmp/ok.png"