@coreplane/switchboard 1.205.0 → 1.206.1

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 +254 -0
  19. package/dist/assets/src/core/coordinator/driver.ts +504 -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 +12 -3
  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 +1058 -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 +6056 -3225
  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
@@ -0,0 +1,504 @@
1
+ // The plan runner's driver (docs/reference/specs/http-ingress.md item 9;
2
+ // docs/decisions/0031-the-coordinator-runs-a-plan-not-a-pull-request.md): the
3
+ // `ShipCoordinator` Workflow's `run()` body, written over a structural step
4
+ // runner and a bot client so plain Node proves what the platform replays. The
5
+ // driver owns three things and nothing else — which step is taken next and
6
+ // under which name (the machine's own, so a replay meets the same step), how a
7
+ // bot answer becomes the machine's return, and what is left to the platform's
8
+ // retry: a call that throws inside a step is the platform's to ask again under
9
+ // the one policy (twelve times, two minutes apart, constant — long enough for a
10
+ // bot deploy and its rollover), and the driver never catches it.
11
+ //
12
+ // Every step's stored output is the bot's reply as the wire carried it — the
13
+ // status and the text of a JSON object the bot stamped with its clock (`at`) —
14
+ // so the mapping onto the machine is pure and runs identically on replay; an
15
+ // answer the runner cannot read fails the instance at once rather than twelve
16
+ // times over. Every wait is a `waitForEvent` typed `run-finished-<runId>` for
17
+ // one chunk of the child's budget (the machine slices the budget plus the
18
+ // margin, `WAIT_CHUNK_MS`, and asks `read-record` between the slices), and
19
+ // every wait — event or timeout alike — is confirmed by a `read-record` before
20
+ // the machine acts on it, so an event the engine never delivered costs a
21
+ // chunk, not the budget.
22
+ //
23
+ // Units run one at a time in the plan's order, each once its in-play
24
+ // dependencies are done. `done` is merged: a plan branch's pull request is the
25
+ // runner's to squash (the `merge` step, under `plan:merge`) once the review
26
+ // approved at its head and the checks are green, so its dependents start on a
27
+ // base that carries it; a unit that ended any other way — a refused merge, a
28
+ // cap, a stop — blocks its dependents, each told so as its own ending, and the
29
+ // plan finishes `failed` so the summary says which units are left for the
30
+ // plan's re-issue. A task string's ship branch waits for a person. Node-free:
31
+ // the shim Worker imports this by relative path.
32
+
33
+ import {
34
+ applyReturn,
35
+ cursorFinished,
36
+ nextAction,
37
+ openPlanCursor,
38
+ openUnitPipeline,
39
+ parsePlanBranch,
40
+ readyUnits,
41
+ renderUnitReport,
42
+ settleUnit,
43
+ startUnit,
44
+ type ChildFacts,
45
+ type CoordinatorAction,
46
+ type PlanGraph,
47
+ type PlanUnitNode,
48
+ type ShipCaps,
49
+ type StepReturn,
50
+ type UnitEnding,
51
+ type UnitPipelineInput,
52
+ type UnitPipelineState,
53
+ } from "../ship/coordinator.js";
54
+ import { isCoordinatorUnit, runFinishedEventType, type CoordinatorUnit } from "./contract.js";
55
+
56
+ const MIN = 60_000;
57
+
58
+ /** The one retry policy every step runs under: a bot deploy plus its rollover fits inside it. */
59
+ export const STEP_RETRIES = { limit: 12, delay: 2 * MIN, backoff: "constant" } as const;
60
+ export interface StepConfig {
61
+ retries: { limit: number; delay: number; backoff: "constant" | "linear" | "exponential" };
62
+ /** The step's own wall clock, in ms; the platform's default when absent. */
63
+ timeout?: number;
64
+ }
65
+ export const STEP_CONFIG: StepConfig = { retries: STEP_RETRIES };
66
+ /** A spawn answers when the child registers, which waits on an attach — minutes on a cold sandbox. */
67
+ export const SPAWN_STEP_CONFIG: StepConfig = { retries: STEP_RETRIES, timeout: 15 * MIN };
68
+
69
+ /** The platform's step primitives as the driver uses them — `WorkflowStep`
70
+ * fits; so does a test's recorder. Every `do` stores a bot reply. */
71
+ export interface StepRunner {
72
+ do(name: string, config: StepConfig, callback: () => Promise<BotReply>): Promise<BotReply>;
73
+ sleep(name: string, ms: number): Promise<void>;
74
+ waitForEvent(name: string, options: { type: string; timeout?: number }): Promise<unknown>;
75
+ }
76
+
77
+ export type CoordinatorStepRoute =
78
+ "plan" | "unit-start" | "branch" | "spawn" | "read-record" | "pr-check" | "round" | "unit-end" | "merge" | "finish";
79
+
80
+ /** What a step stores: the bot's reply as the wire carried it — its status and
81
+ * its text, read the same way on replay. Two numbers and a string, so the
82
+ * platform's serializable bound holds without a shape decided at storage time. */
83
+ export interface BotReply {
84
+ status: number;
85
+ text: string;
86
+ }
87
+
88
+ /** A bot answer read from a reply: the status and the JSON object the bot stamped with its clock. */
89
+ export interface BotAnswer {
90
+ status: number;
91
+ body: { at: number; [key: string]: unknown };
92
+ }
93
+
94
+ /** The bot as the driver calls it: `step` resolves with what the bot's HTTP
95
+ * answered and throws for the transport (a container mid-restart, a bot
96
+ * mid-deploy) so the platform asks again; the Worker's client also throws as
97
+ * final for the door's own refusal, since no retry changes a token map. */
98
+ export interface CoordinatorBot {
99
+ step(route: CoordinatorStepRoute, body: Record<string, unknown>): Promise<BotReply>;
100
+ }
101
+
102
+ export interface PlanRunSummary {
103
+ instance: string;
104
+ planId?: string;
105
+ /** Each unit's ending kind, or `blocked`. */
106
+ units: Record<string, string>;
107
+ outcome: "completed" | "failed";
108
+ }
109
+
110
+ // ---- reading the bot ---------------------------------------------------------------------------------
111
+
112
+ const REASON_MAX = 200;
113
+
114
+ /** A bot answer is a JSON object stamped `at`, at any status — a gate's 403
115
+ * and a busy 409 are answers. Anything else is not the bot's: the door's
116
+ * refusal (no `at`), the shim's own error page, a container mid-restart. */
117
+ export function readBotAnswer(
118
+ status: number,
119
+ text: string,
120
+ ): { ok: true; answer: BotAnswer } | { ok: false; reason: string } {
121
+ let parsed: unknown;
122
+ try {
123
+ parsed = JSON.parse(text);
124
+ } catch {
125
+ parsed = undefined;
126
+ }
127
+ const body =
128
+ typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
129
+ ? (parsed as Record<string, unknown>)
130
+ : undefined;
131
+ if (body !== undefined && typeof body.at === "number")
132
+ return { ok: true, answer: { status, body: body as BotAnswer["body"] } };
133
+ const detail =
134
+ typeof body?.error === "string" ? body.error : text.length > REASON_MAX ? `${text.slice(0, REASON_MAX)}…` : text;
135
+ return { ok: false, reason: `HTTP ${status} — ${detail}` };
136
+ }
137
+
138
+ /** The answers the bot itself calls a passing condition — the step is asked
139
+ * again, under the policy. Everything else, refusals included, is the machine's. */
140
+ const TRANSIENT = new Set(["github_unavailable", "no_channel", "thread_failed", "unit_not_started"]);
141
+ export function transientRefusal(answer: BotAnswer): string | undefined {
142
+ const { ok, error, message } = answer.body;
143
+ if (ok !== false || typeof error !== "string" || !TRANSIENT.has(error)) return undefined;
144
+ return `the bot answered ${error}${typeof message === "string" ? `: ${message}` : ""}`;
145
+ }
146
+
147
+ class UnreadableAnswer extends Error {
148
+ constructor(route: CoordinatorStepRoute, answer: BotAnswer, what: string) {
149
+ super(
150
+ `the bot's ${route} answer could not be read (${what}): HTTP ${answer.status} ${JSON.stringify(answer.body).slice(0, REASON_MAX)}`,
151
+ );
152
+ }
153
+ }
154
+
155
+ interface PlanFacts {
156
+ planId?: string;
157
+ repo: string;
158
+ base: string;
159
+ caps: ShipCaps;
160
+ childMinutes: UnitPipelineInput["childMinutes"];
161
+ units: CoordinatorUnit[];
162
+ }
163
+
164
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
165
+ const isMinutes = (v: unknown): v is Record<string, number> =>
166
+ isRecord(v) && Object.values(v).every((n) => typeof n === "number" && Number.isFinite(n));
167
+
168
+ function readPlan(a: BotAnswer): PlanFacts {
169
+ const b = a.body;
170
+ if (b.ok !== true) throw new UnreadableAnswer("plan", a, "not ok");
171
+ if (typeof b.repo !== "string" || typeof b.base !== "string") throw new UnreadableAnswer("plan", a, "repo and base");
172
+ if (!isMinutes(b.caps) || typeof b.caps.maxRounds !== "number" || typeof b.caps.maxMinutes !== "number")
173
+ throw new UnreadableAnswer("plan", a, "caps");
174
+ if (
175
+ !isMinutes(b.childMinutes) ||
176
+ typeof b.childMinutes.coding !== "number" ||
177
+ typeof b.childMinutes.review !== "number"
178
+ )
179
+ throw new UnreadableAnswer("plan", a, "childMinutes");
180
+ const units: unknown = b.units;
181
+ if (!Array.isArray(units) || !units.every(isCoordinatorUnit)) throw new UnreadableAnswer("plan", a, "units");
182
+ return {
183
+ ...(typeof b.planId === "string" ? { planId: b.planId } : {}),
184
+ repo: b.repo,
185
+ base: b.base,
186
+ caps: { maxRounds: b.caps.maxRounds, maxMinutes: b.caps.maxMinutes },
187
+ childMinutes: { coding: b.childMinutes.coding, review: b.childMinutes.review },
188
+ units,
189
+ };
190
+ }
191
+
192
+ function readUnitStart(a: BotAnswer): { at: number } {
193
+ if (a.body.ok !== true || typeof a.body.threadKey !== "string") throw new UnreadableAnswer("unit-start", a, "thread");
194
+ return { at: a.body.at };
195
+ }
196
+
197
+ function branchReturn(step: string, a: BotAnswer): StepReturn {
198
+ const { ok, reason, at } = a.body;
199
+ if (ok === true) return { type: "branch", step, ok: true, at };
200
+ if (ok === false && typeof reason === "string") return { type: "branch", step, ok: false, reason, at };
201
+ throw new UnreadableAnswer("branch", a, "ok or reason");
202
+ }
203
+
204
+ function spawnReturn(step: string, a: BotAnswer): StepReturn {
205
+ const { ok, runId, alreadySpawned, error, message, at } = a.body;
206
+ if (ok === true && typeof runId === "string")
207
+ return { type: "spawn", step, outcome: alreadySpawned === true ? "alreadySpawned" : "spawned", runId, at };
208
+ if (a.status === 409 && error === "busy")
209
+ return { type: "spawn", step, outcome: "busy", ...(typeof runId === "string" ? { runId } : {}), at };
210
+ if (a.status === 403 && typeof error === "string")
211
+ return {
212
+ type: "spawn",
213
+ step,
214
+ outcome: "refused",
215
+ refusal: error,
216
+ ...(typeof message === "string" ? { message } : {}),
217
+ at,
218
+ };
219
+ if (a.status === 502 && typeof error === "string")
220
+ return { type: "spawn", step, outcome: "failed", reason: typeof message === "string" ? message : error, at };
221
+ throw new UnreadableAnswer("spawn", a, "outcome");
222
+ }
223
+
224
+ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
225
+ const run = a.body.run;
226
+ if (a.body.ok !== true || !isRecord(run) || typeof run.finished !== "boolean")
227
+ throw new UnreadableAnswer("read-record", a, "run");
228
+ if (!run.finished) return { type: "read-record", step, run: { finished: false }, at: a.body.at };
229
+ if (typeof run.status !== "string") throw new UnreadableAnswer("read-record", a, "status");
230
+ // The typed artifacts as the bot's record carries them — shape-checked where
231
+ // they were written (the run record's validator), read here as they are.
232
+ const facts = run as unknown as Omit<Extract<ChildFacts, { finished: true }>, "finished" | "status">;
233
+ const { finalReply, pr, headSha, description, verdict, reviewPosted, reviewHead, dispositions, handoff } = facts;
234
+ return {
235
+ type: "read-record",
236
+ step,
237
+ run: {
238
+ finished: true,
239
+ status: run.status as Extract<ChildFacts, { finished: true }>["status"],
240
+ ...(finalReply !== undefined ? { finalReply } : {}),
241
+ ...(pr !== undefined ? { pr } : {}),
242
+ ...(headSha !== undefined ? { headSha } : {}),
243
+ ...(description !== undefined ? { description } : {}),
244
+ ...(verdict !== undefined ? { verdict } : {}),
245
+ ...(reviewPosted !== undefined ? { reviewPosted } : {}),
246
+ ...(reviewHead !== undefined ? { reviewHead } : {}),
247
+ ...(dispositions !== undefined ? { dispositions } : {}),
248
+ ...(handoff !== undefined ? { handoff } : {}),
249
+ },
250
+ at: a.body.at,
251
+ };
252
+ }
253
+
254
+ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
255
+ const { ok, state, prNumber, url, headSha, at } = a.body;
256
+ if (ok === true && state === "none") return { type: "pr-check", step, pr: { state: "none" }, at };
257
+ if (ok === true && state === "open" && typeof prNumber === "number" && typeof url === "string")
258
+ return {
259
+ type: "pr-check",
260
+ step,
261
+ pr: { state: "open", prNumber, url, ...(typeof headSha === "string" ? { headSha } : {}) },
262
+ at,
263
+ };
264
+ throw new UnreadableAnswer("pr-check", a, "state");
265
+ }
266
+
267
+ function mergeReturn(step: string, a: BotAnswer): StepReturn {
268
+ const { ok, outcome, sha, reason, at } = a.body;
269
+ if (ok === true && outcome === "merged" && typeof sha === "string")
270
+ return { type: "merge", step, outcome: "merged", sha, at };
271
+ if (ok === true && (outcome === "pending" || outcome === "refused") && typeof reason === "string")
272
+ return { type: "merge", step, outcome, reason, at };
273
+ throw new UnreadableAnswer("merge", a, "outcome");
274
+ }
275
+
276
+ // ---- the steps ----------------------------------------------------------------------------------------
277
+
278
+ /** One bot call inside a step: a reply that is not the bot's answer and a
279
+ * passing refusal are throws, so the platform asks again; the reply is stored. */
280
+ async function call(
281
+ bot: CoordinatorBot,
282
+ route: CoordinatorStepRoute,
283
+ body: Record<string, unknown>,
284
+ ): Promise<BotReply> {
285
+ const reply = await bot.step(route, body);
286
+ const read = readBotAnswer(reply.status, reply.text);
287
+ if (!read.ok) throw new Error(`the bot did not answer ${route}: ${read.reason}`);
288
+ const transient = transientRefusal(read.answer);
289
+ if (transient !== undefined) throw new Error(transient);
290
+ return reply;
291
+ }
292
+
293
+ /** The answer a stored reply carries — the reply passed `call` once, so it reads the same on replay. */
294
+ function answerOf(route: CoordinatorStepRoute, reply: BotReply): BotAnswer {
295
+ const read = readBotAnswer(reply.status, reply.text);
296
+ if (!read.ok) throw new Error(`the stored ${route} reply is not the bot's answer: ${read.reason}`);
297
+ return read.answer;
298
+ }
299
+
300
+ /** A wait's outcome: the event, or anything else — the machine confirms either by `read-record`. */
301
+ async function waitForRun(
302
+ step: StepRunner,
303
+ action: Extract<CoordinatorAction, { type: "wait" }>,
304
+ ): Promise<"event" | "timeout"> {
305
+ try {
306
+ await step.waitForEvent(action.step, { type: runFinishedEventType(action.runId), timeout: action.timeoutMs });
307
+ return "event";
308
+ } catch {
309
+ return "timeout";
310
+ }
311
+ }
312
+
313
+ async function perform(
314
+ step: StepRunner,
315
+ bot: CoordinatorBot,
316
+ instanceId: string,
317
+ unit: string,
318
+ action: Exclude<CoordinatorAction, { type: "end" }>,
319
+ ): Promise<StepReturn> {
320
+ const tag = { parentInstanceId: instanceId, unit };
321
+ switch (action.type) {
322
+ case "branch":
323
+ return branchReturn(
324
+ action.step,
325
+ answerOf("branch", await step.do(action.step, STEP_CONFIG, () => call(bot, "branch", tag))),
326
+ );
327
+ case "spawn":
328
+ return spawnReturn(
329
+ action.step,
330
+ answerOf(
331
+ "spawn",
332
+ await step.do(action.step, SPAWN_STEP_CONFIG, () =>
333
+ call(bot, "spawn", {
334
+ ...tag,
335
+ step: action.step,
336
+ preset: action.preset,
337
+ budget: action.budgetMinutes,
338
+ brief: action.brief,
339
+ }),
340
+ ),
341
+ ),
342
+ );
343
+ case "wait":
344
+ return { type: "wait", step: action.step, outcome: await waitForRun(step, action) };
345
+ case "read-record":
346
+ return readRecordReturn(
347
+ action.step,
348
+ answerOf(
349
+ "read-record",
350
+ await step.do(action.step, STEP_CONFIG, () => call(bot, "read-record", { ...tag, runId: action.runId })),
351
+ ),
352
+ );
353
+ case "pr-check":
354
+ return prCheckReturn(
355
+ action.step,
356
+ answerOf("pr-check", await step.do(action.step, STEP_CONFIG, () => call(bot, "pr-check", tag))),
357
+ );
358
+ case "sleep":
359
+ await step.sleep(action.step, action.ms);
360
+ return { type: "sleep", step: action.step };
361
+ case "merge":
362
+ return mergeReturn(
363
+ action.step,
364
+ answerOf(
365
+ "merge",
366
+ await step.do(action.step, STEP_CONFIG, () =>
367
+ call(bot, "merge", { ...tag, prNumber: action.prNumber, headSha: action.headSha }),
368
+ ),
369
+ ),
370
+ );
371
+ }
372
+ }
373
+
374
+ /** One unit's pipeline: its start, then the machine's steps until it ends;
375
+ * every round boundary and the ending told to the bot as they happen. */
376
+ async function runUnit(
377
+ step: StepRunner,
378
+ bot: CoordinatorBot,
379
+ instanceId: string,
380
+ node: PlanUnitNode,
381
+ plan: PlanFacts,
382
+ ): Promise<UnitEnding> {
383
+ const unit = node.id;
384
+ const tag = { parentInstanceId: instanceId, unit };
385
+ const start = readUnitStart(
386
+ answerOf("unit-start", await step.do(`${unit}/start`, STEP_CONFIG, () => call(bot, "unit-start", tag))),
387
+ );
388
+ let state: UnitPipelineState = openUnitPipeline(
389
+ {
390
+ unit: { id: unit, branch: node.branch },
391
+ repo: plan.repo,
392
+ base: plan.base,
393
+ caps: plan.caps,
394
+ childMinutes: plan.childMinutes,
395
+ // The branch decides who merges (record 0031's merge grant): a plan
396
+ // branch the runner opened is the runner's to squash once the review
397
+ // approved at its head and the checks are green; any other branch — a
398
+ // task string's ship branch — waits for a person.
399
+ merge: parsePlanBranch(node.branch) !== undefined ? "runner" : "person",
400
+ },
401
+ start.at,
402
+ );
403
+ let notes = 0;
404
+ for (;;) {
405
+ const action = nextAction(state);
406
+ if (action.type === "end") return action.ending;
407
+ const transition = applyReturn(state, await perform(step, bot, instanceId, unit, action));
408
+ state = transition.state;
409
+ for (const note of transition.notes) {
410
+ if (note.type === "round") {
411
+ const body = { ...tag, index: note.index, agent: note.agent, outcome: note.outcome };
412
+ await step.do(`${unit}/note/${++notes}`, STEP_CONFIG, () => call(bot, "round", body));
413
+ } else {
414
+ const body = {
415
+ ...tag,
416
+ ending: { kind: note.ending.kind, report: renderUnitReport(state) },
417
+ ...(state.pr !== undefined ? { pr: state.pr } : {}),
418
+ };
419
+ await step.do(`${unit}/end`, STEP_CONFIG, () => call(bot, "unit-end", body));
420
+ }
421
+ }
422
+ }
423
+ }
424
+
425
+ function blockedReport(unit: string, dep: string, depEnding: string): string {
426
+ if (depEnding === "blocked")
427
+ return `⛔ Blocked: ${unit} waits on ${dep}, which is blocked itself. Re-issue the plan naming the remaining units once it is resolved.`;
428
+ const person = depEnding === "merge_ready";
429
+ return `⛔ Blocked: ${unit} waits on ${dep}, which ended ${depEnding}${person ? " — a person's merge" : ""}. Re-issue the plan naming the remaining units once it is ${person ? "merged" : "resolved"}.`;
430
+ }
431
+
432
+ /** The plan: its units one at a time in dependency order, then the endings of the units it never reached. */
433
+ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string): Promise<PlanRunSummary> {
434
+ const plan = readPlan(
435
+ answerOf("plan", await step.do("plan", STEP_CONFIG, () => call(bot, "plan", { parentInstanceId: instanceId }))),
436
+ );
437
+ const graph: PlanGraph = {
438
+ planId: plan.planId ?? instanceId,
439
+ units: plan.units.map((u) => ({
440
+ id: u.unit,
441
+ title: u.title ?? u.unit,
442
+ slug: u.slug,
443
+ branch: u.branch,
444
+ dependsOn: u.dependsOn,
445
+ })),
446
+ };
447
+ let cursor = openPlanCursor(graph);
448
+ const endings: Record<string, string> = {};
449
+ for (;;) {
450
+ const [next] = readyUnits(graph, cursor);
451
+ if (next === undefined) break;
452
+ cursor = startUnit(graph, cursor, next);
453
+ const ending = await runUnit(
454
+ step,
455
+ bot,
456
+ instanceId,
457
+ graph.units.find((u) => u.id === next)!,
458
+ plan,
459
+ );
460
+ endings[next] = ending.kind;
461
+ cursor = settleUnit(graph, cursor, next, ending.kind === "merged" ? "done" : "failed");
462
+ }
463
+ // Blocked units, in the plan's order: each told its own ending, so the rows
464
+ // and the summary say why it never ran. Every blocked unit's ending is known
465
+ // before any report is rendered — a plan may list a dependent before the
466
+ // dependency that blocks it.
467
+ const blocked = cursor.order.filter((id) => cursor.status[id] === "blocked");
468
+ for (const id of blocked) endings[id] = "blocked";
469
+ for (const id of blocked) {
470
+ const node = graph.units.find((u) => u.id === id)!;
471
+ const dep = node.dependsOn.find((d) => cursor.status[d] === "failed" || cursor.status[d] === "blocked")!;
472
+ const body = {
473
+ parentInstanceId: instanceId,
474
+ unit: id,
475
+ ending: { kind: "blocked", report: blockedReport(id, dep, endings[dep]!) },
476
+ };
477
+ await step.do(`${id}/end`, STEP_CONFIG, () => call(bot, "unit-end", body));
478
+ }
479
+ if (!cursorFinished(cursor)) throw new Error(`the plan's cursor did not finish: ${JSON.stringify(cursor.status)}`);
480
+ const settled = (kind: string) => kind === "merged" || kind === "merge_ready";
481
+ return {
482
+ instance: instanceId,
483
+ ...(plan.planId !== undefined ? { planId: plan.planId } : {}),
484
+ units: endings,
485
+ outcome: cursor.order.every((id) => settled(endings[id] ?? "")) ? "completed" : "failed",
486
+ };
487
+ }
488
+
489
+ /** The Workflow's body. The finish is asked on every path — as `failed`, best
490
+ * effort, when the walk threw — and the cause is rethrown, so the instance's
491
+ * own status says what happened and the parent's record exists either way. */
492
+ export async function runPlan(step: StepRunner, bot: CoordinatorBot, instanceId: string): Promise<PlanRunSummary> {
493
+ const finish = (outcome: PlanRunSummary["outcome"]) =>
494
+ step.do("finish", STEP_CONFIG, () => call(bot, "finish", { parentInstanceId: instanceId, outcome }));
495
+ let summary: PlanRunSummary;
496
+ try {
497
+ summary = await walk(step, bot, instanceId);
498
+ } catch (err) {
499
+ await finish("failed").catch(() => {});
500
+ throw err;
501
+ }
502
+ await finish(summary.outcome);
503
+ return summary;
504
+ }
@@ -0,0 +1,181 @@
1
+ // The pure halves of the shim's `POST /admin/coordinator/instances`
2
+ // (docs/reference/specs/http-ingress.md item 9): what the bot's shim Worker
3
+ // decides without the platform — the body it accepts, how it reads the bot's
4
+ // answer to "does this bearer hold `coordinator:step`?", and the wire shape of
5
+ // each outcome — so `deploy/cloudflare/worker.ts` is one `create` call between
6
+ // three tested functions, the way `src/deploy/restart.ts` is for the restart.
7
+ // Node-free: the shim imports this by relative path.
8
+ //
9
+ // Why the shim asks the bot: the Worker holds the token map (WHO), the grants
10
+ // live in the container's config (WHETHER). The shim relays the caller's own
11
+ // bearer to `POST /admin/coordinator/authorize`, where the bot runs the whole
12
+ // check against the policy table, and creates the instance only on a 200.
13
+
14
+ import { INSTANCE_ID_PATTERN } from "./contract.js";
15
+
16
+ export const COORDINATOR_INSTANCES_PATH = "/admin/coordinator/instances";
17
+ /** The bot's question route: 200 `{ ok, subject }` when the bearer's actor holds `coordinator:step`. */
18
+ export const COORDINATOR_AUTHORIZE_PATH = "/admin/coordinator/authorize";
19
+
20
+ export type ParsedCreateInstance =
21
+ { ok: true; id: string; params: Record<string, unknown> } | { ok: false; reason: string };
22
+
23
+ /** The body: `{ id, params? }` — the instance's id in the platform's alphabet,
24
+ * the params the Workflow is created with (ids only by the coordinator's
25
+ * contract; what they are is the Workflow's to type). */
26
+ export function parseCreateInstanceRequest(text: string): ParsedCreateInstance {
27
+ let parsed: unknown;
28
+ try {
29
+ parsed = JSON.parse(text);
30
+ } catch {
31
+ return { ok: false, reason: "body is not valid JSON" };
32
+ }
33
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
34
+ return { ok: false, reason: "body must be a JSON object" };
35
+ const b = parsed as Record<string, unknown>;
36
+ if (typeof b.id !== "string" || !INSTANCE_ID_PATTERN.test(b.id))
37
+ return { ok: false, reason: "`id` must be a Workflow instance id: letters, digits, `_` and `-`, at most 100" };
38
+ if (b.params !== undefined && (typeof b.params !== "object" || b.params === null || Array.isArray(b.params)))
39
+ return { ok: false, reason: "`params` must be an object" };
40
+ return { ok: true, id: b.id, params: (b.params as Record<string, unknown> | undefined) ?? {} };
41
+ }
42
+
43
+ export type SubjectAuthorization =
44
+ { ok: true; subject: string } | { ok: false; status: 401 | 403 | 503; reason: string };
45
+
46
+ /** The bot's `POST /admin/coordinator/authorize` answer, as the shim reads it:
47
+ * 200 `{ ok: true, subject }` → allowed; 401 / 403 / 503 `{ ok: false,
48
+ * error }` → relayed as they are; anything else (a bot without the route, a
49
+ * non-JSON body) → 503, fail-closed — nothing is created on an answer the shim
50
+ * cannot read. */
51
+ export function parseSubjectAuthorization(status: number, text: string): SubjectAuthorization {
52
+ let parsed: unknown;
53
+ try {
54
+ parsed = JSON.parse(text);
55
+ } catch {
56
+ parsed = undefined;
57
+ }
58
+ const body = typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : undefined;
59
+ if (status === 200 && body?.ok === true && typeof body.subject === "string" && body.subject !== "")
60
+ return { ok: true, subject: body.subject };
61
+ if ((status === 401 || status === 403 || status === 503) && body?.ok === false && typeof body.error === "string")
62
+ return { ok: false, status, reason: body.error };
63
+ return {
64
+ ok: false,
65
+ status: 503,
66
+ reason: `coordinator disabled: the bot did not answer the authorization check (HTTP ${status}) — is it running this version?`,
67
+ };
68
+ }
69
+
70
+ /** How the create ended: the platform's `create`, a duplicate id (the engine
71
+ * refuses one, and the existing instance's status is read back), or a failure. */
72
+ export type CreateInstanceOutcome =
73
+ | { kind: "created"; id: string }
74
+ | { kind: "duplicate"; id: string; status?: string }
75
+ | { kind: "failed"; id: string; reason: string };
76
+
77
+ export function createInstanceResponse(outcome: CreateInstanceOutcome): {
78
+ status: number;
79
+ body: Record<string, unknown>;
80
+ } {
81
+ switch (outcome.kind) {
82
+ case "created":
83
+ return { status: 201, body: { ok: true, id: outcome.id, created: true } };
84
+ case "duplicate":
85
+ return {
86
+ status: 409,
87
+ body: {
88
+ ok: false,
89
+ error: "duplicate_instance",
90
+ id: outcome.id,
91
+ ...(outcome.status !== undefined ? { status: outcome.status } : {}),
92
+ },
93
+ };
94
+ case "failed":
95
+ return { status: 502, body: { ok: false, error: "create_failed", id: outcome.id, message: outcome.reason } };
96
+ }
97
+ }
98
+
99
+ /** The shim's answer as the bot reads it back (the reverse direction of
100
+ * `createInstanceResponse`): the three outcomes by their wire shape, and
101
+ * `unanswered` by reason for anything else — the door's 401/403, a shim
102
+ * without the route, a body that is not the route's. */
103
+ export type CreateInstanceAnswer = CreateInstanceOutcome | { kind: "unanswered"; reason: string };
104
+
105
+ export function readCreateInstanceAnswer(status: number, text: string): CreateInstanceAnswer {
106
+ let parsed: unknown;
107
+ try {
108
+ parsed = JSON.parse(text);
109
+ } catch {
110
+ parsed = undefined;
111
+ }
112
+ const body = typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : undefined;
113
+ if (body !== undefined && typeof body.id === "string") {
114
+ if (status === 201 && body.ok === true && body.created === true) return { kind: "created", id: body.id };
115
+ if (status === 409 && body.error === "duplicate_instance")
116
+ return { kind: "duplicate", id: body.id, ...(typeof body.status === "string" ? { status: body.status } : {}) };
117
+ if (status === 502 && body.error === "create_failed")
118
+ return { kind: "failed", id: body.id, reason: typeof body.message === "string" ? body.message : "create_failed" };
119
+ }
120
+ const detail = typeof body?.error === "string" ? body.error : text.slice(0, 200);
121
+ return { kind: "unanswered", reason: `HTTP ${status} — ${detail}` };
122
+ }
123
+
124
+ /** `GET /admin/coordinator/instances/<id>` — the platform's status of one instance, read by the shim. */
125
+ export const COORDINATOR_INSTANCE_STATUS_PREFIX = `${COORDINATOR_INSTANCES_PATH}/`;
126
+
127
+ /** The instance id a status path names, or undefined for any other path. */
128
+ export function parseInstanceStatusPath(pathname: string): string | undefined {
129
+ if (!pathname.startsWith(COORDINATOR_INSTANCE_STATUS_PREFIX)) return undefined;
130
+ const id = pathname.slice(COORDINATOR_INSTANCE_STATUS_PREFIX.length);
131
+ return INSTANCE_ID_PATTERN.test(id) ? id : undefined;
132
+ }
133
+
134
+ /** How the status read ended on the shim: the platform's status word, no such
135
+ * instance, or the engine failing by reason. */
136
+ export type InstanceStatusOutcome =
137
+ | { kind: "status"; id: string; status: string }
138
+ | { kind: "absent"; id: string }
139
+ | { kind: "failed"; id: string; reason: string };
140
+
141
+ export function instanceStatusResponse(outcome: InstanceStatusOutcome): {
142
+ status: number;
143
+ body: Record<string, unknown>;
144
+ } {
145
+ switch (outcome.kind) {
146
+ case "status":
147
+ return { status: 200, body: { ok: true, id: outcome.id, status: outcome.status } };
148
+ case "absent":
149
+ return { status: 404, body: { ok: false, error: "no_instance", id: outcome.id } };
150
+ case "failed":
151
+ return { status: 502, body: { ok: false, error: "status_failed", id: outcome.id, message: outcome.reason } };
152
+ }
153
+ }
154
+
155
+ /** The Workflows binding's own word for an id it has never seen — the error
156
+ * `Workflow.get(id)` throws, carrying the platform's `instance.not_found` code.
157
+ * Nothing else reads as absence: a failure whose text merely mentions "not
158
+ * found" is a failure, because a wrong absence lets a re-issue write over a
159
+ * live runner's records. */
160
+ export function isInstanceNotFound(message: string): boolean {
161
+ return /\binstance\.not_found\b/i.test(message);
162
+ }
163
+
164
+ /** The status answer as the bot reads it back: the word, `absent`, or `unanswered` by reason for anything else. */
165
+ export type InstanceStatusAnswer =
166
+ { kind: "status"; status: string } | { kind: "absent" } | { kind: "unanswered"; reason: string };
167
+
168
+ export function readInstanceStatusAnswer(status: number, text: string): InstanceStatusAnswer {
169
+ let parsed: unknown;
170
+ try {
171
+ parsed = JSON.parse(text);
172
+ } catch {
173
+ parsed = undefined;
174
+ }
175
+ const body = typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : undefined;
176
+ if (status === 200 && body?.ok === true && typeof body.status === "string" && body.status !== "")
177
+ return { kind: "status", status: body.status };
178
+ if (status === 404 && body?.error === "no_instance") return { kind: "absent" };
179
+ const detail = typeof body?.error === "string" ? body.error : text.slice(0, 200);
180
+ return { kind: "unanswered", reason: `HTTP ${status} — ${detail}` };
181
+ }