@coreplane/switchboard 1.19.3 → 1.201.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 (51) hide show
  1. package/dist/assets/config/config.example.yaml +9 -0
  2. package/dist/assets/deploy/cloudflare/package.json +1 -1
  3. package/dist/assets/deploy/cloudflare-resident/package.json +1 -1
  4. package/dist/assets/deploy/cloudflare-resident/worker.ts +1663 -456
  5. package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +12 -0
  6. package/dist/assets/deploy/cloudflare-sandbox/package.json +1 -1
  7. package/dist/assets/package-lock.json +13 -22
  8. package/dist/assets/package.json +1 -1
  9. package/dist/assets/project.json +1 -1
  10. package/dist/assets/source.json +3 -3
  11. package/dist/assets/src/core/schedules.ts +7 -2
  12. package/dist/assets/src/core/trace/attrs.ts +7 -0
  13. package/dist/assets/src/execution/residentDepsStore.ts +16 -0
  14. package/dist/assets/src/execution/residentIncarnation.ts +134 -0
  15. package/dist/assets/src/execution/residentInstanceId.ts +177 -0
  16. package/dist/assets/src/execution/residentStepPlan.ts +128 -0
  17. package/dist/assets/web/dist/.vite/manifest.json +104 -67
  18. package/dist/assets/web/dist/assets/{AppShell-Bk2gbvet.js → AppShell-CjDyTiip.js} +1 -1
  19. package/dist/assets/web/dist/assets/CostsPage-CMAh3G74.js +1 -0
  20. package/dist/assets/web/dist/assets/NotFoundPage-CbEKm0tY.js +1 -0
  21. package/dist/assets/web/dist/assets/ResidentDetailPage-CcfmOAIP.js +1 -0
  22. package/dist/assets/web/dist/assets/ResidentsIndexPage-9Z5jcteM.js +1 -0
  23. package/dist/assets/web/dist/assets/{RunRoutePage-XVFj0XDc.css → RunRoutePage-CdbrbqU2.css} +1 -1
  24. package/dist/assets/web/dist/assets/{RunRoutePage-ty94olNM.js → RunRoutePage-Cdimh4e0.js} +4 -4
  25. package/dist/assets/web/dist/assets/RunsIndexPage-CvgDXrO4.js +1 -0
  26. package/dist/assets/web/dist/assets/{RunsTabs-C4krAL9o.js → RunsTabs-rEjVQG6H.js} +1 -1
  27. package/dist/assets/web/dist/assets/ScheduledPage-C5bG9qvd.js +1 -0
  28. package/dist/assets/web/dist/assets/StatusDot-D_IPK3WP.js +1 -0
  29. package/dist/assets/web/dist/assets/{Tooltip-BfLPyxQy.js → Tooltip-JlZp3OVC.js} +1 -1
  30. package/dist/assets/web/dist/assets/{favicon-DL1rdWJt.js → favicon-DZDjc8ab.js} +1 -1
  31. package/dist/assets/web/dist/assets/instrument-sans-latin-ext-wght-normal-B5bTHO_g.woff2 +0 -0
  32. package/dist/assets/web/dist/assets/instrument-sans-latin-wght-normal-BbzFLZTg.woff2 +0 -0
  33. package/dist/assets/web/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  34. package/dist/assets/web/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  35. package/dist/assets/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  36. package/dist/assets/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  37. package/dist/assets/web/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  38. package/dist/assets/web/dist/assets/{main-Bnbk_Rsg.js → main-BKKzFAnK.js} +3 -3
  39. package/dist/assets/web/dist/assets/main-DoTjwE-G.css +1 -0
  40. package/dist/assets/web/dist/assets/{seed-BglCRKLA.js → seed-B8mQEhhw.js} +1 -1
  41. package/dist/assets/web/dist/assets/{wallClock-Ckv3sKoR.js → wallClock-DI4HIEN5.js} +1 -1
  42. package/dist/cli.js +66 -9
  43. package/package.json +1 -1
  44. package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +0 -1
  45. package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +0 -1
  46. package/dist/assets/web/dist/assets/ResidentDetailPage-DvQ05AGa.js +0 -1
  47. package/dist/assets/web/dist/assets/ResidentsIndexPage-B3uxKUne.js +0 -1
  48. package/dist/assets/web/dist/assets/RunsIndexPage-CM-qxyQm.js +0 -1
  49. package/dist/assets/web/dist/assets/ScheduledPage-C1psvLD4.js +0 -1
  50. package/dist/assets/web/dist/assets/StatusDot-DuoQnQeU.js +0 -1
  51. package/dist/assets/web/dist/assets/main-BsBGUyMH.css +0 -2
