cursedops 0.5.3 → 0.7.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/README.md CHANGED
@@ -13,6 +13,7 @@ bun add cursedops
13
13
  | `cursedops/launchd` | installing, replacing and removing a macOS launchd user agent, and the port a LIVE job serves on (`livePort`, 0.5.0) |
14
14
  | `cursedops/smoke` | the scaffolding of a deployed smoke — the ledger, the fetch, the DNS hint, the exit code — the one check no app owns (every address of a deployment serving the same built client), and since 0.5.0 its VERDICTS: the origin asked on loopback, the smoke's own environment, a network that lies about DNS, and a version that has settled |
15
15
  | `cursedops/worker-deploy` | the sequence that ships a Cloudflare Worker — clean tree, stage first, schema, `--var` stamp, secrets, smoke — and the readers it needs (0.5.0) |
16
+ | `cursedops/worker-deploy` (CPU) | a `wrangler tail` around the traffic a deploy already sends, each request's BILLED CPU charged to its declared route, red on an undeclared, over-budget or uncapped route — and `runWorkerDeploy`'s `stageWalk` + `cpuTail` that wrap the walk and the smoke in it (0.7.0, task 2134, lifted from collections) |
16
17
  | `cursedops/worker-secrets` | a Worker holding EXACTLY its deployment's secrets, uploaded over a pipe, read back (0.5.0) |
17
18
  | `cursedops/worker-rollback` | a hostname back on its Mac origin: origin first, route second, the route found rather than typed (0.5.0) |
18
19
  | `cursedops/edge-fetch` | a request to a deployed Worker as a script must make it — curl pinned past the Mac's negative DNS cache (0.5.0) |
@@ -21,6 +22,8 @@ bun add cursedops
21
22
  | `cursedops/api-floor` | the rule that an unmatched `/api/...` is a phrase and never the app shell — the namespace predicates, the trailing-slash normaliser and the default 404 body. No `node:` import, so it mounts inside a Worker |
22
23
  | `cursedops/build-info` | which commit a checkout-served process is running and whether its tree was dirty — read once at load, and a `null` retried in the background rather than cached for the life of the process. `node:child_process`, so never in a Worker |
23
24
  | `cursedops/bound-lists` | the static check that no SQL builds an `IN (…)` list one `?` per value — the shape D1 500s on past 100 ids. Test-time only; the recorded third exception, see below |
25
+ | `cursedops/staged-client` | the IMMUTABLE client a checkout-served app serves — a build staged into `<APP_DATA_DIR>/client/<commit>/` behind `CURRENT`, so another agent's `vite build` in the checkout never changes live bytes — and its `forge-client stamp` / `forge-client stage` bin (0.6.0, lifted from family, flix, roms, station) |
26
+ | `cursedops/deploy-tree` | the two refusals a checkout-served deploy needs — never deploy a dirty tree, never `git revert` in one (`readTreeStatus`, `refuseToDeploy`, `refuseToRevert`; 0.6.0, lifted from nine apps) |
24
27
  | `cursedops/public-surface` | the ratchet on a LIBRARY's public surface — a symbol count per export subpath against a committed baseline that may only fall — and its `public-surface` bin. Not an app's: the one entry here admitted for three published libraries, see below |
25
28
 
26
29
  Bun, zero runtime dependencies, ships TypeScript source. Nothing here knows an app's
@@ -416,12 +419,28 @@ most fragile part (a hand-rolled HTTP parser) in two places.
416
419
  | `edgeFetch` (curl `--resolve` past the negative DNS cache) | **moved** — `createEdgeFetch`; the Access header now THROWS on an incomplete token instead of sending empty headers |
417
420
  | the import's literal and row-for-row proof | **moved** — `sqlLiteral`, `rowDigest`, `sameRows`, `rowDifferences`, `wranglerRows` |
418
421
  | the steady `/healthz` wait | **moved**, into `cursedops/smoke` as `steadyHealth` |
