cursedops 0.18.0 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/apiFloor.ts +4 -4
- package/src/d1Import.ts +6 -6
- package/src/edgeFetch.ts +1 -1
- package/src/launchd.ts +1 -1
- package/src/relay.ts +4 -4
- package/src/relayStage.ts +1 -1
- package/src/roots.ts +2 -2
- package/src/serve.ts +3 -3
- package/src/ship.ts +8 -8
- package/src/smoke.ts +35 -9
- package/src/workerDeploy.ts +5 -3
- package/src/workerRollback.ts +2 -2
- package/src/workerSecrets.ts +1 -1
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ import shape, worked example, and the incident each one is pinned to.
|
|
|
25
25
|
| `cursedops/backups` | `BACKUP_KEEP`, the fleet's one retention for dated snapshots; `BACKUP_STAMP_PATTERN`, `backupStamp` and `stampTime` |
|
|
26
26
|
| `cursedops/serve` | the static tier's four helpers — the path-traversal guard, the MIME table, the hashed-asset test, the crash handlers |
|
|
27
27
|
| `cursedops/worker-safety` | the two static checks every Worker app's suite runs over its own source — the import-graph walk an app's own test does from `worker/index.ts` (a value import of `bun:sqlite` or a Bun-only subpath is a deploy that goes green and a Worker that never starts), and the await port's silent shapes (`rows.filter(async …)` keeps every row) |
|
|
28
|
-
| `cursedops/api-floor` | the rule that an unmatched `/api/...` is a phrase and never the app shell — the namespace predicates, the trailing-slash
|
|
28
|
+
| `cursedops/api-floor` | the rule that an unmatched `/api/...` is a phrase and never the app shell — the namespace predicates, the trailing-slash normalizer, the default 404 body |
|
|
29
29
|
| `cursedops/build-info` | which commit a checkout-served process is running and whether its tree was dirty, read once at load. `node:child_process`, so never in a Worker |
|
|
30
30
|
| `cursedops/bound-lists` | the static check that no SQL builds an `IN (…)` list one `?` per value — the shape D1 500s on past 100 ids |
|
|
31
31
|
| `cursedops/staged-client` | the IMMUTABLE client a checkout-served app serves — a build staged into `<APP_DATA_DIR>/client/<commit>/` behind `CURRENT` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
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 each app's worker:secrets and worker:smoke main as one function of its data), the relay a Worker fronts a Mac-bound app with (the Durable Object, the frames, the Mac's dialer and key rotation — lifted from station for roms — and the signed-in stage walk's skeleton and the relay app's whole worker:deploy), the Worker import-graph and await-port checks every Worker app's suite runs over its own source, 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": {
|
package/src/apiFloor.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* > this commit and reload"*, which sent the owner at a deploy that could not have
|
|
20
20
|
* > helped.
|
|
21
21
|
*
|
|
22
|
-
* Both halves of that are here, because a floor without the
|
|
22
|
+
* Both halves of that are here, because a floor without the normalizer fixes the symptom
|
|
23
23
|
* the owner did not have: {@link canonicalApiPath} makes the trailing slash a non-event,
|
|
24
24
|
* and {@link isApiPath} + {@link apiNotFoundBody} make every remaining miss a JSON 404
|
|
25
25
|
* that names the route, the app and the commit answering. A wrong path says "wrong path";
|
|
@@ -73,7 +73,7 @@ export const isApiPath = (urlPath: string): boolean =>
|
|
|
73
73
|
* 🔴 One rule, and it is the one that broke the Openclaw page: a trailing slash on an API
|
|
74
74
|
* path is never meaningful. Hono mounts a sub-app's `get("/")` at the mount point WITHOUT
|
|
75
75
|
* a trailing slash and then matches strictly, so `/api/openclaw/` is a different path
|
|
76
|
-
* from `/api/openclaw` and matched nothing at all.
|
|
76
|
+
* from `/api/openclaw` and matched nothing at all. Normalizing once, in front of the
|
|
77
77
|
* router, means no caller anywhere can spell it the losing way again — which a fix in the
|
|
78
78
|
* one client module that happened to do it would not have bought.
|
|
79
79
|
*
|
|
@@ -86,8 +86,8 @@ export function canonicalApiPath(urlPath: string): string | null {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
* The same
|
|
90
|
-
* there is nothing to
|
|
89
|
+
* The same normalization, applied to a whole request. Returns the request unchanged when
|
|
90
|
+
* there is nothing to normalize, so a caller that guards on {@link canonicalApiPath}
|
|
91
91
|
* allocates nothing.
|
|
92
92
|
*
|
|
93
93
|
* 🔴 **Call it from INSIDE the floor handler and re-dispatch, not from the host's
|
package/src/d1Import.ts
CHANGED
|
@@ -14,17 +14,17 @@
|
|
|
14
14
|
* ## 🔴 Row for row, never a count
|
|
15
15
|
*
|
|
16
16
|
* Two tables can hold the same NUMBER of different rows, and a count is exactly the check that
|
|
17
|
-
* cannot tell — on `collections`'
|
|
17
|
+
* cannot tell — on `collections`' catalog that would have been a caption, a rotation or a
|
|
18
18
|
* checksum silently replaced by another file's. `collections`, `vault`, `patterns` and `music`
|
|
19
19
|
* each wrote this comparison (task 2097); the `rowDigest` bodies had drifted:
|
|
20
20
|
*
|
|
21
|
-
* · `collections`
|
|
21
|
+
* · `collections` normalized numbers — `bun:sqlite` hands back `0` where D1's JSON carries
|
|
22
22
|
* `0.0`, and float noise past twelve significant digits — because `duration_seconds` is REAL
|
|
23
|
-
* on 39 of its rows and a comparison that called those different reported the
|
|
24
|
-
* corrupt on every run. `vault` and `music` did not
|
|
23
|
+
* on 39 of its rows and a comparison that called those different reported the catalog
|
|
24
|
+
* corrupt on every run. `vault` and `music` did not normalize, and would have.
|
|
25
25
|
* · `vault` and `music` spelled a BLOB as `X'…'` for the insert; `patterns` could not carry one.
|
|
26
26
|
*
|
|
27
|
-
* The body here is the union: numbers
|
|
27
|
+
* The body here is the union: numbers normalized, blobs compared as hex whichever side they came
|
|
28
28
|
* from (a `Uint8Array` from `bun:sqlite`, an array of bytes from D1's JSON), booleans as 0/1.
|
|
29
29
|
*/
|
|
30
30
|
|
|
@@ -54,7 +54,7 @@ function cell(value: unknown): string | number | null {
|
|
|
54
54
|
return String(value);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/** One row flattened to a comparable string over `columns` — see the header for the
|
|
57
|
+
/** One row flattened to a comparable string over `columns` — see the header for the normalization. */
|
|
58
58
|
export function rowDigest(row: Readonly<Record<string, unknown>>, columns: readonly string[]): string {
|
|
59
59
|
return JSON.stringify(columns.map((column) => cell(row[column])));
|
|
60
60
|
}
|
package/src/edgeFetch.ts
CHANGED
|
@@ -271,7 +271,7 @@ export const NO_ANSWER_STATUS = 599;
|
|
|
271
271
|
* {@link NO_ANSWER_STATUS} `Response` whose body and `x-edge-error` header carry the reason, and
|
|
272
272
|
* `onThrow` hears it first so the smoke's ledger can name it.
|
|
273
273
|
*
|
|
274
|
-
* 🔴 Why (2026-09-25): roms' `worker:smoke` asked a relayed path while the Mac's link
|
|
274
|
+
* 🔴 Why (2026-09-25): roms' `worker:smoke` asked a relayed path while the Mac's link redialed,
|
|
275
275
|
* `edgeFetch` threw on curl exit 28, and the process died with a stack trace and no ledger — so a
|
|
276
276
|
* healthy release exited 1. Every leg of a smoke is supposed to be RECORDED: a throw is a failed
|
|
277
277
|
* leg naming its path, never the end of the run.
|
package/src/launchd.ts
CHANGED
|
@@ -66,7 +66,7 @@ export interface CommandResult {
|
|
|
66
66
|
/**
|
|
67
67
|
* How this module reaches launchd and the LaunchAgents directory.
|
|
68
68
|
*
|
|
69
|
-
* Injectable as one object because the interesting
|
|
69
|
+
* Injectable as one object because the interesting behavior — clearing a label
|
|
70
70
|
* and *waiting* for it to go — cannot otherwise be reached from a test that does
|
|
71
71
|
* not install a real login agent first, and a test nobody runs is not a test.
|
|
72
72
|
*/
|
package/src/relay.ts
CHANGED
|
@@ -38,7 +38,7 @@ export const DEFAULT_MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
|
38
38
|
/** How long the Worker waits for the Mac to answer one request before it says so. */
|
|
39
39
|
export const DEFAULT_RELAY_TIMEOUT_MS = 30_000;
|
|
40
40
|
|
|
41
|
-
/** A request
|
|
41
|
+
/** A request traveling DOWN the link. `path` includes the query string. */
|
|
42
42
|
export interface RequestFrame {
|
|
43
43
|
t: "req";
|
|
44
44
|
id: string;
|
|
@@ -48,7 +48,7 @@ export interface RequestFrame {
|
|
|
48
48
|
body: string | null;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** The Mac's answer,
|
|
51
|
+
/** The Mac's answer, traveling UP. Multiple `set-cookie` lines stay separate pairs. */
|
|
52
52
|
export interface ResponseFrame {
|
|
53
53
|
t: "res";
|
|
54
54
|
id: string;
|
|
@@ -459,7 +459,7 @@ export class RelayLink {
|
|
|
459
459
|
if (body.length <= (cache.maxKeptBody ?? 512 * 1024)) {
|
|
460
460
|
const at = this.now();
|
|
461
461
|
await this.state.storage.put(`c:${owner}:${keyed}`, { body, contentType: response.headers.get("content-type") ?? "application/json", at } satisfies Kept);
|
|
462
|
-
// The newest cookie the Mac
|
|
462
|
+
// The newest cookie the Mac honored is the one a `warm` frame refreshes under.
|
|
463
463
|
await this.state.storage.put("warm", { cookie, at });
|
|
464
464
|
}
|
|
465
465
|
}
|
|
@@ -474,7 +474,7 @@ export class RelayLink {
|
|
|
474
474
|
if (keys.length > 0) await this.state.storage.delete(keys);
|
|
475
475
|
}
|
|
476
476
|
|
|
477
|
-
/** Refresh every read the Mac named, under the newest cookie it
|
|
477
|
+
/** Refresh every read the Mac named, under the newest cookie it honored — if that is still fresh. */
|
|
478
478
|
private async warm(paths: readonly string[]): Promise<void> {
|
|
479
479
|
const cache = this.options.offline;
|
|
480
480
|
if (!cache) return;
|
package/src/relayStage.ts
CHANGED
|
@@ -163,7 +163,7 @@ export const realRun: Run = (argv, options) => {
|
|
|
163
163
|
/**
|
|
164
164
|
* 🔴 How many rounds in a row every `/healthz` must name a just-put link digest before a walk uses it.
|
|
165
165
|
* A digest put is a new Worker version, and the edge takes it up instance by instance: the link
|
|
166
|
-
* staying up for 15 s proves only that the instance the link
|
|
166
|
+
* staying up for 15 s proves only that the instance the link dialed has it. The Worker signs the
|
|
167
167
|
* Mac's identity with that digest, so a request answered by an instance still on the old one is the
|
|
168
168
|
* Mac's own 401 (and station's kept read a 503). Measured 2026-10-15 (forge task 3235): station's
|
|
169
169
|
* fast-lane walk at baf8495, the link steady for 15 s, then four writes 401 at the Mac; on the real
|
package/src/roots.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* written independently against the same problem; measured 2026-09-16 the
|
|
9
9
|
* `findForgeRoot` bodies were character-identical and the surrounding halves were
|
|
10
10
|
* not, which is the definition of an extraction that is measurable rather than
|
|
11
|
-
* speculative. Two implementations, one
|
|
11
|
+
* speculative. Two implementations, one behavior.
|
|
12
12
|
*
|
|
13
13
|
* ## The rule this serves
|
|
14
14
|
*
|
|
@@ -332,7 +332,7 @@ function shellDoubleQuoted(value: string): string {
|
|
|
332
332
|
* {@link findForgeRoot}).
|
|
333
333
|
*
|
|
334
334
|
* 🔴 **Verified, never trusted.** A `$FORGE` pointing at a directory with no `forge.env` is a
|
|
335
|
-
* stale export from a shell that outlived a move, and
|
|
335
|
+
* stale export from a shell that outlived a move, and honoring it re-creates the exact defect
|
|
336
336
|
* {@link repoCd} exists to end: a command naming a directory that is not there.
|
|
337
337
|
* {@link forgeCodeRoot} deliberately keeps its older contract (the variable wins unchecked); this
|
|
338
338
|
* is the one for text a person will paste.
|
package/src/serve.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* `station` and `vault` each have a `src/server/serve.ts`. The FILES differ — 247
|
|
10
10
|
* to 387 lines, eight distinct versions, which is why no `diff` ever flagged them —
|
|
11
11
|
* but these four bodies are character-identical in **eight of eight**, with the
|
|
12
|
-
* whitespace and comments
|
|
12
|
+
* whitespace and comments normalized away.
|
|
13
13
|
*
|
|
14
14
|
* 🔴 **`fileWithin` is why this was urgent rather than tidy.** It is the check that
|
|
15
15
|
* stops `%2e%2e%2f` reaching the filesystem. Eight copies of a security guarantee
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* `8 <symbol> src/server/serve.ts` and is the census that will notice if a copy
|
|
29
29
|
* comes back.
|
|
30
30
|
*
|
|
31
|
-
* ## 🔴 The one
|
|
31
|
+
* ## 🔴 The one behavior that is NOT a byte-for-byte carry
|
|
32
32
|
*
|
|
33
33
|
* The eight copies resolved containment LEXICALLY — `resolve(join(root, …))` and a
|
|
34
34
|
* `startsWith` — which is correct for `..` and for `%2e%2e%2f`, and blind to a
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
* the same way — the failure mode a naive `realpath` on one side only would
|
|
43
43
|
* have introduced, and the reason this is not one line;
|
|
44
44
|
* - every caller today passes a Vite `dist/`, and all eight were checked for
|
|
45
|
-
* symlinks on 2026-09-17: there are none. So no app's
|
|
45
|
+
* symlinks on 2026-09-17: there are none. So no app's behavior moves, and the
|
|
46
46
|
* day one of them serves a directory it did not build, the guard already holds.
|
|
47
47
|
*
|
|
48
48
|
* `serve.test.ts` drives both halves, including the symlink escape, because a guard
|
package/src/ship.ts
CHANGED
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
* flipped yesterday — the precedence the retired `fastMode.ts` settled on 2026-08-13). A stage
|
|
40
40
|
* deploy is never deferred: it IS the fast way to see a change on a real edge.
|
|
41
41
|
*
|
|
42
|
-
* 🔴 **Under a batch (`$AUTOPILOT_BATCH` set) it is
|
|
42
|
+
* 🔴 **Under a batch (`$AUTOPILOT_BATCH` set) it is honored only for the ship task or an incident**
|
|
43
43
|
* ({@link shipNowVerdict}), and only with a reason in `FORGE_SHIP_NOW_WHY`. Until task 2384 any agent
|
|
44
44
|
* could prefix one variable and turn the owner's switch off for itself — the thing
|
|
45
45
|
* `~/.claude/CLAUDE.md` §2 says an agent may never do — and no record said it had. Now every ask,
|
|
46
|
-
*
|
|
47
|
-
* with no reason. In the owner's own shell (no batch) it is
|
|
46
|
+
* honored or refused, is a `kind: "ship-now"` ledger row, and `autopilot doctor` lists the ones
|
|
47
|
+
* with no reason. In the owner's own shell (no batch) it is honored exactly as before, and recorded.
|
|
48
48
|
*/
|
|
49
49
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
50
50
|
import { dirname, join } from "node:path";
|
|
@@ -92,7 +92,7 @@ export function readShipSetting(state: string | null): ShipSetting {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
/** Is `FORGE_SHIP_NOW` set to something that means yes? Asked is not
|
|
95
|
+
/** Is `FORGE_SHIP_NOW` set to something that means yes? Asked is not honored — see {@link shipNowVerdict}. */
|
|
96
96
|
export function shipNowAsked(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
97
97
|
const v = env[SHIP_NOW_VAR]?.trim().toLowerCase();
|
|
98
98
|
return v === "1" || v === "true" || v === "yes";
|
|
@@ -116,7 +116,7 @@ export interface ShipNowVerdict {
|
|
|
116
116
|
asked: boolean;
|
|
117
117
|
/** Ship now: asked, and (outside a batch, or on a claim that earns it). */
|
|
118
118
|
honoured: boolean;
|
|
119
|
-
/** One sentence: what made it
|
|
119
|
+
/** One sentence: what made it honored or refused. */
|
|
120
120
|
why: string;
|
|
121
121
|
/** `FORGE_SHIP_NOW_WHY`, trimmed — `""` when not given. */
|
|
122
122
|
reason: string;
|
|
@@ -159,7 +159,7 @@ export interface ShipNowWhere {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
/**
|
|
162
|
-
* Is `FORGE_SHIP_NOW`
|
|
162
|
+
* Is `FORGE_SHIP_NOW` honored here? Outside a batch, whenever it is asked: that is the owner's own
|
|
163
163
|
* shell. Under `$AUTOPILOT_BATCH`, only when that batch holds a live `kind: "task"` board claim
|
|
164
164
|
* (`$FORGE_STATE/state/board/*.json`, the pid alive and not expired) on a task in
|
|
165
165
|
* `$FORGE/tasks/In_Progress/` whose `escalation-key:` is one of {@link SHIP_NOW_KEYS}, AND
|
|
@@ -206,11 +206,11 @@ export function shipNowVerdict(where: ShipNowWhere, env: NodeJS.ProcessEnv = pro
|
|
|
206
206
|
if (key && SHIP_NOW_KEYS.some((k) => k.test(key))) return { ...base, honoured: true, why: `batch ${batch} holds ${c.task} (escalation-key: ${key})`, task: c.task };
|
|
207
207
|
}
|
|
208
208
|
return refuse(
|
|
209
|
-
`batch ${batch} holds ${held.length ? held.join(", ") : "no live task claim"} — ${SHIP_NOW_VAR} is
|
|
209
|
+
`batch ${batch} holds ${held.length ? held.join(", ") : "no live task claim"} — ${SHIP_NOW_VAR} is honored under a batch only for the ship task or a prod incident (escalation-key: ship | prod-down:<host>)`,
|
|
210
210
|
);
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
/** The ledger line every ship-now ask leaves —
|
|
213
|
+
/** The ledger line every ship-now ask leaves — honored or refused. */
|
|
214
214
|
export interface ShipNowEventLine {
|
|
215
215
|
at: string;
|
|
216
216
|
kind: "ship-now";
|
package/src/smoke.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* its logs was wrong.
|
|
14
14
|
* 3. A deploy *succeeded* while every byte of media was unreachable, because the
|
|
15
15
|
* failing layer answered before the thing being health-checked ran.
|
|
16
|
-
* 4. An app was up, green, and serving a
|
|
16
|
+
* 4. An app was up, green, and serving a catalog a MONTH out of date.
|
|
17
17
|
* 5. Two apps went live answering `no built client at …/public` at `/` while the
|
|
18
18
|
* edge returned 200 out of its cache of the RETIRED deployment. Gate green,
|
|
19
19
|
* deploy green, smoke green, app down; the owner found it, not a check.
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
* built, `patterns.cursedalchemy.com` served one built on 2026-09-12 out of a
|
|
63
63
|
* Worker nobody knew about, and **every automated surface was green for six days.**
|
|
64
64
|
* The app-local implementation that found it — `apps/patterns/scripts/worker-smoke.ts`,
|
|
65
|
-
* `CROSS_CHECKS` — is what this
|
|
65
|
+
* `CROSS_CHECKS` — is what this generalizes, because five more apps are moving
|
|
66
66
|
* behind an edge Worker and a check told ONE NAME is a check about that name.
|
|
67
67
|
*
|
|
68
68
|
* ## What this module will never grow
|
|
@@ -501,7 +501,7 @@ export async function compareDeployedShells(addresses: string[], options: ShellO
|
|
|
501
501
|
url.searchParams.set("cb", cacheBust());
|
|
502
502
|
// 🔴 Re-thrown WITH the address and with the original message intact: which
|
|
503
503
|
// of the addresses could not be reached is the whole diagnosis, and
|
|
504
|
-
// {@link Smoke.dnsHint} still has to
|
|
504
|
+
// {@link Smoke.dnsHint} still has to recognize an ENOTFOUND inside it.
|
|
505
505
|
const response = await send(url.toString(), {
|
|
506
506
|
redirect: "manual",
|
|
507
507
|
signal: AbortSignal.timeout(timeoutMs),
|
|
@@ -929,7 +929,7 @@ export async function steadyHealth<T = Record<string, unknown>>(
|
|
|
929
929
|
*/
|
|
930
930
|
export const HEALTH_READ_MAX_TIME_SEC = 10;
|
|
931
931
|
|
|
932
|
-
/** The init a smoke's `/healthz` read carries — `cursedops/edge-fetch`
|
|
932
|
+
/** The init a smoke's `/healthz` read carries — `cursedops/edge-fetch` honors it; a plain `fetch` ignores it. */
|
|
933
933
|
const healthRead: EdgeRequestInit = { maxTimeSec: HEALTH_READ_MAX_TIME_SEC };
|
|
934
934
|
|
|
935
935
|
/** One question a deploy must answer the NEW way — `null` when it does, else why not. */
|
|
@@ -1030,8 +1030,13 @@ export interface WorkerSmokeSpec {
|
|
|
1030
1030
|
deployment: { env: string; workerName: string; publicUrl: string; previewUrl: string | null };
|
|
1031
1031
|
/** Addresses given on the command line. Empty: the preview (no cache in front) and the public URL. */
|
|
1032
1032
|
urls?: readonly string[];
|
|
1033
|
-
/**
|
|
1033
|
+
/**
|
|
1034
|
+
* The checkout's HEAD, as `/healthz` reports a commit (`git rev-parse --short=8 HEAD`). Under
|
|
1035
|
+
* `runWorkerDeploy` the commit it STAMPED wins — see {@link DEPLOYED_COMMIT_ENV}.
|
|
1036
|
+
*/
|
|
1034
1037
|
head: string;
|
|
1038
|
+
/** Where {@link DEPLOYED_COMMIT_ENV} is read. Default `process.env`. */
|
|
1039
|
+
env?: Record<string, string | undefined>;
|
|
1035
1040
|
/**
|
|
1036
1041
|
* Every private route; each must refuse an anonymous caller with 401 (`door: "access"`: 403 on
|
|
1037
1042
|
* the preview, 200 through the token on the public host). Empty is a FAILURE.
|
|
@@ -1059,6 +1064,25 @@ export interface WorkerSmokeSpec {
|
|
|
1059
1064
|
log?: (line: string) => void;
|
|
1060
1065
|
}
|
|
1061
1066
|
|
|
1067
|
+
/**
|
|
1068
|
+
* 🔴 The commit `runWorkerDeploy` stamped into the Worker it just uploaded, handed to its smoke.
|
|
1069
|
+
*
|
|
1070
|
+
* Each app's smoke reads its checkout's HEAD when it starts, minutes after the deploy read it to
|
|
1071
|
+
* stamp the build. On 2026-10-18 music's fast lane built and stamped `a0bca487`, a batch landed an
|
|
1072
|
+
* e2e-only commit two minutes in, and the smoke — asking for the new HEAD `43c2bae8` — waited out
|
|
1073
|
+
* its steady window and then failed a healthy production deploy on `commit`. The smoke proves
|
|
1074
|
+
* what was DEPLOYED, so it asks for this commit when it is set, and for HEAD only when run alone.
|
|
1075
|
+
*/
|
|
1076
|
+
export const DEPLOYED_COMMIT_ENV = "CURSEDOPS_DEPLOYED_COMMIT";
|
|
1077
|
+
|
|
1078
|
+
/** The commit `/healthz` must report, and the words the `commit` check says it in. */
|
|
1079
|
+
export function expectedCommit(spec: Pick<WorkerSmokeSpec, "head" | "env">): { commit: string; said: string } {
|
|
1080
|
+
const stamped = (spec.env ?? process.env)[DEPLOYED_COMMIT_ENV]?.trim();
|
|
1081
|
+
if (!stamped) return { commit: spec.head, said: `HEAD ${spec.head}` };
|
|
1082
|
+
const commit = stamped.slice(0, spec.head.length || 8);
|
|
1083
|
+
return { commit, said: commit === spec.head ? `HEAD ${commit}` : `deployed ${commit} (HEAD has moved on to ${spec.head} since)` };
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1062
1086
|
/**
|
|
1063
1087
|
* The whole anonymous `worker:smoke` of a deployed Worker, lifted out of `apps/family` and
|
|
1064
1088
|
* `apps/music` (task 2145). Per address: `/healthz` settles on THIS commit with the door mounted
|
|
@@ -1078,6 +1102,7 @@ export async function runWorkerSmoke(spec: WorkerSmokeSpec): Promise<number> {
|
|
|
1078
1102
|
? [...spec.urls]
|
|
1079
1103
|
: [...new Set([deployment.previewUrl, deployment.publicUrl].filter((u): u is string => Boolean(u)))];
|
|
1080
1104
|
const smoke = createSmoke({ base: deployment.publicUrl || (urls[0] as string), ...spec.smokeOptions });
|
|
1105
|
+
const want = expectedCommit(spec);
|
|
1081
1106
|
|
|
1082
1107
|
for (const base of urls) {
|
|
1083
1108
|
const host = new URL(base).hostname;
|
|
@@ -1104,7 +1129,7 @@ export async function runWorkerSmoke(spec: WorkerSmokeSpec): Promise<number> {
|
|
|
1104
1129
|
try {
|
|
1105
1130
|
const read = await steadyHealth<Record<string, unknown>>(
|
|
1106
1131
|
() => ask("/healthz", healthRead),
|
|
1107
|
-
(body) => body.commit ===
|
|
1132
|
+
(body) => body.commit === want.commit && body.apiServed === true,
|
|
1108
1133
|
spec.steady,
|
|
1109
1134
|
);
|
|
1110
1135
|
health = read.body;
|
|
@@ -1120,7 +1145,7 @@ export async function runWorkerSmoke(spec: WorkerSmokeSpec): Promise<number> {
|
|
|
1120
1145
|
health.apiServed === true,
|
|
1121
1146
|
`apiServed ${JSON.stringify(health.apiServed)}${health.apiRefusal ? ` (${health.apiRefusal})` : ""}`,
|
|
1122
1147
|
);
|
|
1123
|
-
smoke.record(`${tag} commit`, health.commit ===
|
|
1148
|
+
smoke.record(`${tag} commit`, health.commit === want.commit, `serving ${JSON.stringify(health.commit)}, ${want.said}`);
|
|
1124
1149
|
smoke.record(
|
|
1125
1150
|
`${tag} publicUrl`,
|
|
1126
1151
|
health.publicUrl === deployment.publicUrl,
|
|
@@ -1180,6 +1205,7 @@ async function accessDoorSmoke(
|
|
|
1180
1205
|
log: (line: string) => void,
|
|
1181
1206
|
): Promise<number> {
|
|
1182
1207
|
const { deployment } = spec;
|
|
1208
|
+
const want = expectedCommit(spec);
|
|
1183
1209
|
const anonymous = spec.anonymousFetch;
|
|
1184
1210
|
if (!anonymous) {
|
|
1185
1211
|
smoke.refuseEnvironment("the anonymous fetch", 'door: "access" needs `anonymousFetch` — a fetch that carries NO Access token');
|
|
@@ -1199,7 +1225,7 @@ async function accessDoorSmoke(
|
|
|
1199
1225
|
let health: Record<string, unknown>;
|
|
1200
1226
|
let status: number;
|
|
1201
1227
|
try {
|
|
1202
|
-
const read = await steadyHealth<Record<string, unknown>>(() => ask("/healthz", healthRead), (body) => body.commit ===
|
|
1228
|
+
const read = await steadyHealth<Record<string, unknown>>(() => ask("/healthz", healthRead), (body) => body.commit === want.commit, spec.steady);
|
|
1203
1229
|
health = read.body;
|
|
1204
1230
|
status = read.response.status;
|
|
1205
1231
|
} catch (error) {
|
|
@@ -1208,7 +1234,7 @@ async function accessDoorSmoke(
|
|
|
1208
1234
|
continue;
|
|
1209
1235
|
}
|
|
1210
1236
|
smoke.record(`${tag} healthz`, status === 200 && health.runtime === "worker", `${status}, runtime ${JSON.stringify(health.runtime)}`);
|
|
1211
|
-
smoke.record(`${tag} commit`, health.commit ===
|
|
1237
|
+
smoke.record(`${tag} commit`, health.commit === want.commit, `serving ${JSON.stringify(health.commit)}, ${want.said}`);
|
|
1212
1238
|
smoke.record(
|
|
1213
1239
|
`${tag} access`,
|
|
1214
1240
|
health.access === true,
|
package/src/workerDeploy.ts
CHANGED
|
@@ -60,6 +60,7 @@ import { join } from "node:path";
|
|
|
60
60
|
import { liveColumns, liveColumnsQueries, missingColumnStatements, schemaTables } from "cursedops/d1-columns";
|
|
61
61
|
import { wranglerRows } from "cursedops/d1-import";
|
|
62
62
|
import { forgeCodeRoot, forgeState } from "cursedops/roots";
|
|
63
|
+
import { DEPLOYED_COMMIT_ENV } from "cursedops/smoke";
|
|
63
64
|
import { appendLedger, deferral, readShipSetting, type ShipEventLine, type ShipNowWhere, type ShipSetting } from "cursedops/ship";
|
|
64
65
|
import { type StageLeaseResult, takeStageLease } from "cursedops/stage-lease";
|
|
65
66
|
|
|
@@ -81,7 +82,7 @@ export function parseWorkerEnv(argv: readonly string[]): WorkerEnv {
|
|
|
81
82
|
const value = (joined !== undefined ? joined.slice("--env=".length) : argv[index + 1])?.trim();
|
|
82
83
|
if (value === "production" || value === "stage") return value;
|
|
83
84
|
throw new Error(
|
|
84
|
-
`
|
|
85
|
+
`unrecognized --env ${JSON.stringify(value ?? "")} — production (no flag) or stage. ` +
|
|
85
86
|
"This refuses rather than defaulting: a typo that meant production would report success against the owner's data.",
|
|
86
87
|
);
|
|
87
88
|
}
|
|
@@ -977,7 +978,8 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
977
978
|
step(`smoke every address this deployment answers on${spec.cpuTail ? ", under a CPU tail" : ""}`);
|
|
978
979
|
enter("smoke");
|
|
979
980
|
const smoke = spec.cpuTail ? underCpuTail(spec.cpuTail, spec.env, spec.smoke) : spec.smoke;
|
|
980
|
-
|
|
981
|
+
// The commit stamped above, not the HEAD the smoke would read minutes later (`DEPLOYED_COMMIT_ENV`).
|
|
982
|
+
const smoked = deps.run(smoke, { [DEPLOYED_COMMIT_ENV]: commit });
|
|
981
983
|
if (spec.cpuTail && smoked === TAIL_UNATTACHED_EXIT) unmeasured.push("the smoke");
|
|
982
984
|
else if (smoked !== 0) {
|
|
983
985
|
return stop(
|
|
@@ -1262,7 +1264,7 @@ export interface JudgeOpts {
|
|
|
1262
1264
|
scriptName: string;
|
|
1263
1265
|
/** Paths that are never the static tier. Default {@link DEFAULT_API_PREFIXES}. */
|
|
1264
1266
|
apiPrefixes?: readonly string[];
|
|
1265
|
-
/** Replace the
|
|
1267
|
+
/** Replace the labeler entirely. Default {@link tierRoute}. */
|
|
1266
1268
|
route?: (method: string, path: string) => string | null;
|
|
1267
1269
|
}
|
|
1268
1270
|
|
package/src/workerRollback.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* ## Why this order, and why it is a library entry
|
|
17
17
|
*
|
|
18
18
|
* `patterns` wrote it first (2026-09-19) because the owner required the rollback to be ONE
|
|
19
|
-
* command — a launchd invocation nobody runs monthly and a route id nobody has
|
|
19
|
+
* command — a launchd invocation nobody runs monthly and a route id nobody has memorized are not
|
|
20
20
|
* cheap at the moment somebody needs them — and `collections`, `vault` and `music` copied it.
|
|
21
21
|
* Deleting the route while nothing listens on the Mac points the household's hostname at a dead
|
|
22
22
|
* box; so this starts the origin, WAITS for it to answer, and refuses to touch the route if it
|
|
@@ -184,7 +184,7 @@ export async function runWorkerRollback(spec: WorkerRollbackSpec, argv: readonly
|
|
|
184
184
|
const found = await findWorkerRoute(spec.api, host);
|
|
185
185
|
log(`route ${found.pattern} → ${found.route ? `${found.script} (${found.route})` : "(none — the hostname already falls through to the Mac)"}`);
|
|
186
186
|
|
|
187
|
-
log("\n▸ what D1 holds against the Mac's frozen
|
|
187
|
+
log("\n▸ what D1 holds against the Mac's frozen catalog (the writes a rollback loses):");
|
|
188
188
|
bun(["run", "scripts/worker-import.ts", "--verify-only"], spec.root);
|
|
189
189
|
|
|
190
190
|
if (argv.includes("--check")) {
|
package/src/workerSecrets.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* never noticed an extra — the drift this module ends.
|
|
31
31
|
*
|
|
32
32
|
* 🔴 **A value that is already single-quoted is written as is.** {@link envFileLines}:
|
|
33
|
-
* `collections`' seed
|
|
33
|
+
* `collections`' seed serializer returns `'{"kdf":…}'`, quoting it again wrote an escaped pair
|
|
34
34
|
* around it, `readEnvFile` stripped one layer, and the stage's master lock came up asking to
|
|
35
35
|
* ENROLL a new password (found 2026-09-22 by the stage walk, one step past sign-in).
|
|
36
36
|
*/
|