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 +19 -0
- package/package.json +17 -4
- package/src/deployTree.ts +181 -0
- package/src/roots.ts +18 -4
- package/src/stagedClient.ts +273 -0
- package/src/workerDeploy.ts +425 -3
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.
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
+
}
|
package/src/workerDeploy.ts
CHANGED
|
@@ -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(
|
|
259
|
-
|
|
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
|
+
}
|