@coreplane/switchboard 1.202.0 → 1.203.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 (37) hide show
  1. package/bin/switchboard.js +26 -0
  2. package/dist/assets/config/config.example.yaml +10 -0
  3. package/dist/assets/deploy/cloudflare-resident/preflight.mjs +3 -3
  4. package/dist/assets/deploy/cloudflare-resident/refresh.ts +179 -93
  5. package/dist/assets/deploy/cloudflare-resident/shared.ts +15 -10
  6. package/dist/assets/deploy/cloudflare-resident/worker.ts +459 -632
  7. package/dist/assets/package-lock.json +4 -4
  8. package/dist/assets/package.json +1 -1
  9. package/dist/assets/source.json +3 -3
  10. package/dist/assets/src/core/authz/policy.ts +4 -0
  11. package/dist/assets/src/core/runRecord.ts +11 -0
  12. package/dist/assets/src/core/schedules.ts +12 -6
  13. package/dist/assets/src/core/ship/handoff.ts +246 -0
  14. package/dist/assets/src/execution/residentDisk.ts +2 -2
  15. package/dist/assets/src/execution/residentInstanceId.ts +48 -45
  16. package/dist/assets/src/execution/residentRefresh.ts +17 -66
  17. package/dist/assets/src/execution/residentState.ts +8 -9
  18. package/dist/assets/src/execution/residentStepPlan.ts +1 -1
  19. package/dist/assets/web/dist/.vite/manifest.json +55 -44
  20. package/dist/assets/web/dist/assets/{AppShell-CJLPO_aI.js → AppShell-Bw3-_TTE.js} +1 -1
  21. package/dist/assets/web/dist/assets/{CostsPage-C6ZvnAmH.js → CostsPage-XQ-fQzdz.js} +1 -1
  22. package/dist/assets/web/dist/assets/DeliveryPage-BRwcQyr7.js +1 -0
  23. package/dist/assets/web/dist/assets/{NotFoundPage-BdLcY60r.js → NotFoundPage-RjH-9dPy.js} +1 -1
  24. package/dist/assets/web/dist/assets/{ResidentDetailPage-hXWrr8T6.js → ResidentDetailPage-BRy5wkv9.js} +1 -1
  25. package/dist/assets/web/dist/assets/{ResidentsIndexPage-YLu4JwxJ.js → ResidentsIndexPage-DAEVLN8j.js} +1 -1
  26. package/dist/assets/web/dist/assets/{RunRoutePage-BDhHRJVO.js → RunRoutePage-B1KHmkZ9.js} +1 -1
  27. package/dist/assets/web/dist/assets/{RunsIndexPage-D7zZ5a0l.js → RunsIndexPage-3hUWFpFV.js} +1 -1
  28. package/dist/assets/web/dist/assets/{RunsTabs-BOUlSa2W.js → RunsTabs-Qn_5TVJU.js} +1 -1
  29. package/dist/assets/web/dist/assets/{ScheduledPage-BciOQcC-.js → ScheduledPage-DysVvVm1.js} +1 -1
  30. package/dist/assets/web/dist/assets/{StatusDot-CTDLBv92.js → StatusDot-Bug2a6T6.js} +1 -1
  31. package/dist/assets/web/dist/assets/{Tooltip-DD2v9Gxx.js → Tooltip-C2eEUbwn.js} +1 -1
  32. package/dist/assets/web/dist/assets/{favicon-C4Q8cRsP.js → favicon-CFfbl2AI.js} +1 -1
  33. package/dist/assets/web/dist/assets/{main-DrSlUcMg.js → main-0uhp-taL.js} +3 -3
  34. package/dist/assets/web/dist/assets/main-Xickfv8V.css +1 -0
  35. package/dist/cli.js +2932 -1875
  36. package/package.json +3 -2
  37. package/dist/assets/web/dist/assets/main-DoTjwE-G.css +0 -1
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.202.0",
3
+ "version": "1.203.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.202.0",
9
+ "version": "1.203.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -18806,7 +18806,7 @@
18806
18806
  },
