@coreplane/switchboard 0.0.0 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +17 -1
  3. package/dist/assets/.dockerignore +27 -0
  4. package/dist/assets/.env.example +33 -0
  5. package/dist/assets/Dockerfile +111 -0
  6. package/dist/assets/config/config.example.yaml +359 -0
  7. package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
  8. package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
  9. package/dist/assets/deploy/bin/cf-logs +32 -0
  10. package/dist/assets/deploy/cloudflare/package.json +29 -0
  11. package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
  12. package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
  13. package/dist/assets/deploy/cloudflare/worker.ts +382 -0
  14. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
  15. package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
  16. package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
  17. package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
  18. package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
  19. package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
  20. package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
  21. package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
  22. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
  23. package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
  24. package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
  25. package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
  26. package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
  27. package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
  28. package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
  29. package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
  30. package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
  31. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
  32. package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
  33. package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
  34. package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
  35. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
  36. package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
  37. package/dist/assets/deploy/profile.example.json +13 -0
  38. package/dist/assets/deploy/secrets.manifest.json +108 -0
  39. package/dist/assets/docker-entrypoint.sh +15 -0
  40. package/dist/assets/package-lock.json +18407 -0
  41. package/dist/assets/package.json +104 -0
  42. package/dist/assets/project.json +219 -0
  43. package/dist/assets/source.json +5 -0
  44. package/dist/assets/src/core/authz/actor.ts +100 -0
  45. package/dist/assets/src/core/authz/authorize.ts +169 -0
  46. package/dist/assets/src/core/authz/grants.ts +347 -0
  47. package/dist/assets/src/core/authz/policy.ts +281 -0
  48. package/dist/assets/src/core/authz/resource.ts +147 -0
  49. package/dist/assets/src/core/authz/types.ts +164 -0
  50. package/dist/assets/src/core/drain.ts +54 -0
  51. package/dist/assets/src/core/ingressTokens.ts +64 -0
  52. package/dist/assets/src/core/memory/engine.ts +115 -0
  53. package/dist/assets/src/core/memory/scorer.ts +147 -0
  54. package/dist/assets/src/core/memory/types.ts +120 -0
  55. package/dist/assets/src/core/normalizeSpans.ts +299 -0
  56. package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
  57. package/dist/assets/src/core/redact.ts +113 -0
  58. package/dist/assets/src/core/runEvents.ts +537 -0
  59. package/dist/assets/src/core/runFriction.ts +665 -0
  60. package/dist/assets/src/core/runLedger/decisions.ts +126 -0
  61. package/dist/assets/src/core/runLedger/types.ts +177 -0
  62. package/dist/assets/src/core/runRecord.ts +627 -0
  63. package/dist/assets/src/core/runShape.ts +61 -0
  64. package/dist/assets/src/core/schedules.ts +452 -0
  65. package/dist/assets/src/core/time/formatDuration.ts +61 -0
  66. package/dist/assets/src/core/trace/attrs.ts +203 -0
  67. package/dist/assets/src/core/trace/classify.ts +49 -0
  68. package/dist/assets/src/core/trace/clock.ts +6 -0
  69. package/dist/assets/src/core/trace/context.ts +9 -0
  70. package/dist/assets/src/core/trace/ids.ts +23 -0
  71. package/dist/assets/src/core/trace/partition.ts +235 -0
  72. package/dist/assets/src/core/trace/sinks.ts +68 -0
  73. package/dist/assets/src/core/trace/streamSpans.ts +163 -0
  74. package/dist/assets/src/core/trace/traceparent.ts +29 -0
  75. package/dist/assets/src/core/trace/tracer.ts +247 -0
  76. package/dist/assets/src/core/trace/types.ts +125 -0
  77. package/dist/assets/src/core/trace/workerTrace.ts +97 -0
  78. package/dist/assets/src/deploy/buildStamp.ts +93 -0
  79. package/dist/assets/src/deploy/liveGate.ts +203 -0
  80. package/dist/assets/src/deploy/profile.ts +162 -0
  81. package/dist/assets/src/deploy/restart.ts +393 -0
  82. package/dist/assets/src/effort.ts +17 -0
  83. package/dist/assets/src/execution/bashTimeout.ts +78 -0
  84. package/dist/assets/src/execution/bindingPurge.ts +43 -0
  85. package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
  86. package/dist/assets/src/execution/residentCleanliness.ts +95 -0
  87. package/dist/assets/src/execution/residentCredentials.ts +81 -0
  88. package/dist/assets/src/execution/residentDepCache.ts +321 -0
  89. package/dist/assets/src/execution/residentDepsStore.ts +326 -0
  90. package/dist/assets/src/execution/residentDetach.ts +48 -0
  91. package/dist/assets/src/execution/residentDisk.ts +107 -0
  92. package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
  93. package/dist/assets/src/execution/residentExecWrap.ts +100 -0
  94. package/dist/assets/src/execution/residentHead.ts +85 -0
  95. package/dist/assets/src/execution/residentReadonly.ts +72 -0
  96. package/dist/assets/src/execution/residentRefresh.ts +429 -0
  97. package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
  98. package/dist/assets/src/execution/residentState.ts +47 -0
  99. package/dist/assets/src/execution/residentStepReport.ts +98 -0
  100. package/dist/assets/src/execution/residentStepTrace.ts +97 -0
  101. package/dist/assets/src/execution/residentSteps.ts +99 -0
  102. package/dist/assets/src/execution/residentText.ts +83 -0
  103. package/dist/assets/src/execution/residentTrace.ts +119 -0
  104. package/dist/assets/src/execution/sandboxEnv.ts +42 -0
  105. package/dist/assets/src/execution/sandboxErrors.ts +159 -0
  106. package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
  107. package/dist/assets/src/execution/shellQuote.ts +8 -0
  108. package/dist/assets/src/mcp/registry.ts +242 -0
  109. package/dist/assets/src/providers/types.ts +152 -0
  110. package/dist/assets/web/dist/.vite/manifest.json +176 -0
  111. package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
  112. package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
  113. package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
  114. package/dist/assets/web/dist/assets/ResidentDetailPage-DvQ05AGa.js +1 -0
  115. package/dist/assets/web/dist/assets/ResidentsIndexPage-B3uxKUne.js +1 -0
  116. package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
  117. package/dist/assets/web/dist/assets/RunRoutePage-ty94olNM.js +126 -0
  118. package/dist/assets/web/dist/assets/RunsIndexPage-CM-qxyQm.js +1 -0
  119. package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
  120. package/dist/assets/web/dist/assets/ScheduledPage-C1psvLD4.js +1 -0
  121. package/dist/assets/web/dist/assets/StatusDot-DuoQnQeU.js +1 -0
  122. package/dist/assets/web/dist/assets/Tooltip-BfLPyxQy.js +1 -0
  123. package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
  124. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
  125. package/dist/assets/web/dist/assets/main-Bnbk_Rsg.js +28 -0
  126. package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
  127. package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
  128. package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
  129. package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
  130. package/dist/cli.js +34494 -0
  131. package/package.json +43 -10