422
+ | the Worker CPU tail and its judgement (`worker-cpu.ts` + `workerCpu.ts`) | **moved** (0.7.0, task 2134) — `runWorkerCpuTail`, `judgeTailCapture`, `workerBudgets`; `runWorkerDeploy({ stageWalk, cpuTail })` runs the walk and the smoke under it |
423
+ | the Worker BUDGET TABLE (routes, numbers, the unlock's ceiling) | **stays** — what a route may cost is the app |
419
424
  | the deployment table (`workerEnvs.ts`: names, database, URLs, secret map, which file) | **stays** — identity, rule 2 |
420
425
  | the smoke's CHECKS (gated-route census, health identity fields, shell/CSP) | **stays** — the same argument as the rest of `smoke` |
421
426
  | the walk (`workerWalk.ts`) and the stage walk's grant through `apps/auth-stage` | **stays** — what a signed-in owner does is the app |
422
427
  | the import's table PLAN (what is skipped, parents first, what is rebuilt: FTS, `art_lookups`) | **stays** — the app's schema |
423
428
  | minting a stage's secret FILE (which keys are copied from production, which are throwaway) | **stays** — `throwawaySecret` and `writeSecretsFile` are the mechanism it uses |
424
429
 
430
+ ### The Worker CPU check, in `cursedops/worker-deploy` (0.7.0, task 2134)
431
+
432
+ `collections` wrote it on 2026-09-23 after averaging 489 billed CPU-ms per request with a 15 s
433
+ p99 and every check green; `vault` was measured in the same state the same day (argon2id at
434
+ 4,089 CPU-ms per create, 4,458 per unlock, on its stage). The billed number exists only on the
435
+ trace a tail receives, so this tails the Worker around the walk and the smoke, charges each
436
+ request's `cpuTime` to its declared route with `cursedbelt-server/bench`, and fails on an
437
+ undeclared route, one over its budget, or an EXEMPT route past its `noticeAboveMs` (the library
438
+ only notices; a Worker is killed at 30 s of CPU, and a killed unlock reads as a wrong password).
439
+ An exempt route with no ceiling is red on its own. `bench` is passed in (`import * as bench`),
440
+ because `cursedbelt-server` depends on this package. It lives in `worker-deploy` rather than a subpath of its own because a shipped file may not
441
+ import a sibling (`publishShape.test.ts`). An app adopts it with a budget table, a
442
+ ten-line `scripts/worker-cpu.ts`, and `stageWalk` + `cpuTail` on its `runWorkerDeploy` spec.
443
+
425
444
  ## `cursedops/public-surface`
426
445
 
427
446
  ```sh
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cursedops",
3
- "version": "0.5.3",
4
- "description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots — and printing a command that runs when pasted — without knowing a path, the generation's whole-tree laws run over one repo from a checkout or a worktree, macOS launchd agent install/replace/remove and the live port a job serves, the scaffolding and verdicts of a deployed smoke (origin probe, the smoke's own environment, a network that lies about DNS, a settled version), the static-serving helpers eight apps copied — the path-traversal guard among them — the API floor that keeps an unmatched /api/... from ever being answered with the app shell, the commit and dirty flag a checkout-served process reports, the Cloudflare Worker deploy toolkit four apps copied (the deploy sequence, exact-set secrets over a pipe, origin-first rollback, the curl edge fetch, the row-for-row D1 import proof), and the public-surface ratchet three published libraries each carried a forked copy of. Mechanism only — no app knows its name from here. Bun, zero runtime dependencies (typescript is an optional peer, for public-surface only), ships source.",
3
+ "version": "0.7.0",
4
+ "description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots — and printing a command that runs when pasted — without knowing a path, the generation's whole-tree laws run over one repo from a checkout or a worktree, macOS launchd agent install/replace/remove and the live port a job serves, the scaffolding and verdicts of a deployed smoke (origin probe, the smoke's own environment, a network that lies about DNS, a settled version), the static-serving helpers eight apps copied — the path-traversal guard among them — the API floor that keeps an unmatched /api/... from ever being answered with the app shell, the commit and dirty flag a checkout-served process reports, the Cloudflare Worker deploy toolkit four apps copied (the deploy sequence, exact-set secrets over a pipe, origin-first rollback, the curl edge fetch, the row-for-row D1 import proof, the billed-CPU tail check around a deploy's walk and smoke), and the public-surface ratchet three published libraries each carried a forked copy of. Mechanism only — no app knows its name from here. Bun, zero runtime dependencies (typescript is an optional peer, for public-surface only), ships source.",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -97,11 +97,24 @@
97
97
  "source": "./src/d1Import.ts",
98
98
  "import": "./src/d1Import.ts"
99
99
  },
100
- "./package.json": "./package.json"
100
+ "./package.json": "./package.json",
101
+ "./staged-client": {
102
+ "types": "./src/stagedClient.ts",
103
+ "bun": "./src/stagedClient.ts",
104
+ "source": "./src/stagedClient.ts",
105
+ "import": "./src/stagedClient.ts"
106
+ },
107
+ "./deploy-tree": {
108
+ "types": "./src/deployTree.ts",
109
+ "bun": "./src/deployTree.ts",
110
+ "source": "./src/deployTree.ts",
111
+ "import": "./src/deployTree.ts"
112
+ }
101
113
  },
102
114
  "bin": {
103
115
  "public-surface": "./src/publicSurface.ts",
104
- "forge-paths": "./src/paths.ts"
116
+ "forge-paths": "./src/paths.ts",
117
+ "forge-client": "./src/stagedClient.ts"
105
118
  },
106
119
  "files": [
107
120
  "src",
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `cursedops/deploy-tree` — is this checkout in a state where a deploy's rollback would be a rollback?
3
+ *
4
+ * Lifted 2026-09-23 (0.6.0) from the nine apps that each carried `scripts/deployTree.ts` —
5
+ * collections, desk, family, flix, music, patterns, roms, station and vault; identical in logic,
6
+ * differing only in comments and indentation. vault's `deployTree.test.ts` came with it.
7
+ *
8
+ * ```ts
9
+ * import { readTreeStatus, refuseToDeploy, refuseToRevert } from "cursedops/deploy-tree";
10
+ * ```
11
+ *
12
+ * ## The incident this exists because of — 2026-09-18, six of eight apps
13
+ *
14
+ * A fleet deploy ran `bun run deploy` in eight app checkouts. Six failed, all on the same
15
+ * smoke check and none on their own code:
16
+ *
17
+ * ```
18
+ * ✗ clean-tree — 🔴 the served checkout has uncommitted changes — `git revert` would not undo this deploy
19
+ * ```
20
+ *
21
+ * Every other check passed in every app; the apps installed and served correctly. The trees
22
+ * were dirty because somebody else's in-flight sweep was sitting in them, uncommitted.
23
+ *
24
+ * 🔴 **Then the failure did something far worse than fail.** A non-edge smoke failure rolls
25
+ * back with `git revert`, and the rollback ran in that same dirty tree. Both outcomes were
26
+ * bad, and both happened inside four minutes:
27
+ *
28
+ * · **It succeeded** in `family`, `flix`, `music` and `roms` — landing a commit that reverted
29
+ * work a stranger had written and had never been told was touched. (`706d1106`, `50aa95ed`,
30
+ * `c6ef8cb6`, `88fececc`; each reverted-back the same session.)
31
+ * · **It failed** in `station` — `error: Your local changes to src/api/console.ts would be
32
+ * overwritten by merge … fatal: revert failed`, then `🔴 THE ROLLBACK DID NOT LAND.`, which
33
+ * leaves a live hostname on a deploy the script had already decided to undo.
34
+ *
35
+ * ## The shape of the bug, which is the reason this is a module and not an `if`
36
+ *
37
+ * 🔴 **The rollback was unsafe in exactly the condition that triggered it.** `clean-tree` fires
38
+ * only when the tree is dirty; `git revert` is only safe when it is not. The two met on every
39
+ * run. Nothing else in this generation can commit on an agent's behalf, and this did it to a
40
+ * stranger's work.
41
+ *
42
+ * So the fix is two refusals, not one, and they are here rather than inline in `deploy.ts`
43
+ * because a refusal nobody can test is a comment:
44
+ *
45
+ * 1. {@link refuseToDeploy} — run BEFORE the build, so the deploy never starts. A dirty tree
46
+ * means the thing that would deploy is not the thing `HEAD` names, so there is no commit a
47
+ * revert could undo. A pre-flight refusal costs a second; not having one cost four commits.
48
+ * 2. {@link refuseToRevert} — run immediately before `git revert`, because the pre-flight
49
+ * passing is not a promise about ten minutes later. A build, a stage run, or another agent
50
+ * working in this same checkout can all dirty the tree between the two, and one agent per
51
+ * checkout is a convention, not a lock.
52
+ *
53
+ * Neither takes an override flag, deliberately. `deploy.ts`'s own `--allow-empty` note records
54
+ * what an escape hatch on a gate that fires every day turns into: the flag becomes the habit,
55
+ * and the habit is what lets the real case through.
56
+ */
57
+
58
+ /** One uncommitted path, with the two-letter porcelain code git gave it. */
59
+ export type DirtyPath = {
60
+ /** The index/worktree code, e.g. `" M"`, `"D "`, `"??"`. */
61
+ code: string;
62
+ path: string;
63
+ };
64
+
65
+ export type TreeStatus = {
66
+ clean: boolean;
67
+ paths: DirtyPath[];
68
+ /**
69
+ * The subset that are DELETIONS. Called out separately because a rollback landing one is
70
+ * the worst case of all: `apps/music` and `apps/station` both sat with
71
+ * `D scripts/guardrails.ts` uncommitted — a deleted guardrails script, one blanket commit
72
+ * away from being deleted for real, by a script whose whole job was to be safe.
73
+ */
74
+ deletions: DirtyPath[];
75
+ };
76
+
77
+ /**
78
+ * One porcelain v1 line: `XY<space><path>`, `X` the index state and `Y` the worktree state.
79
+ *
80
+ * 🔴 The second branch is not hypothetical — it is the bug this module shipped with for about
81
+ * ten minutes. `deploy.ts`'s `shell()` helper `.trim()`s the output it returns, which strips
82
+ * the LEADING SPACE off the first line of ` M scripts/deploy.ts` and nothing else. The
83
+ * fixed-offset parse then read one character late and reported `M cripts/deploy.ts` — a path
84
+ * that does not exist, in a refusal whose whole job is to name paths a person can go and look
85
+ * at. `deploy.ts` now reads the status untrimmed; this branch means a caller that forgets
86
+ * still gets the right answer rather than a plausible wrong one.
87
+ */
88
+ function parseLine(line: string): DirtyPath {
89
+ const [code, rest] = line[2] === " " ? [line.slice(0, 2), line.slice(3)] : [` ${line.slice(0, 1)}`, line.slice(2)];
90
+ const path = rest.trim();
91
+ // A rename is `R old -> new`; keep the destination, which is the path that exists on disk.
92
+ const arrow = path.indexOf(" -> ");
93
+ return { code, path: arrow >= 0 ? path.slice(arrow + 4).trim() : path };
94
+ }
95
+
96
+ /** Parse `git status --porcelain` output. */
97
+ export function readTreeStatus(porcelain: string): TreeStatus {
98
+ const paths: DirtyPath[] = [];
99
+ for (const line of porcelain.split("\n")) {
100
+ if (line.trim() === "") continue;
101
+ paths.push(parseLine(line));
102
+ }
103
+ return {
104
+ clean: paths.length === 0,
105
+ paths,
106
+ deletions: paths.filter((p) => p.code.includes("D")),
107
+ };
108
+ }
109
+
110
+ /** `git status --porcelain`'s own rendering of a path, for an error message. */
111
+ function listed(paths: DirtyPath[], limit = 8): string {
112
+ const shown = paths.slice(0, limit).map((p) => ` ${p.code} ${p.path}`);
113
+ if (paths.length > limit) shown.push(` … and ${paths.length - limit} more`);
114
+ return shown.join("\n");
115
+ }
116
+
117
+ /**
118
+ * The pre-flight. Returns the refusal to print, or `null` if the deploy may proceed.
119
+ *
120
+ * 🔴 This runs before the build — before anything is built, staged, installed or swapped — so
121
+ * that the deploy simply does not start. The alternative, which is what happened, is that the
122
+ * deploy runs all the way to the smoke, the smoke's `clean-tree` check fails on this exact
123
+ * condition, and the rollback then commits against a stranger.
124
+ */
125
+ export function refuseToDeploy(status: TreeStatus): string | null {
126
+ if (status.clean) return null;
127
+ const lines = [
128
+ `\n✗ clean-tree — this checkout has ${status.paths.length} uncommitted path(s), so it must not deploy.`,
129
+ "",
130
+ " What deploys here is the WORKING TREE — there is no artifact to swap back. With",
131
+ " uncommitted changes in it, the code that would go live is not the code HEAD names,",
132
+ " so there is no commit a `git revert` could undo. The rollback this script would reach",
133
+ " for is not a rollback, and on 2026-09-18 it committed reverts of four other agents'",
134
+ " work rather than admit that. Nothing has been built, installed or swapped.",
135
+ "",
136
+ listed(status.paths),
137
+ ];
138
+ if (status.deletions.length > 0) {
139
+ lines.push(
140
+ "",
141
+ ` 🔴 ${status.deletions.length} of those is a DELETION. If this is not your work, do not commit it —`,
142
+ " two app checkouts on this fleet sat with a guardrails script deleted and uncommitted,",
143
+ " one blanket commit away from losing the file. This module's header names them.",
144
+ );
145
+ }
146
+ lines.push(
147
+ "",
148
+ " Commit them, or find whoever owns them and let them land — then deploy. `git status`",
149
+ " and `git diff` say whose they are; the runner commits by name, so a tree it left dirty",
150
+ " is somebody's live work, not litter.",
151
+ );
152
+ return lines.join("\n");
153
+ }
154
+
155
+ /**
156
+ * The rollback guard. Returns the refusal to print, or `null` if `git revert` is safe to run.
157
+ *
158
+ * 🔴 Never `git revert` in a dirty tree AT ALL — not only when `clean-tree` was the failing
159
+ * check. `station` proves the other half: its revert hit `error: Your local changes to
160
+ * src/api/console.ts would be overwritten by merge`, aborted, and left the live host on a
161
+ * deploy the script had decided to undo. A revert that half-lands is worse than one that never
162
+ * started, because the second one still has an honest error message.
163
+ */
164
+ export function refuseToRevert(status: TreeStatus, sha: string): string | null {
165
+ if (status.clean) return null;
166
+ return [
167
+ `\n🔴 NOT ROLLING BACK. The deploy failed, and ${sha.slice(0, 8)} is still what this checkout serves.`,
168
+ "",
169
+ ` This tree has ${status.paths.length} uncommitted path(s), and \`git revert\` in a dirty tree either`,
170
+ " commits somebody else's work as reverted or aborts halfway and leaves the host on the",
171
+ " code it was told to undo. Both happened on 2026-09-18. Neither is a rollback, so this",
172
+ " script will not do it — a deploy that needs a person is a better outcome than a commit",
173
+ " nobody asked for.",
174
+ "",
175
+ listed(status.paths),
176
+ "",
177
+ " A person: land or set aside the paths above, then `git revert --no-edit " +
178
+ `${sha.slice(0, 8)}\`, then`,
179
+ " `bun run build && bun run deploy` — the app's own deploy doc, § 'by hand', is the long form.",
180
+ ].join("\n");
181
+ }
package/src/roots.ts CHANGED
@@ -163,15 +163,29 @@ export function readForgeVar(marker: string, name: string, env: NodeJS.ProcessEn
163
163
  } catch {
164
164
  return null;
165
165
  }