18807
18807
  "packages/switchboard": {
18808
18808
  "name": "@coreplane/switchboard",
18809
- "version": "1.202.0",
18809
+ "version": "1.203.0",
18810
18810
  "license": "Apache-2.0",
18811
18811
  "dependencies": {
18812
18812
  "@anthropic-ai/sdk": "^0.124.0",
@@ -18818,7 +18818,7 @@
18818
18818
  "zod": "^4.5.4"
18819
18819
  },
18820
18820
  "bin": {
18821
- "switchboard": "dist/cli.js"
18821
+ "switchboard": "bin/switchboard.js"
18822
18822
  },
18823
18823
  "devDependencies": {
18824
18824
  "esbuild": "^0.28.2",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.202.0",
3
+ "version": "1.203.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.202.0",
3
- "commit": "1da9e3cbb372db6a36b9218f2df3f33a9ed0a828",
4
- "builtAt": "2026-09-11T21:24:21.512Z"
2
+ "version": "1.203.0",
3
+ "commit": "e3f155d00ca4b1131eb3bbe4d9274a825afa4e9e",
4
+ "builtAt": "2026-09-11T23:34:59.316Z"
5
5
  }
@@ -112,6 +112,10 @@ export const POLICY: readonly Rule[] = [
112
112
  // ── help / schedules / deploy / env / setup ──────────────────────────────────────
113
113
  { action: "help:read", resource: "command", when: [grant("help:read")] },
114
114
  { action: "status:read", resource: "command", when: [grant("status:read")] },
115
+ // `delivery report` reads GitHub with the App's token and the caller's own
116
+ // visible runs: the grant admits the command (browsers through the read
117
+ // baseline, tokens minted with it); which runs join is the `runs:read` predicate.
118
+ { action: "delivery:read", resource: "command", when: [grant("delivery:read")] },
115
119
  { action: "schedule:read", resource: "command", when: [grant("schedule:read")] },
116
120
  { action: "deploy:read", resource: "command", when: [grant("deploy:read")] },
117
121
  { action: "deploy:write", resource: "command", when: [grant("deploy:write")] },
@@ -1,6 +1,7 @@
1
1
  import type { ChannelVisibility, Predicate } from "./authz/types.js";
2
2
  import type { RunEvent } from "./runEvents.js";
3
3
  import { isHeadMaterial, isSpanRecord } from "./runEvents.js";
4
+ import { isHandoffShape, type Handoff } from "./ship/handoff.js";
4
5
  import {
5
6
  FRICTION_CATEGORIES,
6
7
  type CategoryTotals,
@@ -89,6 +90,13 @@ export interface RunRecord {
89
90
  /** The thread that started the run (`IncomingMessage.sourceUrl`), for the
90
91
  * index's hover link. Optional as above. */
91
92
  sourceUrl?: string;
93
+ /** The typed handoff a coding child submitted (docs/reference/specs/agent-ship.md
94
+ * item 14): its deviations from the plan unit, its follow-ups and the
95
+ * criteria it could not prove — redacted like every stored string. Present
96
+ * only on a run that submitted one (an affirmed empty handoff is stored as
97
+ * three empty lists, distinguishable from none); absent on every other run
98
+ * and on records written before it existed. */
99
+ handoff?: Handoff;
92
100
  }
93
101
 
94
102
  /** A run as a listing shows it: the record minus its events. `diagnosis` stays —
@@ -408,6 +416,9 @@ export function isRunRecord(v: unknown): v is RunRecord {
408
416
  )
409
417
  return false;
410
418
  if (!isOptionalString(r.activity) || !isOptionalString(r.sourceUrl) || !isOptionalString(r.userName)) return false;
419
+ // The handoff is checked for shape, not bounds (docs/reference/specs/agent-ship.md
420
+ // item 14): redaction may lengthen a stored string past the tool's limit.
421
+ if (r.handoff !== undefined && !isHandoffShape(r.handoff)) return false;
411
422
  if (typeof r.channelId !== "string" || typeof r.userId !== "string" || typeof r.threadKey !== "string") return false;
412
423
  // Absent on records written before the stamp existed (read as `unknown`); present → a known value.
413
424
  if (r.channelVisibility !== undefined && !CHANNEL_VISIBILITIES.includes(r.channelVisibility as ChannelVisibility))
@@ -116,7 +116,7 @@ export const SCHEDULES: readonly ScheduleDef[] = [
116
116
  cron: "*/10 * * * *",
117
117
  worker: "resident",
118
118
  description:
119
- "Resident watchdog: re-arm dead refresh alarm chains (marking degraded(alarm-missed)), time out stuck onboarding, and create the refresh Workflow instance for residents whose lifecycle is `workflow`. Cadence must stay shorter than the resident SLEEP_AFTER (20m). Not a run.",
119
+ "Resident watchdog: create each resident's refresh Workflow instance when its ten-minute bucket is due, name a stale mid-flight marker by its instance, and time out stuck onboarding. Cadence must stay shorter than the resident SLEEP_AFTER (20m). Not a run.",
120
120
  action: { type: "watchdog" },
121
121
  },
122
122
  ];
@@ -376,9 +376,7 @@ export interface WatchdogSummary {
376
376
  action?: unknown;
377
377
  error?: string;
378
378
  disk?: unknown;
379
- /** `alarm` or `workflow`: which scheduler drives this resident's refresh cycle. */
380
- lifecycle?: unknown;
381
- /** What the pass did about the resident's refresh Workflow instance (`workflow` residents only). */
379
+ /** What the pass did about the resident's refresh Workflow instance: `{id, action, why}`. */
382
380
  instance?: unknown;
383
381
  }>;
384
382
  }