@@ -0,0 +1,93 @@
1
+ // What commit a Worker script is running (docs/reference/specs/execution.md item 13).
2
+ //
3
+ // The bot has always known this: `npm run deploy` writes `build.json`, the
4
+ // image COPYs it, and `/healthz` serves `build: {commit, builtAt}` — which is
5
+ // how `deploy all`'s live gate tells "deployed" from "live". The three Worker
6
+ // SCRIPTS (resident, memory, sandbox) had no equivalent. The resident instead
7
+ // carried a hand-edited `const BUILD_MARKER = "perf53"` whose comment said
8
+ // "bump on every deploy-worthy change"; a marker bumped by hand goes unbumped
9
+ // the first busy week. That costs twice over: a deploy cannot be proven from
10
+ // outside (its receipt has to be assembled from `wrangler versions list` plus a
11
+ // container digest), and the test-override guard rail that expires an override
12
+ // when the build changes (resident-repos item 49(c), `ignored:"stale-build …"`)
13
+ // silently stops expiring anything.
14
+ //
15
+ // A file cannot carry the stamp into a Worker script the way it does into the
16
+ // bot's image: it would have to be either committed (so a deploy dirties the
17
+ // tree, which `deploy all` then refuses) or gitignored (so `tsc` and a fresh
18
+ // clone fail on the missing import). So the deploy SUBSTITUTES it —
19
+ // `wrangler deploy --define` (see `deploy/bin/build-stamp.mjs`) — and this
20
+ // module is the one place that reads it.
21
+ //
22
+ // `--define` and not `--var`: a CLI `--var` may replace the `vars` block a
23
+ // Worker's wrangler.jsonc declares, and the resident's `STATE_WORKER_URL` var
24
+ // is load-bearing (the watchdog records firings through it). A bundle-time
25
+ // substitution cannot touch bindings.
26
+
27
+ /** What a Worker reports as `build` on `/healthz`. Shaped like the bot's
28
+ * `BuildInfo` (`src/channels/health.ts`) so both read the same to an
29
+ * operator and to `src/deploy/liveGate.ts`. */
30
+ export interface BuildStamp {
31
+ commit: string;
32
+ builtAt?: string;
33
+ }
34
+
35
+ /** The commit of a Worker nobody stamped — a bare `wrangler deploy`, `wrangler
36
+ * dev`, or a test process. Never silently absent: an operator must be able to
37
+ * tell "built without a stamp" from "built at this commit". */
38
+ export const UNKNOWN_COMMIT = "unknown";
39
+
40
+ /** The environment variable a deploy with no tree to read (the published package, src/deploy/run.ts) hands
41
+ * the stamp scripts the commit through — the same name as the `--define` identifier below, so the two scripts
42
+ * (`deploy/bin/build-stamp.mjs`, `deploy/cloudflare/write-build.mjs`) and the runner agree by construction.
43
+ * Spelled here, not imported from the scripts: the bot image carries `src/` and not `deploy/`. */
44
+ export const BUILD_COMMIT_ENV = "SWITCHBOARD_BUILD_COMMIT";
45
+
46
+ // The identifiers `deploy/bin/build-stamp.mjs` substitutes. They are declared,
47
+ // never defined: with no `--define` they do not exist at runtime at all, which
48
+ // is why every read below goes through `typeof` first (see injectedBuildStamp).
49
+ declare const SWITCHBOARD_BUILD_COMMIT: string;
50
+ declare const SWITCHBOARD_BUILT_AT: string;
51
+
52
+ /** Pure: the stamp for whatever the deploy injected. A commit that is missing,
53
+ * blank, or not a string becomes `unknown`, and `builtAt` is dropped rather
54
+ * than guessed — a made-up build time is worse than none. */
55
+ export function resolveBuildStamp(commit: unknown, builtAt: unknown): BuildStamp {
56
+ const trimmed = typeof commit === "string" ? commit.trim() : "";
57
+ const at = typeof builtAt === "string" ? builtAt.trim() : "";
58
+ return { commit: trimmed === "" ? UNKNOWN_COMMIT : trimmed, ...(at === "" ? {} : { builtAt: at }) };
59
+ }
60
+
61
+ /** The identity a stored artifact compares itself against — the commit, plus
62
+ * the build's own timestamp when there is one.
63
+ *
64
+ * Not the commit alone: two builds of the same DIRTY tree carry the same
65
+ * `<sha>-dirty` commit, and a redeploy of the same clean commit is still a
66
+ * later build. The resident's test overrides (`gc.ts` `effectiveLimits`,
67
+ * resident-repos item 49(c)) are expired by "a different build wrote this",
68
+ * so a coarser identity leaves a forgotten admin cap alive across exactly the
69
+ * deploys that were meant to clear it. `builtAt` is injected once per deploy,
70
+ * so every isolate of one deployed version agrees — an override set through
71
+ * one isolate is still honored by its siblings.
72
+ *
73
+ * An UN-stamped bundle (`wrangler dev`, a bare `wrangler deploy`) therefore
74
+ * shares one id across all of its builds, and an override does not expire
75
+ * between them. That is deliberate, not an oversight: with nothing injected
76
+ * there is no value that differs per build yet is stable per deployment —
77
+ * anything generated at module load would differ per ISOLATE, so overrides
78
+ * would vanish between siblings of one deployment, which is the worse
79
+ * failure. Stamped deploys are the case the guard rail exists for. */
80
+ export function buildId(stamp: BuildStamp): string {
81
+ return stamp.builtAt === undefined ? stamp.commit : `${stamp.commit}@${stamp.builtAt}`;
82
+ }
83
+
84
+ /** The stamp this bundle was built with. Reading an undeclared identifier is a
85
+ * ReferenceError, and `typeof` is the one operator that tolerates one — so it
86
+ * gates both reads, and an un-stamped bundle answers `unknown` instead of
87
+ * throwing inside `/healthz`. */
88
+ export function injectedBuildStamp(): BuildStamp {
89
+ return resolveBuildStamp(
90
+ typeof SWITCHBOARD_BUILD_COMMIT === "string" ? SWITCHBOARD_BUILD_COMMIT : null,
91
+ typeof SWITCHBOARD_BUILT_AT === "string" ? SWITCHBOARD_BUILT_AT : null,
92
+ );
93
+ }
@@ -0,0 +1,203 @@
1
+ import { COLD_START_ALLOWANCE_MS, DRAIN_DEADLINE_MS } from "../core/drain.js";
2
+
3
+ // "Deployed" is not "live". `wrangler deploy` uploads a Worker version and
4
+ // starts a container rollout, but the OLD bot container keeps serving while it
5
+ // drains in-flight runs (up to DRAIN_DEADLINE_MS) — a `deploy:all` that trusted
6
+ // the upload printed `bot … deployed`, exited 0, and the old container was
7
+ // still draining (docs/decisions/0015-deploy-order-deployed-is-not-live.md).
8
+ // This module is the pure half of the live gate
9
+ // `deploy:all` runs after the bot step: read `/healthz`, decide whether the NEW
10
+ // container — identified by the commit baked into its image (`build.commit`,
11
+ // src/channels/health.ts) — is the one answering, and say why not otherwise.
12
+ // No node:* imports; the CLI does the fetching and the clock.
13
+
14
+ /** What the gate needs from a `/healthz` body (src/channels/health.ts `HealthPayload`). */
15
+ export interface HealthzBody {
16
+ ok?: unknown;
17
+ inFlight?: unknown;
18
+ draining?: unknown;
19
+ drainStartedAt?: unknown;
20
+ build?: { commit?: unknown; builtAt?: unknown } | unknown;
21
+ /** ISO process start — `deploy restart`'s identity (the image, hence `build.commit`, is unchanged). */
22
+ startedAt?: unknown;
23
+ }
24
+
25
+ /** Parse a `/healthz` response body; undefined when it is not a JSON object
26
+ * (a container mid-restart answers nothing, a pre-item-8 Worker answers `ok`). */
27
+ export function parseHealthz(text: string): HealthzBody | undefined {
28
+ try {
29
+ const parsed: unknown = JSON.parse(text);
30
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
31
+ ? (parsed as HealthzBody)
32
+ : undefined;
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ /** How long the gate waits for the new container: the drain deadline the old
39
+ * one may use in full, plus the same cold-start allowance the reconnect
40
+ * catch-up budgets (`COLD_START_ALLOWANCE_MS`, item 7) — one number for "how
41
+ * long until the replacement is up", so the gate cannot time out a container
42
+ * the catch-up still expects to arrive. */
43
+ export const LIVE_GATE_DEADLINE_MS = DRAIN_DEADLINE_MS + COLD_START_ALLOWANCE_MS;
44
+ /** `/healthz` poll interval while waiting to go live. */
45
+ export const LIVE_GATE_POLL_MS = 15_000;
46
+
47
+ /** One poll's verdict: `live` carrying the identity that proved it, or the
48
+ * reason an operator would want to read — `waiting` until the deadline,
49
+ * `timeout` after it (the CLI exits non-zero: never report success when not live). */
50
+ export type ReadyDecision<Identity> =
51
+ ({ kind: "live" } & Identity) | { kind: "waiting"; reason: string } | { kind: "timeout"; reason: string };
52
+
53
+ export type LiveDecision = ReadyDecision<{ commit: string }>;
54
+ export type RestartDecision = ReadyDecision<{ startedAt: string }>;
55
+
56
+ function servedCommit(body: HealthzBody): string | undefined {
57
+ const b = body.build;
58
+ if (typeof b !== "object" || b === null) return undefined;
59
+ const c = (b as { commit?: unknown }).commit;
60
+ return typeof c === "string" && c !== "" ? c : undefined;
61
+ }
62
+
63
+ /** The part of a live decision every gate shares: a non-JSON body is never
64
+ * live; a JSON body is judged by `identify` — the identity that proves the NEW
65
+ * container is answering, or the reason it is not. A draining body is judged
66
+ * the same way (run-history item 39: a container that already serves the
67
+ * deployed identity IS live, whatever is rolling it next); when the identity
68
+ * is the old one, the reason names the drain — the more useful fact — unless
69
+ * `identify` already judged the same identity draining (`sameIdentity`: a
70
+ * same-commit rollout, decided by `startedAt`) and said why. Past
71
+ * `deadlineMs` the reason becomes a timeout. */
72
+ function decideReady<Identity>(
73
+ body: HealthzBody | undefined,
74
+ elapsedMs: number,
75
+ deadlineMs: number,
76
+ identify: (
77
+ body: HealthzBody,
78
+ ) => { live: true; identity: Identity } | { live: false; reason: string; sameIdentity?: true },
79
+ ): ReadyDecision<Identity> {
80
+ let reason: string;
81
+ if (!body) {
82
+ reason = "/healthz not answering with JSON (container restarting, or unreachable)";
83
+ } else {
84
+ const verdict = identify(body);
85
+ if (verdict.live) return { kind: "live", ...verdict.identity };
86
+ if (body.draining === true && !verdict.sameIdentity) {
87
+ const n = typeof body.inFlight === "number" ? body.inFlight : "?";
88
+ const since = typeof body.drainStartedAt === "string" ? ` since ${body.drainStartedAt}` : "";
89
+ reason = `old container still draining — ${n} run(s) in flight${since}`;
90
+ } else reason = verdict.reason;
91
+ }
92
+ return elapsedMs >= deadlineMs ? { kind: "timeout", reason } : { kind: "waiting", reason };
93
+ }
94
+
95
+ /** Two commit identities name the same commit when one is a prefix of the other
96
+ * (≥7 chars) — `git rev-parse HEAD` vs. a short form; a `-dirty` suffix never matches. */
97
+ export function sameCommit(a: string, b: string): boolean {
98
+ const x = a.trim();
99
+ const y = b.trim();
100
+ if (x.length < 7 || y.length < 7) return false;
101
+ if (x.endsWith("-dirty") || y.endsWith("-dirty")) return false;
102
+ return x.startsWith(y) || y.startsWith(x);
103
+ }
104
+
105
+ /**
106
+ * The decision for one poll. `live` only when the body is JSON and carries the
107
+ * expected commit — and, when that container is DRAINING, only if it is provably
108
+ * the new one: a rollout of the SAME commit (a Worker recovered with
109
+ * `--only bot`, a forced `all` with no code change) drains an old container
110
+ * that serves the deployed commit too, so the commit alone would call it live the
111
+ * moment SIGTERM landed. The runner reads `startedAt` before the upload
112
+ * (`opts.previousStartedAt`) and a draining same-commit container counts only
113
+ * with a later one; without a pre-upload reading, a draining same-commit
114
+ * container waits. Everything else is `waiting` with the reason an operator
115
+ * would want to read — until `elapsedMs` reaches `deadlineMs`, when the same
116
+ * reason becomes a `timeout` (the CLI exits non-zero: never report success when
117
+ * not live).
118
+ */
119
+ export function decideLive(
120
+ body: HealthzBody | undefined,
121
+ expectedCommit: string,
122
+ elapsedMs: number,
123
+ deadlineMs: number = LIVE_GATE_DEADLINE_MS,
124
+ opts: { previousStartedAt?: string } = {},
125
+ ): LiveDecision {
126
+ return decideReady(body, elapsedMs, deadlineMs, (b) => {
127
+ const commit = servedCommit(b);
128
+ if (!commit)
129
+ return {
130
+ live: false,
131
+ reason: "/healthz carries no build identity — a container that predates the live gate is answering",
132
+ };
133
+ if (commit === "unknown")
134
+ return { live: false, reason: 'serving a build with commit "unknown" (image built without build.json)' };
135
+ if (!sameCommit(commit, expectedCommit))
136
+ return {
137
+ live: false,
138
+ reason: `serving commit ${commit.slice(0, 7)}, expected ${expectedCommit.slice(0, 7)} (old container still up)`,
139
+ };
140
+ if (b.draining === true) {
141
+ const started = servedStartedAt(b);
142
+ const newer =
143
+ started !== undefined &&
144
+ opts.previousStartedAt !== undefined &&
145
+ Date.parse(started) > Date.parse(opts.previousStartedAt);
146
+ if (!newer)
147
+ return {
148
+ live: false,
149
+ sameIdentity: true,
150
+ reason: `the deployed commit answers but is draining${started ? ` (started ${started})` : ""} — a same-commit rollout replaces it; waiting for the new container`,
151
+ };
152
+ }
153
+ return { live: true, identity: { commit } };
154
+ });
155
+ }
156
+
157
+ /** A parseable ISO `startedAt` from a body, else undefined. */
158
+ export function servedStartedAt(body: HealthzBody): string | undefined {
159
+ const s = body.startedAt;
160
+ return typeof s === "string" && Number.isFinite(Date.parse(s)) ? s : undefined;
161
+ }
162
+
163
+ /**
164
+ * The live decision after `deploy restart`: the image is unchanged, so the
165
+ * restarted container is recognised by a `startedAt` LATER than
166
+ * `previousStartedAt` (what `/healthz` said before the restart was requested).
167
+ * With no previous value (the old container predated `startedAt`), any
168
+ * non-draining container that reports one counts. Same waiting/timeout
169
+ * vocabulary as `decideLive`.
170
+ */
171
+ export function decideRestarted(
172
+ body: HealthzBody | undefined,
173
+ previousStartedAt: string | undefined,
174
+ elapsedMs: number,
175
+ deadlineMs: number = LIVE_GATE_DEADLINE_MS,
176
+ ): RestartDecision {
177
+ return decideReady(body, elapsedMs, deadlineMs, (b) => {
178
+ const startedAt = servedStartedAt(b);
179
+ if (!startedAt)
180
+ return {
181
+ live: false,
182
+ reason: "/healthz carries no startedAt — a container that predates `deploy restart` is answering",
183
+ };
184
+ if (previousStartedAt !== undefined && Date.parse(startedAt) <= Date.parse(previousStartedAt))
185
+ return { live: false, reason: `old container still answering (started ${startedAt})` };
186
+ return { live: true, identity: { startedAt } };
187
+ });
188
+ }
189
+
190
+ /** The line printed on every preflight retry, so a long wait is never silent.
191
+ * `tag` names the command waiting (`deploy:all`, `deploy:restart`). */
192
+ export function heartbeatLine(
193
+ step: string,
194
+ body: HealthzBody | undefined,
195
+ elapsedMs: number,
196
+ waitMaxMs: number,
197
+ tag = "deploy:all",
198
+ ): string {
199
+ const waited = `waited ${Math.floor(elapsedMs / 60_000)}m of ${Math.round(waitMaxMs / 60_000)}m`;
200
+ if (!body) return `[${tag}] ${step}: still waiting — /healthz not answering, ${waited}`;
201
+ const n = typeof body.inFlight === "number" ? body.inFlight : "?";
202
+ return `[${tag}] ${step}: still waiting — ${n} run(s) in flight (draining: ${body.draining === true ? "yes" : "no"}), ${waited}`;
203
+ }
@@ -0,0 +1,162 @@
1
+ // The deployment profile: everything about WHERE an installation runs that the
2
+ // code must not know. Cloudflare account, the zone the Workers sit under, each
3
+ // Worker's script name and hostname, where the runtime config comes from, and
4
+ // where secrets come from. The product is installed, not forked: this file is
5
+ // the operator's, the code reads it, and nothing under src/ or deploy/ names
6
+ // an account or a hostname of its own.
7
+ //
8
+ // Two files: `deploy/profile.json` (an installation's own; today the
9
+ // repository's own production values, gitignored once they move to the
10
+ // infrastructure repo) and `deploy/profile.example.json` (checked in, the
11
+ // shape with placeholders). `deploy plan` falls back to the example so the
12
+ // plan can be read anywhere — CI on a pull request, a fresh clone — but the
13
+ // plan says so, and `deploy all` refuses a plan computed from the example: a
14
+ // placeholder account is not a place to deploy to.
15
+ //
16
+ // Pure: parsing, validation, and the URLs derived from the profile. Reading
17
+ // the file is the runner's job (src/deploy/run.ts).
18
+
19
+ import { z } from "zod";
20
+
21
+ /** The Workers an installation may run, in deploy order. The project's docs
22
+ * site (deploy/cloudflare-docs/) is not one of them: it is the project's
23
+ * website, deployed by the project's own CI from project.json's facts, never
24
+ * a copy an installation runs (src/deploy/wranglerTemplate.ts `siteView`). */
25
+ export const WORKER_KINDS = ["memory", "bot", "resident", "sandbox"] as const;
26
+ export type WorkerKind = (typeof WORKER_KINDS)[number];
27
+
28
+ export const PROFILE_PATH = "deploy/profile.json";
29
+ export const PROFILE_EXAMPLE_PATH = "deploy/profile.example.json";
30
+ /** Overrides the profile's location — a second installation's profile, a test fixture. */
31
+ export const PROFILE_ENV = "SWITCHBOARD_DEPLOY_PROFILE";
32
+
33
+ const hostname = z
34
+ .string()
35
+ .regex(
36
+ /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/,
37
+ "a bare DNS hostname, no scheme, no path",
38
+ );
39
+
40
+ const endpoint = z.object({
41
+ /** The Cloudflare Worker script name (what `wrangler deployments list` shows). */
42
+ script: z.string().regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, "a Worker script name: lowercase, digits, hyphens"),
43
+ /** The hostname the Worker's custom domain route serves. */
44
+ hostname,
45
+ /** The zone that hostname lives in, when it is not the profile's `zone` — one
46
+ * Worker on a second domain the account also owns. Must be a zone in the
47
+ * same account. */
48
+ zone: hostname.optional(),
49
+ });
50
+
51
+ /** `path` | `github://owner/repo/path@ref` | `op://Vault/Item/field` — parsed by src/deploy/configSource.ts. */
52
+ const source = z.string().min(1);
53
+
54
+ /** Where a Worker's container image comes from (src/deploy/images.ts): `build` —
55
+ * each Worker's `image` is its Dockerfile and wrangler builds it at deploy time
56
+ * (a checkout; this project's own production); `registry` — the release's
57
+ * published images, copied into the account registry by `deploy all` (or
58
+ * `deploy images` ahead of it) and referenced as
59
+ * `registry.cloudflare.com/<account>/<name>:<version>`. */
60
+ export const IMAGE_MODES = ["build", "registry"] as const;
61
+ export type ImageMode = (typeof IMAGE_MODES)[number];
62
+
63
+ export const profileSchema = z.object({
64
+ /** The Cloudflare account every Worker deploys to (32 hex characters). */
65
+ account: z.string().regex(/^[0-9a-f]{32}$/, "a Cloudflare account id: 32 hex characters"),
66
+ /** The zone the hostnames live under; every hostname must be in it unless its
67
+ * Worker names its own `zone`. */
68
+ zone: hostname,
69
+ /** The Workers this installation runs. The bot is the one every installation
70
+ * has; the state Worker (memory), the resident and the sandbox are optional —
71
+ * a profile without one has no step for it (`deploy plan` iterates what is
72
+ * here) and no URL derived for it. */
73
+ workers: z.object({
74
+ memory: endpoint.optional(),
75
+ bot: endpoint,
76
+ resident: endpoint.optional(),
77
+ sandbox: endpoint.optional(),
78
+ }),
79
+ /** Where the bot's runtime config comes from at deploy time; `deploy all`
80
+ * materializes it into the image's build context. */
81
+ configSource: source,
82
+ /** Where `secrets put` reads values from: a directory of `<NAME>` files, or
83
+ * `op://Vault/Item` with the secret's name as the field. Optional: the
84
+ * secrets tooling has its own default directory. */
85
+ secretsSource: source.optional(),
86
+ /** How the bot, resident and sandbox images reach wrangler (`IMAGE_MODES`).
87
+ * Absent means `build` — the checkout deploys what it builds. */
88
+ images: z.enum(IMAGE_MODES).default("build"),
89
+ /** The Cloudflare Access application in front of the bot's dashboards, when
90
+ * there is one: the team domain the JWT is issued by and the app's AUD. */
91
+ access: z.object({ teamDomain: hostname, aud: z.string().regex(/^[0-9a-f]{64}$/) }).optional(),
92
+ });
93
+
94
+ export type DeploymentProfile = z.infer<typeof profileSchema>;
95
+
96
+ /** Pure: a parsed, validated profile, or the problems that make it unusable —
97
+ * each naming the field, never the value. A hostname outside the zone is a
98
+ * problem: the custom-domain route would be for a zone the account does not own. */
99
+ export function parseProfile(
100
+ raw: unknown,
101
+ ): { ok: true; profile: DeploymentProfile } | { ok: false; problems: string[] } {
102
+ const parsed = profileSchema.safeParse(raw);
103
+ if (!parsed.success) {
104
+ return { ok: false, problems: parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`) };
105
+ }
106
+ const p = parsed.data;
107
+ const problems: string[] = [];
108
+ for (const [kind, ep] of Object.entries(p.workers)) {
109
+ if (!ep) continue;
110
+ const zone = ep.zone ?? p.zone;
111
+ if (ep.hostname !== zone && !ep.hostname.endsWith(`.${zone}`))
112
+ problems.push(`workers.${kind}.hostname: not under zone ${zone}`);
113
+ }
114
+ const scripts = Object.values(p.workers)
115
+ .filter((e): e is z.infer<typeof endpoint> => !!e)
116
+ .map((e) => e.script);
117
+ if (new Set(scripts).size !== scripts.length) problems.push("workers: two Workers share a script name");
118
+ return problems.length > 0 ? { ok: false, problems } : { ok: true, profile: p };
119
+ }
120
+
121
+ /** Where the profile came from — `deploy all` refuses the example. */
122
+ export type ProfileOrigin = "profile" | "example";
123
+
124
+ export interface LoadedProfile {
125
+ profile: DeploymentProfile;
126
+ origin: ProfileOrigin;
127
+ /** The path that was read, repo-relative or as the env var gave it. */
128
+ path: string;
129
+ }
130
+
131
+ /** Pure: the URLs the tooling derives — never stored twice, never typed by hand.
132
+ * A Worker the profile does not have has no URL (`undefined`); the bot's are
133
+ * always there, the bot being the one required Worker. */
134
+ export function profileUrls(p: DeploymentProfile) {
135
+ const origin = (kind: WorkerKind): string | undefined => {
136
+ const worker = p.workers[kind];
137
+ return worker ? `https://${worker.hostname}` : undefined;
138
+ };
139
+ const bot = `https://${p.workers.bot.hostname}`;
140
+ return {
141
+ /** A Worker's origin — what its deploy preflight is pointed at; undefined when the profile lacks it. */
142
+ baseUrl: origin,
143
+ /** `GET /healthz` of a Worker; undefined when the profile lacks it. */
144
+ healthUrl: (kind: WorkerKind): string | undefined => {
145
+ const o = origin(kind);
146
+ return o === undefined ? undefined : `${o}/healthz`;
147
+ },
148
+ /** The bot's public origin — live-view links, the dashboards. */
149
+ publicBaseUrl: bot,
150
+ /** The state Worker other Workers record firings on and the bot reads its config from; undefined without one. */
151
+ stateWorkerUrl: origin("memory"),
152
+ /** The bot Worker's restart route (`deploy restart`). */
153
+ botAdminRestartUrl: `${bot}/admin/restart`,
154
+ };
155
+ }
156
+
157
+ /** Pure: is this the example profile? The example's account is the one
158
+ * placeholder that can never be a real Cloudflare account. */
159
+ export const EXAMPLE_ACCOUNT = "00000000000000000000000000000000";
160
+ export function isExampleProfile(p: DeploymentProfile): boolean {
161
+ return p.account === EXAMPLE_ACCOUNT;
162
+ }