@@ -110,6 +110,18 @@
110
110
  // this Worker's script (`<script>-cache`), so two installations on one
111
111
  // account never share one — and BACKUP_BUCKET_NAME must say the same name.
112
112
  "r2_buckets": [{ "binding": "BACKUP_BUCKET", "bucket_name": "{{script}}-cache" }],
113
+ // The refresh cycle as a Workflow instance (docs/reference/specs/resident-repos.md
114
+ // item 7): `ResidentRefresh` in worker.ts runs one cycle — fetch, install,
115
+ // build, snapshot — as steps the engine retries and records, calling the
116
+ // resident's own step methods. The watchdog cron below creates one instance
117
+ // per resident whose row says `lifecycle: workflow` (the admin `/debug`
118
+ // `lifecycle` op flips a resident; the default is the alarm chain, so nothing
119
+ // here changes a resident until it is flagged). Workflow names are unique per
120
+ // account, so the name follows this Worker's script (`<script>-refresh`) the
121
+ // way the bucket does — two installations on one account never share one,
122
+ // and the costs page attributes the Workflow's steps to this Worker by that
123
+ // exact name (src/core/costs.ts).
124
+ "workflows": [{ "name": "{{script}}-refresh", "binding": "RESIDENT_REFRESH", "class_name": "ResidentRefresh" }],
113
125
  // Watchdog cadence: every 10 minutes, strictly BELOW the container
114
126
  // sleep window (SLEEP_AFTER = "20m" in worker.ts) so a healthy resident is
115
127
  // re-warmed before the platform can ever sleep it. Invariant: watchdog/alarm
@@ -6,7 +6,7 @@
6
6
  "node": ">=22"
7
7
  },
8
8
  "scripts": {
9
- "check:image": "docker build --quiet .",
9
+ "check:image": "docker build .",
10
10
  "deploy": "node ../bin/build-stamp.mjs",
11
11
  "typecheck": "tsc --noEmit -p tsconfig.json",
12
12
  "verify": "npm --prefix ../.. run --silent deploy:gen && npm run typecheck",
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.19.3",
3
+ "version": "1.201.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.19.3",
9
+ "version": "1.201.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -3757,9 +3757,8 @@
3757
3757
  "docs": {
3758
3758
  "name": "switchboard-docs-site",
3759
3759
  "devDependencies": {
3760
- "@fontsource-variable/bricolage-grotesque": "^5.3.0",
3761
- "@fontsource-variable/geist": "^5.3.0",
3762
- "@fontsource-variable/geist-mono": "^5.3.0",
3760
+ "@fontsource-variable/instrument-sans": "^5.3.0",
3761
+ "@fontsource-variable/jetbrains-mono": "^5.3.0",
3763
3762
  "mermaid": "^11.4.1",
3764
3763
  "vitepress": "^1.6.4",
3765
3764
  "vitepress-plugin-mermaid": "^2.0.17"
@@ -5049,30 +5048,20 @@
5049
5048
  }
5050
5049
  }
5051
5050
  },
