@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
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
801
|
-
|
|
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
|
|
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).
|
|
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
|
-
|
|
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
|
|
2297
|
-
*
|
|
2298
|
-
|
|
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(
|
|
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,
|
|
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
|
-
#
|
|
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"
|