166
- const known: Record<string, string> = { HOME: env.HOME?.trim() ?? "" };
166
+ // 🔴 `null` is UNRESOLVABLE, and it propagates: a value that references an unset or empty
167
+ // variable — `$HOME` from an env that has none — is not a path with a hole in it. Until 0.5.4
168
+ // an empty HOME expanded `"$HOME/.code/$FORGE_NAME"` to `/.code/cursedforge`, a root-relative
169
+ // state root that exists on no machine, and `assertTestSafeDbPath(…, { env: {} })` compared
170
+ // against it — the guard against a test opening the owner's database was silently OFF (task 2130).
171
+ const home = env.HOME?.trim();
172
+ const known: Record<string, string | null> = { HOME: home ? home : null };
167
173
  for (const line of text.split("\n")) {
168
174
  const match = /^\s*export\s+([A-Z_][A-Z0-9_]*)=(.*)$/.exec(line);
169
175
  if (!match) continue;
170
176
  const [, key, rawValue] = match as unknown as [string, string, string];
171
177
  const unquoted = rawValue.trim().replace(/^"(.*)"$/s, "$1").replace(/^'(.*)'$/s, "$1");
172
- const expanded = unquoted.replace(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g, (_whole, ref: string) => known[ref] ?? "");
173
- known[key] = expanded;
174
- if (key === name) return expanded || null;
178
+ let unresolved = false;
179
+ const expanded = unquoted.replace(/\$\{?([A-Z_][A-Z0-9_]*)\}?/g, (_whole, ref: string) => {
180
+ const value = known[ref];
181
+ if (value === null || value === undefined || value === "") {
182
+ unresolved = true;
183
+ return "";
184
+ }
185
+ return value;
186
+ });
187
+ known[key] = unresolved ? null : expanded;
188
+ if (key === name) return unresolved ? null : expanded || null;
175
189
  }
176
190
  return null;