@@ -396,6 +394,14 @@ function fullestDisk(results: WatchdogSummary["results"]): string | undefined {
396
394
  return best ? `${best.pct}% (${best.resource.replace(/^repo:/, "")})` : undefined;
397
395
  }
398
396
 
397
+ /** The `action` word of a resident's refresh-instance outcome on a watchdog
398
+ * pass (`created | duplicate | skipped | failed`), or undefined when the row
399
+ * carries none. */
400
+ function instanceActionOf(instance: unknown): string | undefined {
401
+ const action = (instance as { action?: unknown } | null | undefined)?.action;
402
+ return typeof action === "string" ? action : undefined;
403
+ }
404
+
399
405
  /** Turn a watchdog pass (or the error it threw) into a firing record. A pass is
400
406
  * `completed` when every resident was checked; any per-resident error — or a
401
407
  * throw before the sweep — is `failed`, naming the first failing resident. */
@@ -407,10 +413,10 @@ export function watchdogFiring(
407
413
  if (result instanceof Error)
408
414
  return { schedule: schedule.name, firedAt, outcome: "failed", detail: cap(`watchdog threw: ${result.message}`) };
409
415
  const errors = result.results.filter((r) => r.error !== undefined);
410
- const reArmed = result.results.filter((r) => r.action === "re-armed").length;
416
+ const created = result.results.filter((r) => instanceActionOf(r.instance) === "created").length;
411
417
  const timedOut = result.results.filter((r) => r.action === "provision-timed-out").length;
412
418
  const disk = fullestDisk(result.results);
413
- const counts = `${result.count}/${result.cap} residents · ${reArmed} re-armed · ${timedOut} timed out · ${errors.length} errors${disk ? ` · disk max ${disk}` : ""}`;
419
+ const counts = `${result.count}/${result.cap} residents · ${created} refreshed · ${timedOut} timed out · ${errors.length} errors${disk ? ` · disk max ${disk}` : ""}`;
414
420
  const first = errors[0];
415
421
  return {
416
422
  schedule: schedule.name,
@@ -0,0 +1,246 @@
1
+ import { redactSecrets } from "../redact.js";
2
+
3
+ // The handoff (docs/decisions/0031-the-coordinator-runs-a-plan-not-a-pull-request.md,
4
+ // docs/reference/specs/agent-ship.md item 14, agent-coding.md item 9): what a
5
+ // coding child hands back beside its pull-request description when it ran for
6
+ // a plan unit — where it departed from the unit and why, what it found and did
7
+ // not do, and which of the unit's criteria it could not prove. It is data, not
8
+ // prose in a final message: the child submits it through `submit_handoff` (the
9
+ // same tool path as the description), the run record carries it, and the
10
+ // parent posts it to the unit's board issue, where a person decides each row's
11
+ // disposition and amends the plan's follow-ups ledger while the plan is still
12
+ // `proposed` — no bot path writes a plan record.
13
+ //
14
+ // Pure and dependency-light on purpose: `runRecord.ts` (node-free, shared with
15
+ // the state Worker) imports the type and the shape check, so nothing here may
16
+ // pull in zod or a Node built-in. The validator is hand-written like
17
+ // `isRunRecord`; the redaction seam is the same import-free module every
18
+ // surface uses.
19
+
20
+ export interface HandoffDeviation {
21
+ /** What the unit said. */
22
+ from: string;
23
+ /** What was done instead. */
24
+ to: string;
25
+ why: string;
26
+ }
27
+
28
+ export interface HandoffFollowUp {
29
+ /** What was found and not done. */
30
+ what: string;
31
+ /** Where it belongs — a file, a unit, a spec. */
32
+ where: string;
33
+ }
34
+
35
+ export interface HandoffUnproven {
36
+ /** The unit's test scenario or criterion. */
37
+ criterion: string;
38
+ why: string;
39
+ }
40
+
41
+ export interface Handoff {
42
+ deviations: HandoffDeviation[];
43
+ followUps: HandoffFollowUp[];
44
+ unproven: HandoffUnproven[];
45
+ }
46
+
47
+ /** The most entries one list may carry: a handoff is a summary for a person,
48
+ * not a second review. */
49
+ export const HANDOFF_MAX_ITEMS = 20;
50
+ /** The most characters one field may carry, on the way in. Stored records are
51
+ * checked for shape only (`isHandoffShape`): redaction may lengthen a string. */
52
+ export const HANDOFF_MAX_FIELD_CHARS = 500;
53
+
54
+ type ListKey = keyof Handoff;
55
+
56
+ /** Each list's entry fields, the one source for the validator, the redactor and the renderers. */
57
+ const FIELDS: Readonly<Record<ListKey, readonly string[]>> = {
58
+ deviations: ["from", "to", "why"],
59
+ followUps: ["what", "where"],
60
+ unproven: ["criterion", "why"],
61
+ };
62
+ const LISTS: readonly ListKey[] = ["deviations", "followUps", "unproven"];
63
+
64
+ /** A handoff under construction: each list as entries keyed by `FIELDS`. It IS
65
+ * a `Handoff` once every entry carries its list's fields — which the two
66
+ * builders below guarantee — so the one conversion at their boundary is the
67
+ * table above standing in for three interface declarations. */
68
+ type FieldLists = Record<ListKey, Record<string, string>[]>;
69
+
70
+ const emptyLists = (): FieldLists => ({ deviations: [], followUps: [], unproven: [] });
71
+ const asHandoff = (lists: FieldLists): Handoff => lists as unknown as Handoff;
72
+ const asLists = (h: Handoff): FieldLists => h as unknown as FieldLists;
73
+
74
+ export function emptyHandoff(): Handoff {
75
+ return asHandoff(emptyLists());
76
+ }
77
+
78
+ export function isEmptyHandoff(h: Handoff): boolean {
79
+ return LISTS.every((k) => h[k].length === 0);
80
+ }
81
+
82
+ function isRecord(v: unknown): v is Record<string, unknown> {
83
+ return typeof v === "object" && v !== null && !Array.isArray(v);
84
+ }
85
+
86
+ /** Structural check — the three lists, each entry an object whose named fields
87
+ * are strings. What a stored record is validated with: no bounds, so a record
88
+ * never becomes unreadable because redaction lengthened a field. */
89
+ export function isHandoffShape(v: unknown): v is Handoff {
90
+ if (!isRecord(v)) return false;
91
+ return LISTS.every((key) => {
92
+ const list = v[key];
93
+ return (
94
+ Array.isArray(list) &&
95
+ list.every((entry) => isRecord(entry) && FIELDS[key].every((f) => typeof entry[f] === "string"))
96
+ );
97
+ });
98
+ }
99
+
100
+ export type ParsedHandoff = { ok: true; handoff: Handoff } | { ok: false; error: string };
101
+
102
+ const fieldList = (key: ListKey): string => {
103
+ const fs = FIELDS[key];
104
+ return `${fs.slice(0, -1).join(", ")} and ${fs[fs.length - 1]}`;
105
+ };
106
+
107
+ /** Validate untrusted input (a tool call) into a Handoff: the three lists
108
+ * present (each may be empty), at most `HANDOFF_MAX_ITEMS` entries each,
109
+ * every field a non-empty string of at most `HANDOFF_MAX_FIELD_CHARS` once
110
+ * trimmed; unknown keys are dropped. A refusal is a string naming the path,
111
+ * never a throw, so the model can fix the object and call again. */
112
+ export function parseHandoff(input: unknown): ParsedHandoff {
113
+ if (!isRecord(input))
114
+ return { ok: false, error: "handoff: must be an object with deviations, followUps and unproven" };
115
+ const out = emptyLists();
116
+ for (const key of LISTS) {
117
+ const list = input[key];
118
+ if (!Array.isArray(list))
119
+ return { ok: false, error: `${key}: must be an array (empty when there is nothing to say)` };
120
+ if (list.length > HANDOFF_MAX_ITEMS) return { ok: false, error: `${key}: at most ${HANDOFF_MAX_ITEMS} entries` };
121
+ for (let i = 0; i < list.length; i++) {
122
+ const entry: unknown = list[i];
123
+ if (!isRecord(entry)) return { ok: false, error: `${key}.${i}: must be an object with ${fieldList(key)}` };
124
+ const clean: Record<string, string> = {};
125
+ for (const f of FIELDS[key]) {
126
+ const raw = entry[f];
127
+ const value = typeof raw === "string" ? raw.trim() : "";
128
+ if (value.length === 0 || value.length > HANDOFF_MAX_FIELD_CHARS)
129
+ return {
130
+ ok: false,
131
+ error: `${key}.${i}.${f}: must be a non-empty string of at most ${HANDOFF_MAX_FIELD_CHARS} characters`,
132
+ };
133
+ clean[f] = value;
134
+ }
135
+ out[key].push(clean);
136
+ }
137
+ }
138
+ return { ok: true, handoff: asHandoff(out) };
139
+ }
140
+
141
+ /** Every string leaf through the redaction seam (`redactSecrets` by default),
142
+ * structure preserved, the input untouched — the same rule the published
143
+ * description follows, so a credential pasted into a handoff never reaches a
144
+ * run record or a board issue. */
145
+ export function redactHandoff(h: Handoff, redact: (s: string) => string = redactSecrets): Handoff {
146
+ const out = emptyLists();
147
+ for (const key of LISTS) {
148
+ for (const entry of asLists(h)[key]) {
149
+ const clean: Record<string, string> = {};
150
+ for (const f of FIELDS[key]) clean[f] = redact(entry[f]!);
151
+ out[key].push(clean);
152
+ }
153
+ }
154
+ return asHandoff(out);
155
+ }
156
+
157
+ // ---- the renders ---------------------------------------------------------------------------------
158
+
159
+ export interface HandoffRenderContext {
160
+ /** The unit's id as the plan spells it (`U17`). */
161
+ unitId: string;
162
+ /** The unit's pull request, when the round opened or edited one. */
163
+ pr?: { number: number; url: string };
164
+ }
165
+
166
+ /** The header of the plan's follow-ups ledger (Appendix B), so the rows below
167
+ * paste into it as the same table. */
168
+ export const HANDOFF_LEDGER_HEADER = "| Follow-up | Source | Disposition |\n|---|---|---|";
169
+
170
+ /** One line: a newline would end a bullet or a table row. */
171
+ function line(s: string): string {
172
+ return s.replace(/\s*\n\s*/g, " ");
173
+ }
174
+
175
+ /** A table cell: a literal `|` would split the row; a backslash is escaped
176
+ * first so an input `\|` does not become an escaped escape. */
177
+ function cell(s: string): string {
178
+ return line(s.replace(/\\/g, "\\\\").replace(/\|/g, "\\|"));
179
+ }
180
+
181
+ /** Each entry as one line of prose, the same words in a bullet and in a ledger row. */
182
+ function entryLines(h: Handoff): Array<{ list: ListKey; bullet: string; row: string }> {
183
+ return [
184
+ ...h.deviations.map((d) => {
185
+ const text = `${d.from} → ${d.to} — ${d.why}`;
186
+ return { list: "deviations" as const, bullet: text, row: `Deviation: ${text}` };
187
+ }),
188
+ ...h.followUps.map((f) => {
189
+ const text = `${f.what} — ${f.where}`;
190
+ return { list: "followUps" as const, bullet: text, row: text };
191
+ }),
192
+ ...h.unproven.map((u) => {
193
+ const text = `${u.criterion} — ${u.why}`;
194
+ return { list: "unproven" as const, bullet: text, row: `Unproven: ${text}` };
195
+ }),
196
+ ];
197
+ }
198
+
199
+ function prLink(pr: HandoffRenderContext["pr"]): string | undefined {
200
+ return pr ? `[#${pr.number}](${pr.url})` : undefined;
201
+ }
202
+
203
+ /**
204
+ * The rows a person pastes into the plan's follow-ups ledger — one per entry,
205
+ * in the ledger's shape `| Follow-up | Source | Disposition |`, the source
206
+ * naming the unit's handoff and its pull request, the disposition `open` until
207
+ * a person decides. Empty string for an empty handoff.
208
+ */
209
+ export function renderHandoffLedgerRows(h: Handoff, ctx: HandoffRenderContext): string {
210
+ const link = prLink(ctx.pr);
211
+ const source = `${ctx.unitId} handoff${link ? ` (${link})` : ""}`;
212
+ return entryLines(h)
213
+ .map((e) => `| ${cell(e.row)} | ${cell(source)} | open |`)
214
+ .join("\n");
215
+ }
216
+
217
+ const HEADINGS: Readonly<Record<ListKey, string>> = {
218
+ deviations: "### Deviations",
219
+ followUps: "### Follow-ups",
220
+ unproven: "### Unproven",
221
+ };
222
+
223
+ /**
224
+ * The comment the parent posts on the unit's board issue: the unit id and the
225
+ * pull request in the first line, each non-empty list under its own heading,
226
+ * then the ledger rows in a fenced block a person pastes into the plan while
227
+ * it is `proposed`. An empty list is omitted with its heading; an entirely
228
+ * empty handoff renders nothing — there is nothing for a person to decide.
229
+ * The same object renders to the same bytes.
230
+ */
231
+ export function renderHandoffComment(h: Handoff, ctx: HandoffRenderContext): string | undefined {
232
+ if (isEmptyHandoff(h)) return undefined;
233
+ const link = prLink(ctx.pr);
234
+ const parts: string[] = [`**Handoff — ${ctx.unitId}** · ${link ? `pull request ${link}` : "no pull request"}`];
235
+ const lines = entryLines(h);
236
+ for (const key of LISTS) {
237
+ const bullets = lines.filter((e) => e.list === key).map((e) => `- ${line(e.bullet)}`);
238
+ if (bullets.length > 0) parts.push(`${HEADINGS[key]}\n\n${bullets.join("\n")}`);
239
+ }
240
+ parts.push(
241
+ "### Ledger rows\n\n" +
242
+ "Paste into the plan's follow-ups ledger while the plan is `proposed`; a person decides each disposition.\n\n" +
243
+ `\`\`\`markdown\n${HANDOFF_LEDGER_HEADER}\n${renderHandoffLedgerRows(h, ctx)}\n\`\`\``,
244
+ );
245
+ return parts.join("\n\n");
246
+ }
@@ -10,8 +10,8 @@
10
10
  // falls back cold with a message that reads like a lock bug, and nothing frees
11
11
  // the disk. Two facts fix that: the failure is classified `disk-full` (never
12
12
  // serviceable, so the bot skips the attach and the card names the disk), and
13
- // the resident recycles its container — the disk is a cache; the next alarm
14
- // restores mirror + checkout from R2 — once nothing live would be lost.
13
+ // the resident recycles its container — the disk is a cache; the next refresh
14
+ // cycle restores mirror + checkout from R2 — once nothing live would be lost.
15
15
 
16
16
  /** The errno wording tools print for ENOSPC: Node's `ENOSPC` code and libc's
17
17
  * strerror text (git, cp, tar, pnpm all pass it through). A message carrying
@@ -1,44 +1,43 @@
1
1
  /** The refresh cycle as a cron-created Workflow instance — the id scheme, the
2
- * per-resident lifecycle flag and the cron's decision to create one
2
+ * per-resident lifecycle row and the cron's decision to create one
3
3
  * (docs/reference/specs/resident-repos.md item 7), kept pure and
4
4
  * dependency-free so it is unit-testable from src/ and imported by the
5
5
  * resident Worker like residentRefresh — the tested code IS the shipped code.
6
6
  *
7
- * Background: the refresh cycle is driven by a self-rearming alarm chain,
8
- * and every failure between two re-arms ends the chain silently; a watchdog
9
- * guesses from a timestamp. A Cloudflare Workflow instance is the durable
10
- * fact the chain lacks: the engine records that a sequence is in progress
11
- * and which step it reached, retries a failed step on a policy, and shows a
12
- * failed instance by name. The port runs behind a per-resident flag,
13
- * `lifecycle: alarm | workflow`, default `alarm`: one resident can run its
14
- * cycles as instances while the fleet keeps the chain, and the two
15
- * schedulers never both drive a cycle for the same resident.
7
+ * Background: the refresh cycle used to be driven by a self-rearming alarm
8
+ * chain, and every failure between two re-arms ended the chain silently; a
9
+ * watchdog guessed from a timestamp. A Cloudflare Workflow instance is the
10
+ * durable fact the chain lacked: the engine records that a sequence is in
11
+ * progress and which step it reached, retries a failed step on a policy,
12
+ * and shows a failed instance by name. The port ran behind a per-resident
13
+ * flag for one release; the chain is gone now and Workflows is the one
14
+ * scheduler, so every row reads `workflow` whatever it stores.
16
15
  *
17
16
  * Shape: a cycle is one SHORT instance, not a loop — the resident Worker's
18
17
  * existing ten-minute cron creates one per eligible resident with a
19
18
  * deterministic id per resident and ten-minute bucket, so a second firing in
20
19
  * the same bucket is refused as a duplicate id and cycles stay serialized
21
- * per resident the way the chain serialized them. (A perpetual instance
22
- * that slept between cycles was rejected by arithmetic: five steps every
23
- * 600 s reaches the engine's 10,000-step instance cap in about two weeks.) */
20
+ * per resident. (A perpetual instance that slept between cycles was
21
+ * rejected by arithmetic: five steps every 600 s reaches the engine's
22
+ * 10,000-step instance cap in about two weeks.) */
24
23
 
25
24
  import { STALE_MIDFLIGHT_MS } from "./residentIncarnation.js";
26
- import { nextRefreshDelayS } from "./residentRefresh.js";
27
25
  import type { ResidentLifecycleState } from "./residentState.js";
28
26
 
29
- // -- the flag ----------------------------------------------------------------
27
+ // -- the lifecycle row ---------------------------------------------------------
30
28
 
31
- /** Which scheduler drives a resident's refresh cycle. */
32
- export type ResidentLifecycle = "alarm" | "workflow";
29
+ /** Which scheduler drives a resident's refresh cycle: there is one. The row
30
+ * survives from the flagged rollout so `/status` can say so; an `alarm` value
31
+ * a flip left behind names a chain that no longer exists. */
32
+ export type ResidentLifecycle = "workflow";
33
33
 
34
- /** The alarm chain is the default; a row without the field reads as `alarm`. */
35
- export const DEFAULT_LIFECYCLE: ResidentLifecycle = "alarm";
34
+ export const DEFAULT_LIFECYCLE: ResidentLifecycle = "workflow";
36
35
 
37
36
  export function parseLifecycle(value: unknown): ResidentLifecycle | undefined {
38
- return value === "alarm" || value === "workflow" ? value : undefined;
37
+ return value === "workflow" ? value : undefined;
39
38
  }
40
39
 
41
- /** What a stored row means: the two words, else the default. */
40
+ /** What a stored row means: `workflow`, whatever it says. */
42
41
  export function lifecycleOf(stored: unknown): ResidentLifecycle {
43
42
  return parseLifecycle(stored) ?? DEFAULT_LIFECYCLE;
44
43
  }
@@ -94,7 +93,6 @@ export function refreshInstanceId(owner: string, name: string, atMs: number): st
94
93
 
95
94
  /** What the cron reads about one resident before creating its instance. */
96
95
  export interface RefreshRow {
97
- lifecycle: ResidentLifecycle;
98
96
  state: ResidentLifecycleState;
99
97
  /** When the state last changed (epoch ms); null when the row never recorded one. */
100
98
  updatedAt: number | null;
@@ -110,36 +108,41 @@ export interface RefreshRow {
110
108
  }
111
109
 
112
110
  export interface RefreshCadence {
113
- /** The awake cadence (the alarm's REFRESH_INTERVAL_S). */
111
+ /** The awake cadence (REFRESH_INTERVAL_S). */
114
112
  intervalS: number;
115
- /** The idle cadence (the alarm's IDLE_REFRESH_INTERVAL_S). */
113
+ /** The idle cadence (IDLE_REFRESH_INTERVAL_S). */
116
114
  idleIntervalS: number;
117
115
  }
118
116
 
119
- export type InstanceDecision =
120
- | { create: true; why: "due" }
121
- | { create: false; why: "alarm-lifecycle" | "not-serving" | "mid-cycle" | "not-due" | "running" };
122
-
123
- /** Create an instance only for a `workflow` row that is serving, is not in a
124
- * live cycle (a `refreshing` marker younger than the stale bound; an older
125
- * one is an orphan the next cycle normalizes, exactly as the watchdog
126
- * judges it), and whose cadence has elapsed since the last instance —
127
- * counted in whole buckets so cron jitter never skips a due bucket: the
128
- * awake cadence is one bucket, the idle cadence the row records is
129
- * `nextRefreshDelayS`'s idle interval in buckets. */
130
- export function shouldCreateRefreshInstance(row: RefreshRow, nowMs: number, cadence: RefreshCadence): InstanceDecision {
131
- if (row.lifecycle !== "workflow") return { create: false, why: "alarm-lifecycle" };
132
- if (row.state === "onboarding" || row.state === "down") return { create: false, why: "not-serving" };
133
- if (row.instanceRunning) return { create: false, why: "running" };
117
+ /** Why no cycle may start for a row right now, or null when one may: the
118
+ * resident is not serving (`onboarding` is provisioning's, `down` is a
119
+ * rebuild's); the engine still runs the last instance; or a `refreshing`
120
+ * marker younger than the stale bound says a cycle is live (an older one is
121
+ * an orphan the next cycle normalizes, exactly as the watchdog judges it).
122
+ * Shared by the cron's decision and the admin `refresh-now` op, which
123
+ * starts a cycle whether or not one is due but never beside a live one. */
124
+ export function refreshCycleBlocked(row: RefreshRow, nowMs: number): "not-serving" | "running" | "mid-cycle" | null {
125
+ if (row.state === "onboarding" || row.state === "down") return "not-serving";
126
+ if (row.instanceRunning) return "running";
134
127
  if (row.state === "refreshing" && row.updatedAt !== null && nowMs - row.updatedAt <= STALE_MIDFLIGHT_MS) {
135
- return { create: false, why: "mid-cycle" };
128
+ return "mid-cycle";
136
129
  }
130
+ return null;
131
+ }
132
+
133
+ export type InstanceDecision =
134
+ { create: true; why: "due" } | { create: false; why: "not-serving" | "mid-cycle" | "not-due" | "running" };
135
+
136
+ /** Create an instance only for a row no live cycle blocks (`refreshCycleBlocked`)
137
+ * whose cadence has elapsed since the last instance — counted in whole
138
+ * buckets so cron jitter never skips a due bucket: the awake cadence is one
139
+ * bucket, the idle cadence the row records (`idleSince` set) is the idle
140
+ * interval in buckets. */
141
+ export function shouldCreateRefreshInstance(row: RefreshRow, nowMs: number, cadence: RefreshCadence): InstanceDecision {
142
+ const blocked = refreshCycleBlocked(row, nowMs);
143
+ if (blocked) return { create: false, why: blocked };
137
144
  if (row.lastInstanceAt !== null) {
138
- const delayS = nextRefreshDelayS({
139
- outcome: row.idleSince !== null ? "idle" : "normal",
140
- intervalS: cadence.intervalS,
141
- idleIntervalS: cadence.idleIntervalS,
142
- });
145
+ const delayS = row.idleSince !== null ? cadence.idleIntervalS : cadence.intervalS;
143
146
  const dueBuckets = Math.max(1, Math.ceil((delayS * 1000) / REFRESH_BUCKET_MS));
144
147
  if (refreshBucket(nowMs) - refreshBucket(row.lastInstanceAt) < dueBuckets) return { create: false, why: "not-due" };
145
148
  }