5052
- "node_modules/@fontsource-variable/bricolage-grotesque": {
5051
+ "node_modules/@fontsource-variable/instrument-sans": {
5053
5052
  "version": "5.3.0",
5054
- "resolved": "https://registry.npmjs.org/@fontsource-variable/bricolage-grotesque/-/bricolage-grotesque-5.3.0.tgz",
5055
- "integrity": "sha512-TLi9Q4hJjS2UvoTMRSS2nHu6c4R56lAw60NR9QYtVRCHn0XtsFpiEhNffZ8Glsoxu6wEEwLKBP8lb94J52PNBA==",
5053
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/instrument-sans/-/instrument-sans-5.3.0.tgz",
5054
+ "integrity": "sha512-u4gKbDBTNFGkg997tfQn3eHOhHuquWUFTRT/rwzuKtrxX5P2ekfs2x+LgBPP4P32+cC+vUwF1Cr+IdRoPQbrGw==",
5056
5055
  "dev": true,
5057
5056
  "license": "OFL-1.1",
5058
5057
  "funding": {
5059
5058
  "url": "https://github.com/sponsors/ayuhito"
5060
5059
  }
5061
5060
  },
5062
- "node_modules/@fontsource-variable/geist": {
5061
+ "node_modules/@fontsource-variable/jetbrains-mono": {
5063
5062
  "version": "5.3.0",
5064
- "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
5065
- "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
5066
- "dev": true,
5067
- "license": "OFL-1.1",
5068
- "funding": {
5069
- "url": "https://github.com/sponsors/ayuhito"
5070
- }
5071
- },
5072
- "node_modules/@fontsource-variable/geist-mono": {
5073
- "version": "5.3.0",
5074
- "resolved": "https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.3.0.tgz",
5075
- "integrity": "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==",
5063
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
5064
+ "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==",
5076
5065
  "dev": true,
5077
5066
  "license": "OFL-1.1",
5078
5067
  "funding": {
@@ -18340,7 +18329,7 @@
18340
18329
  },
18341
18330
  "packages/switchboard": {
18342
18331
  "name": "@coreplane/switchboard",
18343
- "version": "1.19.3",
18332
+ "version": "1.201.0",
18344
18333
  "license": "Apache-2.0",
18345
18334
  "dependencies": {
18346
18335
  "@anthropic-ai/sdk": "^0.124.0",
@@ -18374,6 +18363,8 @@
18374
18363
  "vue-router": "^5.3.1"
18375
18364
  },
18376
18365
  "devDependencies": {
18366
+ "@fontsource-variable/instrument-sans": "^5.3.0",
18367
+ "@fontsource-variable/jetbrains-mono": "^5.3.0",
18377
18368
  "@iconify-json/lucide": "^1.2.129",
18378
18369
  "@tailwindcss/typography": "^0.5.20",
18379
18370
  "@types/node": "^24.0.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.19.3",
3
+ "version": "1.201.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",
@@ -145,7 +145,7 @@
145
145
  },
146
146
  "specs:coverage": {
147
147
  "does": "Maps a change's paths to the specs whose `Code`/`Tests` headers cover them, then lists changed source paths no spec covers.",
148
- "when": "`-- --changed origin/main...HEAD` before review; `-- --require` fails on an uncovered path (warn-only today); `-- --json` for machines."
148
+ "when": "`-- --changed origin/main...HEAD [--test-guard]` before review; `-- --require` fails on an uncovered path; `-- --json` for machines."
149
149
  },
150
150
  "decisions:check": {
151
151
  "does": "Every record under `docs/decisions/` and `docs/plans/` carries a valid `status`, a superseded one names what replaced it, and an accepted record's body is unchanged against `origin/main`.",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.19.3",
3
- "commit": "a91d7d42749d65c163a4cbe247844a9cbd3fd98f",
4
- "builtAt": "2026-09-11T00:01:20.029Z"
2
+ "version": "1.201.0",
3
+ "commit": "13a85ac1e635a449a99906ea3e04e134ccec335e",
4
+ "builtAt": "2026-09-11T20:09:23.453Z"
5
5
  }
@@ -67,7 +67,8 @@ export type ScheduleAction =
67
67
  | RunAction
68
68
  /** Bot shim: GET the container's /healthz — starts it if stopped, renews its activity timeout. */
69
69
  | { type: "healthz" }
70
- /** Resident Worker: one watchdog pass over every resident (re-arm dead refresh chains, time out stuck onboarding). */
70
+ /** Resident Worker: one watchdog pass over every resident (re-arm dead refresh chains, time out stuck
71
+ * onboarding, create the refresh instance for residents on the Workflow lifecycle). */
71
72
  | { type: "watchdog" };
72
73
 
73
74
  export interface ScheduleDef {
@@ -115,7 +116,7 @@ export const SCHEDULES: readonly ScheduleDef[] = [
115
116
  cron: "*/10 * * * *",
116
117
  worker: "resident",
117
118
  description:
118
- "Resident watchdog: re-arm dead refresh alarm chains (marking degraded(alarm-missed)) and time out stuck onboarding. Cadence must stay shorter than the resident SLEEP_AFTER (20m). Not a run.",
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
120
  action: { type: "watchdog" },
120
121
  },
121
122
  ];
@@ -375,6 +376,10 @@ export interface WatchdogSummary {
375
376
  action?: unknown;
376
377
  error?: string;
377
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). */
382
+ instance?: unknown;
378
383
  }>;
379
384
  }
380
385
 
@@ -89,6 +89,9 @@ export interface AttrDomain {
89
89
  // the Workers' own roots (item 25): resident.watchdog, state.alarm
90
90
  residents: number;
91
91
  swept: number;
92
+ /** resident.refresh as a Workflow instance: the engine's instance id, an
93
+ * identifier by the platform's own rule (never a repository name). */
94
+ instanceId: string;
92
95
  }
93
96
 
94
97
  export type SpanAttrKey = keyof AttrDomain;
@@ -109,6 +112,8 @@ const IDENTIFIER_KEYS: ReadonlySet<SpanAttrKey> = new Set<SpanAttrKey>([
109
112
  "model",
110
113
  ]);
111
114
  const IDENTIFIER_RE = /^[A-Za-z0-9_./:@+-]{1,64}$/;
115
+ /** A Workflow instance id: the platform's rule (`^[a-zA-Z0-9_][a-zA-Z0-9-_]*$`, at most 100). */
116
+ const INSTANCE_ID_RE = /^[a-zA-Z0-9_][a-zA-Z0-9-_]{0,99}$/;
112
117
 
113
118
  /** Validate one attrs bag: known keys, value in domain, identifiers sanitized.
114
119
  * Returns the offending keys (empty when valid); emitters and tests use it,
@@ -125,6 +130,7 @@ export function invalidAttrKeys(attrs: SpanAttrs): string[] {
125
130
  if (expected === "string") {
126
131
  if (typeof value !== "string") bad.push(key);
127
132
  else if (IDENTIFIER_KEYS.has(key as SpanAttrKey) && !IDENTIFIER_RE.test(value)) bad.push(key);
133
+ else if (key === "instanceId" && !INSTANCE_ID_RE.test(value)) bad.push(key);
128
134
  } else if (typeof value !== expected) {
129
135
  bad.push(key);
130
136
  } else if (typeof value === "number" && !Number.isFinite(value)) {
@@ -198,6 +204,7 @@ const ATTR_TYPE: Record<SpanAttrKey, "string" | "number" | "boolean"> = {
198
204
  abandonedRuns: "number",
199
205
  residents: "number",
200
206
  swept: "number",
207
+ instanceId: "string",
201
208
  };
202
209
 
203
210
  export const ATTR_KEYS: readonly SpanAttrKey[] = Object.keys(ATTR_TYPE) as SpanAttrKey[];
@@ -71,6 +71,22 @@ export function depsStagingPath(key: string, attempt: string, storeDir: string =
71
71
  return `${storeDir}/.staging-${key}-${attempt}`;
72
72
  }
73
73
 
74
+ /** Both private dirs of one attempt: what a taker sweeps when it finds the
75
+ * attempt's holder dead (its lease names the scratch tree; the staging dir
76
+ * is the same attempt's, so the finished node_modules a dead commit script
77
+ * was moving does not outlive it either). */
78
+ export function depsAttemptPaths(key: string, attempt: string, storeDir: string = DEPS_STORE_DIR): string[] {
79
+ return [depsScratchPath(attempt, storeDir), depsStagingPath(key, attempt, storeDir)];
80
+ }
81
+
82
+ /** The attempt a scratch path was minted for, or undefined for any other path. */
83
+ export function depsAttemptOfScratchPath(path: string, storeDir: string = DEPS_STORE_DIR): string | undefined {
84
+ const prefix = `${storeDir}/.scratch-`;
85
+ if (!path.startsWith(prefix)) return undefined;
86
+ const attempt = path.slice(prefix.length);
87
+ return attempt && !attempt.includes("/") ? attempt : undefined;
88
+ }
89
+
74
90
  export type DepsMaterializationPlan =
75
91
  { action: "hit" } | { action: "join" } | { action: "restore" } | { action: "install" };
76
92
 
@@ -0,0 +1,134 @@
1
+ /** The resident's incarnation id and the durable leases judged against it
2
+ * (docs/reference/specs/resident-repos.md item 22), kept pure and
3
+ * dependency-free so it is unit-testable from src/ and imported by the
4
+ * resident Worker like residentRefresh — the tested code IS the shipped code.
5
+ *
6
+ * Background: the mirror mutex was a promise chain in the Durable Object's
7
+ * memory, and so were the facts the watchdog read to tell a cycle in flight
8
+ * from a marker orphaned by a dead one (`refreshing` with nothing running).
9
+ * An isolate swap — a Worker deploy, a platform restart — drops both: the
10
+ * chain resolves as if nothing were held while the holder's process keeps
11
+ * writing into the tree, and the in-flight counters read zero the instant a
12
+ * cycle is killed, so the watchdog could only guess from a timestamp.
13
+ *
14
+ * Shape: an **incarnation id** is minted each time the object starts in a
15
+ * fresh isolate or its container runtime is replaced under it; nothing that
16
+ * incarnation held survives its end. A **lease** is a keyed document
17
+ * `{holder, incarnation, expiresAt, step}`: who holds what, from which
18
+ * incarnation, until when. One predicate judges every lease — dead when its
19
+ * incarnation is not the current one, or its expiry has passed — so a
20
+ * holder killed by a swap is taken over without waiting, and the expiry (the
21
+ * step's own budget) stays as the backstop for a holder that hung without a
22
+ * swap. The mirror mutex is one lease; the per-key dependency install is
23
+ * another; the in-flight row holds the two the watchdog reads. */
24
+
25
+ /** The mirror mutex row. */
26
+ export const MIRROR_MUTEX_KEY = "resident:mirrorMutex";
27
+ /** One key per in-flight fact the watchdog reads (the refresh cycle, the
28
+ * hydration), never one shared row: two chains inside one object interleave
29
+ * at any await, so a read-modify-write of a shared row can lose the other
30
+ * chain's write. A key per fact is a plain put and a plain delete. */
31
+ export const IN_FLIGHT_KEY_PREFIX = "resident:inFlight:";
32
+ export function inFlightKey(kind: keyof InFlightRow): string {
33
+ return `${IN_FLIGHT_KEY_PREFIX}${kind}`;
34
+ }
35
+ /** One lease per lockfile key while its store entry is being produced. */
36
+ export const DEPS_LEASE_KEY_PREFIX = "resident:depsLease:";
37
+
38
+ /** A `refreshing`/`restoring` marker older than this with nothing running is
39
+ * an orphan from an interrupted cycle; the watchdog normalizes it. Comfortably
40
+ * above the longest legitimate cycle. A hydration's lease expires here too:
41
+ * no restore approaches it (the hydrate's own cap is 25 minutes). */
42
+ export const STALE_MIDFLIGHT_MS = 30 * 60_000;
43
+
44
+ /** How long a refresh cycle's in-flight lease lives. Every step in a cycle is
45
+ * budgeted and the budgets sum below this, so a lease still held past it is
46
+ * a hung call into a container that was replaced under it — the one state the
47
+ * in-memory counter could never expose, because it stayed non-zero for as
48
+ * long as the promise never settled. Above the stale bound on purpose: a
49
+ * cycle that is merely slow must never read as dead. */
50
+ export const REFRESH_CYCLE_LEASE_MS = 2 * STALE_MIDFLIGHT_MS;
51
+
52
+ export interface Lease {
53
+ /** Unique per take (`<incarnation>:<sequence>`); what a release compares. */
54
+ holder: string;
55
+ incarnation: string;
56
+ /** When the holder's budget ends. A lease past it is dead whoever holds it. */
57
+ expiresAt: number;
58
+ /** The engine step the holder runs, for the operator's eyes. */
59
+ step: string;
60
+ /** The directory the holder's command writes into, when it has one: a taker
61
+ * that finds this lease dead sweeps the tree before it starts. */
62
+ tree?: string;
63
+ }
64
+
65
+ /** A random id per isolate start; the caller may pass its own source. */
66
+ export function mintIncarnationId(random: () => string = () => crypto.randomUUID()): string {
67
+ return random();
68
+ }
69
+
70
+ /** Dead when the holder's incarnation is gone or its budget has passed. */
71
+ export function leaseIsDead(lease: Lease, now: number, incarnation: string): boolean {
72
+ return lease.incarnation !== incarnation || lease.expiresAt < now;
73
+ }
74
+
75
+ export type MutexDecision =
76
+ /** Write `row`; `dead` is the lease taken over, if any (its tree may need a sweep). */
77
+ | { action: "take"; why: "free" | "holder-incarnation-gone" | "holder-expired"; row: Lease; dead: Lease | undefined }
78
+ /** A live holder of this incarnation: wait, at most `remainingMs`, then read again. */
79
+ | { action: "wait"; row: Lease; remainingMs: number };
80
+
81
+ /** Decide over the stored row whether `holder` may take the mutex now. Pure:
82
+ * the caller writes the returned row. */
83
+ export function takeMutex(
84
+ row: Lease | undefined,
85
+ now: number,
86
+ incarnation: string,
87
+ budgetMs: number,
88
+ step: string,
89
+ holder: string,
90
+ tree?: string,
91
+ ): MutexDecision {
92
+ const next: Lease = { holder, incarnation, expiresAt: now + budgetMs, step, ...(tree ? { tree } : {}) };
93
+ if (!row) return { action: "take", why: "free", row: next, dead: undefined };
94
+ if (row.incarnation !== incarnation) return { action: "take", why: "holder-incarnation-gone", row: next, dead: row };
95
+ if (row.expiresAt < now) return { action: "take", why: "holder-expired", row: next, dead: row };
96
+ return { action: "wait", row, remainingMs: row.expiresAt - now };
97
+ }
98
+
99
+ /** A holder releases only the row it took: a row another holder took over
100
+ * (this one was judged dead meanwhile) is left in place. */
101
+ export function releaseMutex(
102
+ row: Lease | undefined,
103
+ holder: string,
104
+ ): { released: true; row: undefined } | { released: false; row: Lease | undefined } {
105
+ if (row && row.holder === holder) return { released: true, row: undefined };
106
+ return { released: false, row };
107
+ }
108
+
109
+ export interface InFlightRow {
110
+ refresh: Lease | null;
111
+ hydration: Lease | null;
112
+ }
113
+
114
+ /** Assemble the row from the two documents `inFlightKey` names. */
115
+ export function inFlightRow(refresh: Lease | null | undefined, hydration: Lease | null | undefined): InFlightRow {
116
+ return { refresh: refresh ?? null, hydration: hydration ?? null };
117
+ }
118
+
119
+ /** Which in-flight facts are alive for the current incarnation right now. */
120
+ export function liveInFlight(
121
+ row: InFlightRow | undefined,
122
+ now: number,
123
+ incarnation: string,
124
+ ): { refresh: boolean; hydration: boolean } {
125
+ const live = (lease: Lease | null | undefined) => lease != null && !leaseIsDead(lease, now, incarnation);
126
+ return { refresh: live(row?.refresh), hydration: live(row?.hydration) };
127
+ }
128
+
129
+ const LOCKFILE_KEY_RE = /^[0-9a-f]{64}$/;
130
+
131
+ export function depsLeaseKey(key: string): string {
132
+ if (!LOCKFILE_KEY_RE.test(key)) throw new Error(`deps lease: not a lockfile key: ${JSON.stringify(key)}`);
133
+ return `${DEPS_LEASE_KEY_PREFIX}${key}`;
134
+ }
@@ -0,0 +1,177 @@
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
3
+ * (docs/reference/specs/resident-repos.md item 7), kept pure and
4
+ * dependency-free so it is unit-testable from src/ and imported by the
5
+ * resident Worker like residentRefresh — the tested code IS the shipped code.
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.
16
+ *
17
+ * Shape: a cycle is one SHORT instance, not a loop — the resident Worker's
18
+ * existing ten-minute cron creates one per eligible resident with a
19
+ * deterministic id per resident and ten-minute bucket, so a second firing in
20
+ * 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.) */
24
+
25
+ import { STALE_MIDFLIGHT_MS } from "./residentIncarnation.js";
26
+ import { nextRefreshDelayS } from "./residentRefresh.js";
27
+ import type { ResidentLifecycleState } from "./residentState.js";
28
+
29
+ // -- the flag ----------------------------------------------------------------
30
+
31
+ /** Which scheduler drives a resident's refresh cycle. */
32
+ export type ResidentLifecycle = "alarm" | "workflow";
33
+
34
+ /** The alarm chain is the default; a row without the field reads as `alarm`. */
35
+ export const DEFAULT_LIFECYCLE: ResidentLifecycle = "alarm";
36
+
37
+ export function parseLifecycle(value: unknown): ResidentLifecycle | undefined {
38
+ return value === "alarm" || value === "workflow" ? value : undefined;
39
+ }
40
+
41
+ /** What a stored row means: the two words, else the default. */
42
+ export function lifecycleOf(stored: unknown): ResidentLifecycle {
43
+ return parseLifecycle(stored) ?? DEFAULT_LIFECYCLE;
44
+ }
45
+
46
+ // -- the id ------------------------------------------------------------------
47
+
48
+ /** One bucket per cron firing: the resident cron fires every ten minutes. */
49
+ export const REFRESH_BUCKET_MS = 10 * 60_000;
50
+
51
+ /** The platform's instance id rule (Workflows limits: "Instance ID: 100
52
+ * characters", pattern `^[a-zA-Z0-9_][a-zA-Z0-9-_]*$`). */
53
+ export const INSTANCE_ID_MAX_LENGTH = 100;
54
+ export const INSTANCE_ID_PATTERN = /^[a-zA-Z0-9_][a-zA-Z0-9-_]*$/;
55
+
56
+ const ID_PREFIX = "refresh_";
57
+ const HASH_LENGTH = 8;
58
+
59
+ export function refreshBucket(atMs: number): number {
60
+ return Math.floor(atMs / REFRESH_BUCKET_MS);
61
+ }
62
+
63
+ /** Owner and name lower-cased, every character outside `[A-Za-z0-9_-]` mapped
64
+ * to `-`, joined by `-` (the `/` between them is one such character). */
65
+ export function instanceSlug(owner: string, name: string): string {
66
+ return `${owner}/${name}`.toLowerCase().replace(/[^a-z0-9_-]/g, "-");
67
+ }
68
+
69
+ /** FNV-1a over the slug, as 8 hex digits: enough to tell two long names apart
70
+ * once the id has to be truncated, and pure (no async digest). */
71
+ function shortHash(text: string): string {
72
+ let h = 0x811c9dc5;
73
+ for (let i = 0; i < text.length; i++) {
74
+ h ^= text.charCodeAt(i);
75
+ h = Math.imul(h, 0x01000193) >>> 0;
76
+ }
77
+ return h.toString(16).padStart(HASH_LENGTH, "0");
78
+ }
79
+
80
+ /** `refresh_<slug>_<bucket>`; when that would exceed the platform's length
81
+ * the slug is cut and a short hash of the whole slug is inserted before the
82
+ * bucket, so the id stays deterministic, legal and distinct per resident. */
83
+ export function refreshInstanceId(owner: string, name: string, atMs: number): string {
84
+ const slug = instanceSlug(owner, name);
85
+ const bucket = String(refreshBucket(atMs));
86
+ const plain = `${ID_PREFIX}${slug}_${bucket}`;
87
+ if (plain.length <= INSTANCE_ID_MAX_LENGTH) return plain;
88
+ const hash = shortHash(slug);
89
+ const keep = INSTANCE_ID_MAX_LENGTH - (ID_PREFIX.length + 1 + hash.length + 1 + bucket.length);
90
+ return `${ID_PREFIX}${slug.slice(0, keep)}_${hash}_${bucket}`;
91
+ }
92
+
93
+ // -- the cron's decision -----------------------------------------------------
94
+
95
+ /** What the cron reads about one resident before creating its instance. */
96
+ export interface RefreshRow {
97
+ lifecycle: ResidentLifecycle;
98
+ state: ResidentLifecycleState;
99
+ /** When the state last changed (epoch ms); null when the row never recorded one. */
100
+ updatedAt: number | null;
101
+ /** Set while the resident is idle (epoch ms), the way the facts record it. */
102
+ idleSince: number | null;
103
+ /** When the cron last created an instance for this resident (epoch ms). */
104
+ lastInstanceAt: number | null;
105
+ /** Whether the instance the row last recorded is still alive in the engine
106
+ * (queued, running, paused or waiting), read from the engine's own status.
107
+ * A step between retry attempts holds no lease and writes no state, so the
108
+ * marker's age alone would call a live cycle stale; the engine knows. */
109
+ instanceRunning: boolean;
110
+ }
111
+
112
+ export interface RefreshCadence {
113
+ /** The awake cadence (the alarm's REFRESH_INTERVAL_S). */
114
+ intervalS: number;
115
+ /** The idle cadence (the alarm's IDLE_REFRESH_INTERVAL_S). */
116
+ idleIntervalS: number;
117
+ }
118
+
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" };
134
+ if (row.state === "refreshing" && row.updatedAt !== null && nowMs - row.updatedAt <= STALE_MIDFLIGHT_MS) {
135
+ return { create: false, why: "mid-cycle" };
136
+ }
137
+ if (row.lastInstanceAt !== null) {
138
+ const delayS = nextRefreshDelayS({
139
+ outcome: row.idleSince !== null ? "idle" : "normal",
140
+ intervalS: cadence.intervalS,
141
+ idleIntervalS: cadence.idleIntervalS,
142
+ });
143
+ const dueBuckets = Math.max(1, Math.ceil((delayS * 1000) / REFRESH_BUCKET_MS));
144
+ if (refreshBucket(nowMs) - refreshBucket(row.lastInstanceAt) < dueBuckets) return { create: false, why: "not-due" };
145
+ }
146
+ return { create: true, why: "due" };
147
+ }
148
+
149
+ // -- the step policy ----------------------------------------------------------
150
+
151
+ /** Every step's retry policy: six attempts, thirty seconds apart, doubling.
152
+ * The delays between attempts sum to 930 s, about 15.5 minutes — longer than
153
+ * the 3 to 10 minutes a resident Worker rollover takes to settle, so a deploy
154
+ * mid-cycle costs the cycle retries inside one step, never a failed instance. */
155
+ export const REFRESH_STEP_RETRIES = { limit: 6, delay: "30 seconds", backoff: "exponential" } as const;
156
+
157
+ /** The sum of the delays between `limit` attempts under the policy: the
158
+ * window a step keeps retrying inside. `delay` is the "<n> seconds" form the
159
+ * engine accepts. */
160
+ export function retryWindowMs(policy: { limit: number; delay: string; backoff: "exponential" | "constant" }): number {
161
+ const m = /^(\d+) seconds?$/.exec(policy.delay);
162
+ if (!m) throw new Error(`retry delay must be "<n> seconds", got ${JSON.stringify(policy.delay)}`);
163
+ const first = Number(m[1]) * 1000;
164
+ let total = 0;
165
+ for (let i = 0; i < policy.limit - 1; i++) total += policy.backoff === "exponential" ? first * 2 ** i : first;
166
+ return total;
167
+ }
168
+
169
+ /** The engine's ceiling on a step's configurable timeout ("30 minutes or
170
+ * less"); a step budget is the DO method's own, and never above this. */
171
+ export const STEP_TIMEOUT_MAX_MS = 30 * 60_000;
172
+
173
+ /** A step's timeout in ms: the method's budget, capped at the platform's. */
174
+ export function stepTimeoutMs(budgetMs: number): number {
175
+ if (!(budgetMs > 0)) throw new Error(`step budget must be positive, got ${budgetMs}`);
176
+ return Math.min(budgetMs, STEP_TIMEOUT_MAX_MS);
177
+ }