177
191
  }
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * `cursedops/staged-client` — the IMMUTABLE client a checkout-served app serves: a build copied
4
+ * into `<APP_DATA_DIR>/client/<commit>/`, named by `CURRENT`, so another agent's `vite build` in
5
+ * the shared checkout can never change the bytes production is serving.
6
+ *
7
+ * ── Why (tasks 163-314, 2026-09-17 → 23) ────────────────────────────────────
8
+ * The launchd agent runs `server.ts` out of `$FORGE/apps/<app>`, and until this module it also
9
+ * served that checkout's `dist/` — so any `bun run verify` or `bun run build` by anybody in the
10
+ * checkout rewrote the live client under a server built from a different commit. Measured on
11
+ * station first (`/healthz` said one commit, `dist/index.html` another, a page asking for routes
12
+ * its server had never heard of); family, flix and roms had the same shape and took the same
13
+ * module. It lived as a byte-identical `src/server/clientDir.ts` in those four apps, with
14
+ * `scripts/stamp-build.ts` (four) and `scripts/stage-client.ts` (family, roms); lifted here 0.6.0.
15
+ *
16
+ * ```ts
17
+ * import { resolveClientDir, stageClient } from "cursedops/staged-client";
18
+ * const client = resolveClientDir({ appDir, dataDir: config.dataDir }); // serve client.dir
19
+ * ```
20
+ *
21
+ * ```jsonc
22
+ * "build": "vite build && forge-client stamp" // writes dist/build.json — the commit
23
+ * // deploy: stageClient({ from: dist, dataDir, commit }), or `forge-client stage`
24
+ * ```
25
+ *
26
+ * `stageClient` copies FULLY, verifies the shell arrived, and only then moves `CURRENT`, so a
27
+ * failed stage leaves the previous release serving. The directory is keyed on the commit (a
28
+ * dirty build gets `-dirty`), so a rollback is a directory that is still there, and
29
+ * {@link pruneStagedClients} keeps the newest few plus whatever `CURRENT` names. Nothing staged
30
+ * falls back to `<appDir>/dist` and SAYS so (`staged: false`), because dev, preview and e2e serve
31
+ * the bytes vite just wrote, and a production instance falling back is worth a log line.
32
+ *
33
+ * Standalone by construction: node builtins only, and `cursedops/roots` by its package name for
34
+ * the one CLI default that needs the state root (a shipped file imports no sibling by path).
35
+ */
36
+ import { spawnSync } from "node:child_process";
37
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
38
+ import { dirname, join, resolve } from "node:path";
39
+
40
+ /** The directory under the app's state root that holds every staged build. */
41
+ export const CLIENT_ROOT = "client";
42
+
43
+ /** The file inside {@link CLIENT_ROOT} naming the build that should be served. */
44
+ export const CURRENT_FILE = "CURRENT";
45
+
46
+ /** What `bun run build` stamps beside the shell so a build can name its own commit. */
47
+ const BUILD_STAMP = "build.json";
48
+
49
+ /** How many staged builds are kept. Enough for a rollback, not a disk leak. */
50
+ const KEEP_BUILDS = 3;
51
+
52
+ export interface BuildStamp {
53
+ /** The full 40-char commit the client was built from, or `null` if unreadable. */
54
+ commit: string | null;
55
+ /** Whether the tree was dirty when it was built. */
56
+ dirty: boolean | null;
57
+ /** When it was built — ISO 8601. */
58
+ builtAt: string;
59
+ }
60
+
61
+ /** A directory is a usable client only if it actually holds the shell. */
62
+ export const holdsShell = (dir: string): boolean => existsSync(join(dir, "index.html"));
63
+
64
+ /** The stamp a build wrote, or `null`. Never throws: an unreadable stamp is a `null` commit. */
65
+ export function readBuildStamp(dir: string): BuildStamp | null {
66
+ try {
67
+ const parsed = JSON.parse(readFileSync(join(dir, BUILD_STAMP), "utf8")) as Partial<BuildStamp>;
68
+ if (typeof parsed !== "object" || parsed === null) return null;
69
+ return {
70
+ commit: typeof parsed.commit === "string" && /^[0-9a-f]{40}$/.test(parsed.commit) ? parsed.commit : null,
71
+ dirty: typeof parsed.dirty === "boolean" ? parsed.dirty : null,
72
+ builtAt: typeof parsed.builtAt === "string" ? parsed.builtAt : "",
73
+ };
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /** Write the stamp. `forge-client stamp` calls it immediately after `vite build`. */
80
+ export function writeBuildStamp(dir: string, stamp: BuildStamp): void {
81
+ writeFileSync(join(dir, BUILD_STAMP), `${JSON.stringify(stamp, null, "\t")}\n`);
82
+ }
83
+
84
+ /**
85
+ * The staged build this app should serve, or `null` when nothing is staged.
86
+ *
87
+ * 🔴 A `CURRENT` naming a directory that is missing or shell-less answers `null`
88
+ * rather than throwing. The fallback to `dist/` keeps the app UP, and the smoke's
89
+ * `client-commit` row is what makes the miss loud — an app that refuses to boot
90
+ * because a pointer file is stale is a worse outcome than one serving the previous
91
+ * client and saying so.
92
+ */
93
+ export function stagedClientDir(dataDir: string): string | null {
94
+ const root = join(dataDir, CLIENT_ROOT);
95
+ let named: string;
96
+ try {
97
+ named = readFileSync(join(root, CURRENT_FILE), "utf8").trim();
98
+ } catch {
99
+ return null;
100
+ }
101
+ // One path segment, never joined from anything a request can influence — but the
102
+ // file is on disk and a hand edit is exactly how this would become a traversal.
103
+ if (!/^[A-Za-z0-9._-]+$/.test(named) || named === "." || named === "..") return null;
104
+ const dir = join(root, named);
105
+ return holdsShell(dir) ? dir : null;
106
+ }
107
+
108
+ export interface ResolvedClient {
109
+ /** The directory to serve. */
110
+ dir: string;
111
+ /** True when it came from the state root, false when it is the checkout's `dist/`. */
112
+ staged: boolean;
113
+ /** The commit the served client was BUILT from, full sha, or `null`. */
114
+ commit: string | null;
115
+ }
116
+
117
+ /**
118
+ * Where this process's client lives. `dataDir` is `null` in a context with no state
119
+ * root at all, which resolves to the checkout exactly as before.
120
+ */
121
+ export function resolveClientDir(options: { appDir: string; dataDir: string | null }): ResolvedClient {
122
+ const staged = options.dataDir ? stagedClientDir(options.dataDir) : null;
123
+ const dir = staged ?? join(options.appDir, "dist");
124
+ return { dir, staged: staged !== null, commit: readBuildStamp(dir)?.commit ?? null };
125
+ }
126
+
127
+ /**
128
+ * Copy a build into the state root and point `CURRENT` at it. Returns the directory.
129
+ *
130
+ * 🔴 The order is the whole safety property: copy fully, verify the shell arrived,
131
+ * and only THEN write `CURRENT`. A pointer is never updated to a half-copied build,
132
+ * so the worst a failed stage can do is leave the previous release serving.
133
+ *
134
+ * 🔴 The directory is keyed on the commit, so re-staging the same commit is
135
+ * idempotent and a rollback is a directory that is still there. `dirty` builds get a
136
+ * suffix, because two dirty builds of the same commit are genuinely different bytes
137
+ * and must not collide.
138
+ */
139
+ export function stageClient(options: {
140
+ from: string;
141
+ dataDir: string;
142
+ commit: string;
143
+ dirty?: boolean;
144
+ keep?: number;
145
+ }): string {
146
+ if (!holdsShell(options.from)) {
147
+ throw new Error(`${options.from} holds no index.html — there is no built client to stage`);
148
+ }
149
+ const root = join(options.dataDir, CLIENT_ROOT);
150
+ mkdirSync(root, { recursive: true });
151
+ const name = options.dirty ? `${options.commit.slice(0, 12)}-dirty` : options.commit.slice(0, 12);
152
+ const dir = join(root, name);
153
+ rmSync(dir, { recursive: true, force: true });
154
+ cpSync(options.from, dir, { recursive: true });
155
+ if (!holdsShell(dir)) throw new Error(`staging ${options.from} into ${dir} did not produce an index.html`);
156
+ writeFileSync(join(root, CURRENT_FILE), `${name}\n`);
157
+ pruneStagedClients(options.dataDir, options.keep ?? KEEP_BUILDS);
158
+ return dir;
159
+ }
160
+
161
+ /** Keep the newest `keep` staged builds plus whatever `CURRENT` names. */
162
+ export function pruneStagedClients(dataDir: string, keep: number = KEEP_BUILDS): string[] {
163
+ const root = join(dataDir, CLIENT_ROOT);
164
+ if (!existsSync(root)) return [];
165
+ let current = "";
166
+ try {
167
+ current = readFileSync(join(root, CURRENT_FILE), "utf8").trim();
168
+ } catch {
169
+ // No pointer — every directory here is a candidate but the newest still stay.
170
+ }
171
+ const dirs = readdirSync(root, { withFileTypes: true })
172
+ .filter((entry) => entry.isDirectory())
173
+ .map((entry) => ({ name: entry.name, at: statSync(join(root, entry.name)).mtimeMs }))
174
+ .sort((a, b) => b.at - a.at);
175
+ const removed: string[] = [];
176
+ for (const [index, entry] of dirs.entries()) {
177
+ if (index < keep || entry.name === current) continue;
178
+ rmSync(join(root, entry.name), { recursive: true, force: true });
179
+ removed.push(entry.name);
180
+ }
181
+ return removed;
182
+ }
183
+
184
+
185
+ // ── the `forge-client` bin ────────────────────────────────────────────────────────────────
186
+
187
+ /** The commit and dirtiness of the checkout at `root`, read from git; `null`s when unreadable. */
188
+ export function checkoutState(root: string): { commit: string | null; dirty: boolean | null } {
189
+ const git = (args: string[]): string | null => {
190
+ try {
191
+ const result = spawnSync("git", args, { cwd: root, encoding: "utf8", timeout: 5_000 });
192
+ return result.status === 0 ? (result.stdout ?? "").trim() : null;
193
+ } catch {
194
+ return null;
195
+ }
196
+ };
197
+ const sha = git(["rev-parse", "HEAD"]);
198
+ const status = git(["status", "--porcelain"]);
199
+ return { commit: sha && /^[0-9a-f]{40}$/.test(sha) ? sha : null, dirty: status === null ? null : status.length > 0 };
200
+ }
201
+
202
+ /** The nearest directory at or above `from` holding a `package.json` — the app. */
203
+ function packageRoot(from: string): string | null {
204
+ let dir = resolve(from);
205
+ for (let hops = 0; hops < 16; hops++) {
206
+ if (existsSync(join(dir, "package.json"))) return dir;
207
+ const up = dirname(dir);
208
+ if (up === dir) return null;
209
+ dir = up;
210
+ }
211
+ return null;
212
+ }
213
+
214
+ /**
215
+ * `forge-client stamp [dir]` writes `build.json` into a build (default `<app>/dist`);
216
+ * `forge-client stage [--data-dir <dir>]` stages `<app>/dist` into the app's data directory —
217
+ * `--data-dir`, else `APP_DATA_DIR`, else `<FORGE_STATE>/apps/<package name>` — and refuses a
218
+ * build whose stamp is not this checkout's HEAD. Returns the exit code.
219
+ */
220
+ export async function runClientCli(argv: readonly string[], cwd: string = process.cwd(), env: NodeJS.ProcessEnv = process.env): Promise<number> {
221
+ const [command, ...rest] = argv;
222
+ const app = packageRoot(cwd);
223
+ if (!app) {
224
+ console.error(`✗ no package.json above ${cwd} — run this from an app.`);
225
+ return 2;
226
+ }
227
+ if (command === "stamp") {
228
+ const dir = rest[0] ? resolve(cwd, rest[0]) : join(app, "dist");
229
+ if (!holdsShell(dir)) {
230
+ console.error(`✗ ${dir} holds no index.html — nothing to stamp. Did the build run?`);
231
+ return 1;
232
+ }
233
+ const { commit, dirty } = checkoutState(app);
234
+ writeBuildStamp(dir, { commit, dirty, builtAt: new Date().toISOString() });
235
+ console.log(` stamped ${dir}/build.json — ${commit?.slice(0, 8) ?? "no commit"}${dirty ? " (dirty)" : ""}`);
236
+ return 0;
237
+ }
238
+ if (command === "stage") {
239
+ const at = rest.indexOf("--data-dir");
240
+ let dataDir = at >= 0 ? rest[at + 1] : env.APP_DATA_DIR?.trim() || undefined;
241
+ if (!dataDir) {
242
+ const { forgeState } = await import("cursedops/roots");
243
+ const state = forgeState(app, env);
244
+ const name = (JSON.parse(readFileSync(join(app, "package.json"), "utf8")) as { name?: string }).name;
245
+ if (!state || !name) {
246
+ console.error("✗ no --data-dir, no APP_DATA_DIR, and no state root (or no package name) to derive one from.");
247
+ return 2;
248
+ }
249
+ dataDir = join(state, "apps", name);
250
+ }
251
+ const dist = join(app, "dist");
252
+ const stamp = readBuildStamp(dist);
253
+ const head = checkoutState(app).commit;
254
+ if (!stamp?.commit) {
255
+ console.error(`✗ ${dist} carries no build.json commit — build first (\`forge-client stamp\` runs after vite).`);
256
+ return 1;
257
+ }
258
+ if (stamp.commit !== head) {
259
+ console.error(`✗ ${dist} was built from ${stamp.commit.slice(0, 8)}, but HEAD is ${head?.slice(0, 8) ?? "unreadable"}.`);
260
+ console.error(" Staging it would serve a client that is not this commit. Build again first.");
261
+ return 1;
262
+ }
263
+ const where = stageClient({ from: dist, dataDir, commit: stamp.commit, dirty: stamp.dirty !== false });
264
+ console.log(`✓ staged ${where}${stamp.dirty !== false ? " (from a DIRTY tree)" : ""}`);
265
+ return 0;
266
+ }
267
+ console.error("usage: forge-client stamp [dir] | forge-client stage [--data-dir <dir>]");
268
+ return 2;
269
+ }
270
+
271
+ if (import.meta.main) {
272
+ process.exit(await runClientCli(process.argv.slice(2)));
273
+ }
@@ -49,7 +49,7 @@
49
49
  * Everything that shells out is behind {@link DeployDeps}, so the suite proves the order and every
50
50
  * refusal without wrangler, git or a network.
51
51
  */
52
- import { spawnSync } from "node:child_process";
52
+ import { spawn, spawnSync } from "node:child_process";
53
53
  import { existsSync, readFileSync } from "node:fs";
54
54
 
55
55
  /** The two deployments every Worker app here has. */
@@ -136,6 +136,11 @@ function readIfThere(path: string): string | null {
136
136
  return existsSync(path) ? readFileSync(path, "utf8") : null;
137
137
  }
138
138
 
139
+ /** `command` under the app's Worker CPU wrapper, in the argv {@link parseWorkerCpuArgv} reads. */
140
+ export function underCpuTail(wrapper: readonly string[], env: WorkerEnv, command: readonly string[]): string[] {
141
+ return [...wrapper, ...(env === "stage" ? ["--env", "stage"] : []), "--", ...command];
142
+ }
143
+
139
144
  /** A refusal an app adds to the sequence: a sentence saying why not, or `null` to proceed. */
140
145
  export type Refusal = () => string | null;
141
146
 
@@ -154,6 +159,17 @@ export interface WorkerDeploySpec {
154
159
  build: readonly string[] | null;
155
160
  /** Run in order BEFORE a production deploy; any red and production is not touched. Ignored for the stage. */
156
161
  stageFirst?: readonly (readonly string[])[];
162
+ /**
163
+ * The signed-in walk of the stage, run after `stageFirst` and before production is touched — and,
164
+ * unlike `stageFirst`, under {@link WorkerDeploySpec.cpuTail} when that is set. Ignored for the stage.
165
+ */
166
+ stageWalk?: readonly (readonly string[])[];
167
+ /**
168
+ * The app's Worker CPU wrapper (see {@link runWorkerCpuTail}), e.g. `["bun", "run", "scripts/worker-cpu.ts"]`. When
169
+ * set, every `stageWalk` command and the `smoke` run UNDER it (`underCpuTail`), so the traffic a
170
+ * deploy already sends is charged its billed CPU and judged against the app's Worker budgets.
171
+ */
172
+ cpuTail?: readonly string[];
157
173
  /** Before the stage (and before anything is built): the app's own refusals. */
158
174
  beforeStage?: readonly Refusal[];
159
175
  /** After the build, before anything ships — e.g. "the build output is really there". */
@@ -227,6 +243,11 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
227
243
  step(`the stage first — production waits on it: ${command.join(" ")}`);
228
244
  if (deps.run(command, {}) !== 0) return stop("stage", `\`${command.join(" ")}\` failed — production was NOT touched.`);
229
245
  }
246
+ for (const walk of spec.stageWalk ?? []) {
247
+ const command = spec.cpuTail ? underCpuTail(spec.cpuTail, "stage", walk) : [...walk];
248
+ step(`walk the stage signed in${spec.cpuTail ? ", under a CPU tail" : ""}: ${command.join(" ")}`);
249
+ if (deps.run(command, {}) !== 0) return stop("stage", `\`${command.join(" ")}\` failed — production was NOT touched.`);
250
+ }
230
251
  }
231
252
  if (spec.build) {
232
253
  step("build");
@@ -255,8 +276,9 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
255
276
  }
256
277
  }
