@coreplane/switchboard 1.19.2 → 1.200.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/config/config.example.yaml +9 -0
- package/dist/assets/deploy/cloudflare/package.json +1 -1
- package/dist/assets/deploy/cloudflare-resident/package.json +1 -1
- package/dist/assets/deploy/cloudflare-resident/worker.ts +1663 -456
- package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +12 -0
- package/dist/assets/deploy/cloudflare-sandbox/package.json +1 -1
- package/dist/assets/package-lock.json +11 -22
- package/dist/assets/package.json +1 -1
- package/dist/assets/project.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/core/schedules.ts +7 -2
- package/dist/assets/src/core/trace/attrs.ts +7 -0
- package/dist/assets/src/execution/residentDepsStore.ts +16 -0
- package/dist/assets/src/execution/residentIncarnation.ts +134 -0
- package/dist/assets/src/execution/residentInstanceId.ts +177 -0
- package/dist/assets/src/execution/residentStepPlan.ts +128 -0
- package/dist/assets/web/dist/.vite/manifest.json +18 -18
- package/dist/assets/web/dist/assets/CostsPage-CzutIHUw.js +1 -0
- package/dist/assets/web/dist/assets/{ResidentDetailPage-DvQ05AGa.js → ResidentDetailPage-Dw3e9u1u.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-B3uxKUne.js → ResidentsIndexPage-D8NN4AFS.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-ty94olNM.js → RunRoutePage-C1TGcPCl.js} +1 -1
- package/dist/assets/web/dist/assets/{RunsIndexPage-CM-qxyQm.js → RunsIndexPage-D9iLpfAr.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-C1psvLD4.js → ScheduledPage-BJCtMp7H.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-DuoQnQeU.js → StatusDot-DvV0z_-s.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-BfLPyxQy.js → Tooltip-C7yR095Y.js} +1 -1
- package/dist/assets/web/dist/assets/{main-Bnbk_Rsg.js → main-DDu3Ig6S.js} +2 -2
- package/dist/cli.js +66 -9
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +0 -1
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "1.200.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.200.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/
|
|
3761
|
-
"@fontsource-variable/
|
|
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/
|
|
5051
|
+
"node_modules/@fontsource-variable/instrument-sans": {
|
|
5053
5052
|
"version": "5.3.0",
|
|
5054
|
-
"resolved": "https://registry.npmjs.org/@fontsource-variable/
|
|
5055
|
-
"integrity": "sha512-
|
|
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/
|
|
5061
|
+
"node_modules/@fontsource-variable/jetbrains-mono": {
|
|
5063
5062
|
"version": "5.3.0",
|
|
5064
|
-
"resolved": "https://registry.npmjs.org/@fontsource-variable/
|
|
5065
|
-
"integrity": "sha512-
|
|
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.
|
|
18332
|
+
"version": "1.200.0",
|
|
18344
18333
|
"license": "Apache-2.0",
|
|
18345
18334
|
"dependencies": {
|
|
18346
18335
|
"@anthropic-ai/sdk": "^0.124.0",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.200.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",
|
package/dist/assets/project.json
CHANGED
|
@@ -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
|
|
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`.",
|
package/dist/assets/source.json
CHANGED
|
@@ -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
|
|
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))
|
|
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
|
+
}
|