257
278
  if (spec.smoke) {
258
- step("smoke every address this deployment answers on");
259
- if (deps.run(spec.smoke, {}) !== 0) {
279
+ step(`smoke every address this deployment answers on${spec.cpuTail ? ", under a CPU tail" : ""}`);
280
+ const smoke = spec.cpuTail ? underCpuTail(spec.cpuTail, spec.env, spec.smoke) : spec.smoke;
281
+ if (deps.run(smoke, {}) !== 0) {
260
282
  return stop(
261
283
  "smoke",
262
284
  "the Worker is deployed and its smoke FAILED. Nothing was rolled back: `bunx wrangler rollback` puts the previous version back in one command.",
@@ -267,3 +289,403 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
267
289
  log(`\n✅ ${detail}`);
268
290
  return { code: 0, step: "done", detail, commit, dirty };
269
291
  }
292
+
293
+ // ═════════════════════════════════════════════════════════════════════════════════════════════
294
+ /**
295
+ * THE WORKER CPU CHECK (0.7.0, task 2134) — what a Worker's requests REALLY cost, asserted on every deploy: a
296
+ * `wrangler tail` around traffic the deploy already sends (the signed-in stage walk, the smoke),
297
+ * each request's billed `cpuTime` charged to its declared route, and a red for a route that is
298
+ * undeclared, over its budget, or — exempt or not — heading for the platform's 30 s kill.
299
+ *
300
+ * ```ts
301
+ * // scripts/worker-cpu.ts — the whole app side, besides a budget table
302
+ * import * as bench from "cursedbelt-server/bench";
303
+ * import { parseWorkerCpuArgv, runWorkerCpuTail } from "cursedops/worker-deploy";
304
+ *
305
+ * const { env, command } = parseWorkerCpuArgv(process.argv.slice(2));
306
+ * const d = deploymentFor(env);
307
+ * const result = await runWorkerCpuTail({
308
+ * app: "vault", workerName: d.workerName, base: d.previewUrl ?? d.publicUrl, command, cwd: ROOT,
309
+ * credential, fetch: (url) => edgeFetch(url), bench, config: VAULT_WORKER_CPU_BUDGETS,
310
+ * });
311
+ * process.exit(result.code);
312
+ *
313
+ * // scripts/worker-deploy.ts — the sequence wraps the walk and the smoke itself
314
+ * runWorkerDeploy({ …, stageWalk: [["bun", "run", "scripts/worker-stage-walk.ts"]],
315
+ * cpuTail: ["bun", "run", "scripts/worker-cpu.ts"] }, deps);
316
+ * ```
317
+ *
318
+ * ## 🔴 Why it exists
319
+ *
320
+ * `collections` averaged **489 CPU-ms per request on 2026-09-22 with a 15.2 s p99**, and nothing
321
+ * in its gate could see it: its `cpuBudget.spec.ts` benches the Mac, where the same unlock cost
322
+ * ~1.6 s rather than 8.5–15 s, and a Worker handler has no clock to read its own CPU time. The
323
+ * billed number exists only on the trace a tail receives. `vault` was the next app in the same
324
+ * state — pure-JS argon2id at 4,089 CPU-ms per create and 4,458 per unlock on its stage, with
325
+ * every check green (task 2134). Collections wrote this wrapper in `scripts/`; a copy in vault
326
+ * would have been the fourth hand-kept copy of a deploy step this toolkit exists to own.
327
+ *
328
+ * ## Why `bench` is a parameter, not an import
329
+ *
330
+ * The recorder, the tail parser and the assertion are `cursedbelt-server/bench`'s — and
331
+ * `cursedbelt-server` depends on THIS package, so importing it back would be a cycle. The app
332
+ * already depends on it for its proxy table, so it hands the module in (`import * as bench`) and
333
+ * {@link TailBench} names, structurally, the seven functions used. The app's typecheck is what
334
+ * proves the two still agree.
335
+ *
336
+ * ## The decisions it makes about a capture (each one a way it went wrong or could)
337
+ *
338
+ * 1. **A non-API GET is the static tier, `GET *`** — but only when the table DECLARES `GET *`.
339
+ * A Worker that ends in `app.get("*")` serves the shell and every client-side route there;
340
+ * declaring `GET /*` as a pattern instead would swallow an undeclared `GET /api/…`, which is
341
+ * exactly what `requireDeclared` exists to name. So the label is decided here, never for a
342
+ * path under an API prefix, and an app with no `GET *` gets its unknown GETs named instead.
343
+ * 2. **`minSamples: 1`.** A walk makes one request per route; a p99 of one reading is that reading.
344
+ * 3. 🔴 **An exempt route over its `noticeAboveMs` FAILS here**, where the library only notices —
345
+ * and an exempt route with NO `noticeAboveMs` is a red of its own (`exempt-uncapped`). Exempt
346
+ * is from the per-request money budget, never from the platform: an invocation is killed at
347
+ * 30 s of CPU, and a killed unlock reads as a wrong password.
348
+ * 4. **Only this Worker's invocations are charged** (`scriptName`), so the stage's walk cannot be
349
+ * excused by production's traffic on a shared tail, or the reverse.
350
+ * 5. **A tail that never connects is a failure, never an empty pass**; neither is a runtime that
351
+ * stops reporting `cpuTime` (`recordTailTraces` skips it; the empty report is `no-samples`).
352
+ *
353
+ * ## How it knows the tail is listening, and that it has heard everything
354
+ *
355
+ * `wrangler tail --format json` prints nothing until a request arrives, so there is no banner to
356
+ * wait for. It sends `GET /healthz?cpu-tail=<nonce>` until that request comes back on the tail
357
+ * (connected), runs the command, then `…=<nonce>-end` until THAT comes back (drained). The marker
358
+ * requests are real traffic, so `GET /healthz` must be declared like any other route.
359
+ *
360
+ * 🔴 The command's own exit code wins: a walk that failed is reported as the walk failing, and the
361
+ * CPU verdict is still printed but does not mask it.
362
+ */
363
+
364
+ /** A route's budget as `cursedbelt-server/bench` declares it — structural. */
365
+ export type RouteBudgetLike = number | { cpuMs: number } | { exempt: true; reason: string; noticeAboveMs?: number };
366
+
367
+ /** `CpuBudgetConfig`, structurally. */
368
+ export interface CpuBudgetConfigLike {
369
+ defaultCpuMs?: number;
370
+ routes?: Record<string, RouteBudgetLike>;
371
+ }
372
+
373
+ /** The fields of a `CpuViolation` this reads. */
374
+ export interface CpuViolationLike {
375
+ kind: string;
376
+ key: string;
377
+ fails: boolean;
378
+ message: string;
379
+ }
380
+
381
+ /** `TailRecordResult`, structurally. */
382
+ export interface TailRecordLike {
383
+ recorded: number;
384
+ notFetch: number;
385
+ noCpuTime: number;
386
+ unmatched: number;
387
+ unmatchedPaths: string[];
388
+ }
389
+
390
+ /**
391
+ * The seven functions of `cursedbelt-server/bench` this uses — pass the module itself
392
+ * (`import * as bench from "cursedbelt-server/bench"`). Method syntax on purpose: the parameters
393
+ * are checked bivariantly, so the real, narrower signatures are assignable.
394
+ */
395
+ export interface TailBench {
396
+ parseTailLines(text: string): readonly unknown[];
397
+ tailCpuClock(): unknown;
398
+ createCpuRecorder(opts: { clock: never; config?: never }): { report(): unknown };
399
+ recordTailTraces(recorder: never, traces: never, opts: { route?: (method: string, path: string) => string | null; scriptName?: string }): TailRecordLike;
400
+ routeMatcher(keys: readonly string[]): (method: string, path: string) => string | null;
401
+ checkCpuBudgets(report: never, opts: { requireDeclared?: boolean; minSamples?: number }): readonly CpuViolationLike[];
402
+ formatCpuBudgetReport(report: never): string;
403
+ }
404
+
405
+ /** The static tier's label — decision 1. Its key in a table is `GET *`. */
406
+ export const STATIC_TIER = "*";
407
+ const STATIC_TIER_KEY = `GET ${STATIC_TIER}`;
408
+
409
+ /** Paths that are never the static tier unless a table declares them. */
410
+ export const DEFAULT_API_PREFIXES: readonly string[] = ["/api"];
411
+
412
+ const underPrefix = (path: string, prefixes: readonly string[]): boolean =>
413
+ prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix.replace(/\/$/, "")}/`));
414
+
415
+ /**
416
+ * `(method, path)` → the declared route it is charged to, or `null` (undeclared).
417
+ *
418
+ * 🔴 The `GET *` key itself is NOT given to the matcher: as a pattern it is `.*`, and it would
419
+ * charge an undeclared `GET /api/…` to the static tier — the swallowing decision 1 forbids.
420
+ */
421
+ export function tierRoute(
422
+ bench: Pick<TailBench, "routeMatcher">,
423
+ config: CpuBudgetConfigLike,
424
+ apiPrefixes: readonly string[] = DEFAULT_API_PREFIXES,
425
+ ): (method: string, path: string) => string | null {
426
+ const keys = Object.keys(config.routes ?? {});
427
+ const hasStaticTier = keys.includes(STATIC_TIER_KEY);
428
+ const declared = bench.routeMatcher(keys.filter((key) => !/(^|\s)\*$/.test(key)));
429
+ return (method, path) => {
430
+ const hit = declared(method, path);
431
+ if (hit !== null) return hit;
432
+ return hasStaticTier && method.toUpperCase() === "GET" && !underPrefix(path, apiPrefixes) ? STATIC_TIER : null;
433
+ };
434
+ }
435
+
436
+ /** A red this module adds on top of the library's violations. */
437
+ export interface WorkerCpuFailure {
438
+ kind: string;
439
+ key: string;
440
+ message: string;
441
+ }
442
+
443
+ export interface TailVerdict {
444
+ ok: boolean;
445
+ /** Every violation that fails this check — decision 3 included. */
446
+ failures: WorkerCpuFailure[];
447
+ /** `CpuBudgetReport` — hand it to `formatCpuBudgetReport`. */
448
+ report: unknown;
449
+ recorded: TailRecordLike;
450
+ /** Traces parsed out of the capture, this Worker's or not. */
451
+ traces: number;
452
+ }
453
+
454
+ export interface JudgeOpts {
455
+ bench: TailBench;
456
+ /** The Worker's REAL-reading budget table (`noticeAboveMs` on every exempt route). */
457
+ config: CpuBudgetConfigLike;
458
+ /** Only this deployment's invocations are charged — decision 4. */
459
+ scriptName: string;
460
+ /** Paths that are never the static tier. Default {@link DEFAULT_API_PREFIXES}. */
461
+ apiPrefixes?: readonly string[];
462
+ /** Replace the labeller entirely. Default {@link tierRoute}. */
463
+ route?: (method: string, path: string) => string | null;
464
+ }
465
+
466
+ /** Exempt routes with no ceiling — each one a route the 30 s kill could reach with every check green. */
467
+ export function uncappedExemptions(config: CpuBudgetConfigLike): string[] {
468
+ return Object.entries(config.routes ?? {})
469
+ .filter(([, budget]) => typeof budget === "object" && "exempt" in budget && budget.noticeAboveMs === undefined)
470
+ .map(([key]) => key);
471
+ }
472
+
473
+ /** Judge one `wrangler tail --format json` capture. Pure: no Worker, no network. */
474
+ export function judgeTailCapture(text: string, opts: JudgeOpts): TailVerdict {
475
+ const { bench, config } = opts;
476
+ const traces = bench.parseTailLines(text);
477
+ const recorder = bench.createCpuRecorder({ clock: bench.tailCpuClock() as never, config: config as never });
478
+ const route = opts.route ?? tierRoute(bench, config, opts.apiPrefixes);
479
+ const recorded = bench.recordTailTraces(recorder as never, traces as never, { scriptName: opts.scriptName, route });
480
+ const report = recorder.report();
481
+ const failures: WorkerCpuFailure[] = uncappedExemptions(config).map((key) => ({
482
+ kind: "exempt-uncapped",
483
+ key,
484
+ message:
485
+ `${key} is exempt with no noticeAboveMs — on a Worker every route needs a CEILING, because an invocation is ` +
486
+ "killed at 30 s of CPU whatever the table calls it. Give it the ceiling its real readings justify.",
487
+ }));
488
+ for (const v of bench.checkCpuBudgets(report as never, { requireDeclared: true, minSamples: 1 })) {
489
+ if (v.kind === "exempt-notice") {
490
+ failures.push({ kind: v.kind, key: v.key, message: `${v.message} 🔴 On a Worker this is red: an exempt route is still killed at 30 s of CPU.` });
491
+ } else if (v.fails) {
492
+ failures.push({ kind: v.kind, key: v.key, message: v.message });
493
+ }
494
+ }
495
+ return { ok: failures.length === 0, failures, report, recorded, traces: traces.length };
496
+ }
497
+
498
+ /** The verdict in words, for the deploy log. */
499
+ export function formatTailVerdict(verdict: TailVerdict, bench: Pick<TailBench, "formatCpuBudgetReport">, scriptName: string): string {
500
+ const r = verdict.recorded;
501
+ const lines = [
502
+ `Worker CPU for ${scriptName} — ${r.recorded} request(s) charged from ${verdict.traces} trace(s)` +
503
+ (r.notFetch ? `, ${r.notFetch} cron/other skipped` : "") +
504
+ (r.noCpuTime ? `, ${r.noCpuTime} with no cpuTime` : ""),
505
+ bench.formatCpuBudgetReport(verdict.report as never),
506
+ ];
507
+ if (r.unmatchedPaths.length > 0) lines.push(`undeclared paths: ${r.unmatchedPaths.join(", ")}`);
508
+ for (const failure of verdict.failures) lines.push(`✗ ${failure.message}`);
509
+ lines.push(verdict.ok ? "✓ every request inside its declared Worker budget" : "🔴 Worker CPU budget FAILED");
510
+ return lines.join("\n");
511
+ }
512
+
513
+ /**
514
+ * A Worker table derived from a Mac-proxy table, the rule `collections` measured its way to:
515
+ * **`max(floorMs, overProxy × proxy budget)`** per route, plus the Worker-only routes, with each
516
+ * exempt route given its ceiling from `ceilings`. THROWS on an exempt route with no ceiling, and
517
+ * on a ceiling for a route that is not exempt — both are a table that says something untrue.
518
+ *
519
+ * Why a rule rather than forty fitted numbers: the proxy already RANKS the routes, the ratio
520
+ * (3.5–5.3× Mac→workerd, measured three ways on 2026-09-23) carries the ranking to the platform,
521
+ * and the floor is the cold-isolate cost every request pays whatever it does — at a few hundred
522
+ * requests a day almost every request lands on a cold isolate.
523
+ */
524
+ export function workerBudgets(
525
+ proxy: CpuBudgetConfigLike,
526
+ opts: { floorMs: number; overProxy: number; workerOnly?: Record<string, RouteBudgetLike>; ceilings?: Record<string, number> },
527
+ ): { routes: Record<string, RouteBudgetLike> } {
528
+ const routes: Record<string, RouteBudgetLike> = {};
529
+ const ceilings = opts.ceilings ?? {};
530
+ for (const [key, budget] of Object.entries(proxy.routes ?? {})) {
531
+ if (typeof budget === "object" && "exempt" in budget) {
532
+ const ceiling = ceilings[key] ?? budget.noticeAboveMs;
533
+ if (ceiling === undefined) throw new Error(`workerBudgets: ${key} is exempt and has no ceiling — pass ceilings["${key}"].`);
534
+ routes[key] = { ...budget, noticeAboveMs: ceiling };
535
+ continue;
536
+ }
537
+ const cpuMs = typeof budget === "number" ? budget : budget.cpuMs;
538
+ routes[key] = Math.max(opts.floorMs, opts.overProxy * cpuMs);
539
+ }
540
+ for (const key of Object.keys(ceilings)) {
541
+ const budget = routes[key];
542
+ if (!(typeof budget === "object" && "exempt" in budget)) throw new Error(`workerBudgets: a ceiling for ${key}, which is not an exempt route.`);
543
+ }
544
+ return { routes: { ...(opts.workerOnly ?? {}), ...routes } };
545
+ }
546
+
547
+ /**
548
+ * `[--env stage] -- <command…>` → the deployment and the command. THROWS a usage line on a missing
549
+ * command. The `--env` spelling rules are {@link parseWorkerEnv}'s.
550
+ */
551
+ export function parseWorkerCpuArgv(argv: readonly string[]): { env: WorkerEnv; command: string[] } {
552
+ const split = argv.indexOf("--");
553
+ const own = split >= 0 ? argv.slice(0, split) : argv;
554
+ const command = split >= 0 ? argv.slice(split + 1) : [];
555
+ if (command.length === 0) throw new Error("usage: worker-cpu [--env stage] -- <command…>");
556
+ return { env: parseWorkerEnv(own), command };
557
+ }
558
+
559
+
560
+ /** A running `wrangler tail`. */
561
+ export interface TailProcess {
562
+ /** Everything it has printed on stdout so far. */
563
+ captured(): string;
564
+ /** The tail of its stderr, for a failure message. */
565
+ errors(): string;
566
+ exited(): boolean;
567
+ stop(): void;
568
+ }
569
+
570
+ export interface WorkerCpuDeps {
571
+ /** Start `wrangler tail <worker> --format json`. */
572
+ startTail: (workerName: string, cwd: string, env: Record<string, string>) => TailProcess;
573
+ /** Run the command, inheriting stdio; its exit code. */
574
+ run: (argv: readonly string[], cwd: string) => number;
575
+ sleep: (ms: number) => Promise<void>;
576
+ now: () => number;
577
+ log: (line: string) => void;
578
+ error: (line: string) => void;
579
+ }
580
+
581
+ /** The real {@link WorkerCpuDeps}: `bunx wrangler@4 tail`, pinned to the major whose JSON shape the parser reads. */
582
+ export function workerCpuDeps(): WorkerCpuDeps {
583
+ return {
584
+ startTail: (workerName, cwd, env) => {
585
+ let out = "";
586
+ let err = "";
587
+ const child = spawn("bunx", ["wrangler@4", "tail", workerName, "--format", "json"], {
588
+ cwd,
589
+ env: { ...process.env, ...env },
590
+ stdio: ["ignore", "pipe", "pipe"],
591
+ });
592
+ child.stdout.on("data", (chunk: Buffer) => {
593
+ out += chunk.toString("utf8");
594
+ });
595
+ child.stderr.on("data", (chunk: Buffer) => {
596
+ err += chunk.toString("utf8");
597
+ });
598
+ return {
599
+ captured: () => out,
600
+ errors: () => err.trim().slice(-800),
601
+ exited: () => child.exitCode !== null || child.signalCode !== null,
602
+ stop: () => {
603
+ if (child.exitCode === null) child.kill("SIGINT");
604
+ },
605
+ };
606
+ },
607
+ run: (argv, cwd) => spawnSync(argv[0] as string, argv.slice(1), { cwd, stdio: "inherit" }).status ?? 1,
608
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
609
+ now: () => Date.now(),
610
+ log: (line) => console.log(line),
611
+ error: (line) => console.error(line),
612
+ };
613
+ }
614
+
615
+ export interface WorkerCpuTailSpec extends Omit<JudgeOpts, "scriptName"> {
616
+ /** For the messages. */
617
+ app: string;
618
+ /** The Worker to tail — also the only `scriptName` charged. */
619
+ workerName: string;
620
+ /** Where the markers are sent: an address of THIS deployment that reaches the Worker. */
621
+ base: string;
622
+ command: readonly string[];
623
+ cwd: string;
624
+ /** The env overlay wrangler runs with — `cloudflareCredential` from `cursedops/worker-deploy`. */
625
+ credential: Record<string, string>;
626
+ /** A GET the app's scripts can make (the stage is behind Access) — `cursedops/edge-fetch`. */
627
+ fetch: (url: string) => Promise<unknown>;
628
+ connectMs?: number;
629
+ drainMs?: number;
630
+ /** How long a marker is watched for before it is sent again. Default 3 s. */
631
+ markerWaitMs?: number;
632
+ }
633
+
634
+ export interface WorkerCpuTailResult {
635
+ /** 0 green; the command's own code when it failed; 1 for any CPU or tail failure. */
636
+ code: number;
637
+ /** Where it stopped: `connect`, `command`, `drain`, `budget` or `done`. */
638
+ step: "connect" | "command" | "drain" | "budget" | "done";
639
+ verdict: TailVerdict | null;
640
+ }
641
+
642
+ /** Run `spec.command` under a tail of `spec.workerName` and judge what it cost. Never exits. */
643
+ export async function runWorkerCpuTail(spec: WorkerCpuTailSpec, deps: WorkerCpuDeps = workerCpuDeps()): Promise<WorkerCpuTailResult> {
644
+ const connectMs = spec.connectMs ?? 60_000;
645
+ const drainMs = spec.drainMs ?? 45_000;
646
+ const markerWaitMs = spec.markerWaitMs ?? 3_000;
647
+ const nonce = `${deps.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
648
+ const tag = `[${spec.app}]`;
649
+ const base = spec.base.replace(/\/$/, "");
650
+
651
+ deps.log(`\n▸ ${tag} Worker CPU: tailing ${spec.workerName} around \`${spec.command.join(" ")}\``);
652
+ const tail = deps.startTail(spec.workerName, spec.cwd, spec.credential);
653
+
654
+ /** Send the marker until the tail has carried it back, or give up. */
655
+ const awaitMarker = async (marker: string, withinMs: number): Promise<boolean> => {
656
+ const deadline = deps.now() + withinMs;
657
+ while (deps.now() < deadline) {
658
+ if (tail.exited()) return false;
659
+ await spec.fetch(`${base}/healthz?cpu-tail=${marker}`).catch(() => null);
660
+ const seenBy = deps.now() + markerWaitMs;
661
+ while (deps.now() < seenBy) {
662
+ // The closing quote: `<nonce>` must not be satisfied by `<nonce>-end`.
663
+ if (tail.captured().includes(`cpu-tail=${marker}"`)) return true;
664
+ await deps.sleep(250);
665
+ }
666
+ }
667
+ return false;
668
+ };
669
+
670
+ if (!(await awaitMarker(nonce, connectMs))) {
671
+ tail.stop();
672
+ deps.error(
673
+ `🔴 ${tag} wrangler tail never delivered a request from ${base} within ${Math.round(connectMs / 1000)}s — nothing could be measured.\n${tail.errors()}`,
674
+ );
675
+ return { code: 1, step: "connect", verdict: null };
676
+ }
677
+
678
+ const ran = deps.run(spec.command, spec.cwd);
679
+ const drained = await awaitMarker(`${nonce}-end`, drainMs);
680
+ // Traces are not strictly ordered; a moment more lets a straggler behind the end marker land.
681
+ await deps.sleep(2_000);
682
+ tail.stop();
683
+
684
+ const verdict = judgeTailCapture(tail.captured(), { ...spec, scriptName: spec.workerName });
685
+ deps.log(`\n${formatTailVerdict(verdict, spec.bench, spec.workerName)}`);
686
+ if (!drained) deps.error(`🔴 ${tag} the end marker never came back — the capture may be missing the command's last requests.`);
687
+
688
+ if (ran !== 0) return { code: ran, step: "command", verdict };
689
+ if (!drained) return { code: 1, step: "drain", verdict };
690
+ return verdict.ok ? { code: 0, step: "done", verdict } : { code: 1, step: "budget", verdict };
691
+ }