cursedops 0.15.0 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedops",
3
- "version": "0.15.0",
3
+ "version": "0.16.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": {
@@ -163,6 +163,12 @@
163
163
  "bun": "./src/publicClient.ts",
164
164
  "source": "./src/publicClient.ts",
165
165
  "import": "./src/publicClient.ts"
166
+ },
167
+ "./stage-lease": {
168
+ "types": "./src/stageLease.ts",
169
+ "bun": "./src/stageLease.ts",
170
+ "source": "./src/stageLease.ts",
171
+ "import": "./src/stageLease.ts"
166
172
  }
167
173
  },
168
174
  "bin": {
@@ -207,5 +213,9 @@
207
213
  "script": "test"
208
214
  }
209
215
  ]
216
+ },
217
+ "repository": {
218
+ "type": "git",
219
+ "url": "git+https://github.com/wizardmordamir/cursedforge-cursedops.git"
210
220
  }
211
221
  }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `cursedops/stage-lease` — an app's hosted stage has ONE holder at a time.
3
+ *
4
+ * 🔴 Why (forge task 3048, 2026-10-11 02:26–02:40Z): the ship was in roms' `test:browser`, which
5
+ * HOLDS `roms-stage` (a key minted for that run, its digest on the Worker, a throwaway Mac end
6
+ * linked). A batch in the same minutes ran `worker:deploy -- --env stage` twice and its own
7
+ * `test:browser` twice — each a new link digest on the Worker, which disconnected the ship's Mac
8
+ * end. The ship's suite went red on 12 specs that pass alone, its alone-retry refused because the
9
+ * stage now served the batch's commit, and roms did not ship. Nothing refused either side: the
10
+ * ship's lane record guards PRODUCTION deploys, not the stage.
11
+ *
12
+ * The lease is a file, `$FORGE_STATE/locks/stage/<app>.lock`, created exclusively. Whoever holds the
13
+ * stage takes it — {@link runWorkerDeploy} for every deploy (a production deploy deploys and walks
14
+ * the stage first), and each app script that holds or walks its stage on its own — and anyone else
15
+ * is REFUSED with who holds it and since when.
16
+ *
17
+ * · The holder id rides `FORGE_STAGE_LEASE` into every child, so a `test:browser` that spawns its
18
+ * own `worker-stage.ts --hold` is let through by the lease it already holds.
19
+ * · A process that runs several holders in a row (`autopilot ship run`'s lane: `worker:deploy
20
+ * -- --env stage`, then `test:browser`, then `worker:deploy`) sets `FORGE_STAGE_LEASE` AND
21
+ * `FORGE_STAGE_LEASE_PID` (its own pid) for each step: the first step's lease is recorded
22
+ * against the LANE's pid and outlives the step, so no stranger slips in between steps. The lane
23
+ * removes it when it ends ({@link dropStageLease}); a lane that dies leaves a dead pid.
24
+ * · A lease whose pid is dead, or older than {@link STAGE_LEASE_MAX_MS}, is stale and taken over.
25
+ */
26
+ import { randomUUID } from "node:crypto";
27
+ import { mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
28
+ import { dirname, join } from "node:path";
29
+
30
+ /** The holder id, inherited by every child of the process that took the lease. */
31
+ export const STAGE_LEASE_VAR = "FORGE_STAGE_LEASE";
32
+ /** The pid that OWNS a lease taken under {@link STAGE_LEASE_VAR} — a lane running several holders. */
33
+ export const STAGE_LEASE_PID_VAR = "FORGE_STAGE_LEASE_PID";
34
+ /**
35
+ * Past this, a lease is stale even with a live pid — a recycled pid must not hold a stage forever.
36
+ * The longest honest holder is a ship lane: roms' suite is ~8 min, a ship run ~25 min.
37
+ */
38
+ export const STAGE_LEASE_MAX_MS = 2 * 60 * 60 * 1000;
39
+ /** A lease file that does not parse is a writer mid-write — stale only after this long. */
40
+ const UNREADABLE_GRACE_MS = 10_000;
41
+
42
+ export interface StageLease {
43
+ holder: string;
44
+ pid: number;
45
+ /** ISO time it was taken. */
46
+ started: string;
47
+ /** What took it — `roms worker:deploy --env stage`, `roms test:browser`. */
48
+ what: string;
49
+ }
50
+
51
+ export interface StageLeaseSpec {
52
+ /** The generation's state root (`forgeState`). */
53
+ state: string;
54
+ /** The app whose stage this is — `roms`. */
55
+ app: string;
56
+ what: string;
57
+ }
58
+
59
+ export interface StageLeaseDeps {
60
+ /** Read for the holder id, and WRITTEN with it when a fresh lease is taken. Default `process.env`. */
61
+ env?: NodeJS.ProcessEnv;
62
+ pid?: number;
63
+ now?: () => number;
64
+ alive?: (pid: number) => boolean;
65
+ }
66
+
67
+ export type StageLeaseResult =
68
+ | { ok: true; holder: string; inherited: boolean; release: () => void }
69
+ | { ok: false; refusal: string; held: StageLease | null };
70
+
71
+ export function stageLeasePath(state: string, app: string): string {
72
+ return join(state, "locks", "stage", `${app}.lock`);
73
+ }
74
+
75
+ /** `kill -0`: ESRCH is dead; EPERM is a live process that is not ours. */
76
+ export function pidAlive(pid: number): boolean {
77
+ if (!Number.isInteger(pid) || pid <= 0) return false;
78
+ try {
79
+ process.kill(pid, 0);
80
+ return true;
81
+ } catch (e) {
82
+ return (e as NodeJS.ErrnoException).code === "EPERM";
83
+ }
84
+ }
85
+
86
+ function readLease(path: string): StageLease | null {
87
+ try {
88
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<StageLease>;
89
+ if (typeof parsed.holder !== "string" || typeof parsed.pid !== "number" || typeof parsed.started !== "string") return null;
90
+ return { holder: parsed.holder, pid: parsed.pid, started: parsed.started, what: String(parsed.what ?? "") };
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function minutes(ms: number): string {
97
+ return ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : `${Math.round(ms / 60_000)}m`;
98
+ }
99
+
100
+ /**
101
+ * Take `<app>`'s stage, or be refused. A fresh lease puts its holder id in `deps.env` so children
102
+ * inherit it; `release` removes the file only when this process created it for itself (a lease
103
+ * recorded against a lane's pid is the lane's to drop).
104
+ */
105
+ export function takeStageLease(spec: StageLeaseSpec, deps: StageLeaseDeps = {}): StageLeaseResult {
106
+ const env = deps.env ?? process.env;
107
+ const now = deps.now ?? Date.now;
108
+ const alive = deps.alive ?? pidAlive;
109
+ const self = deps.pid ?? process.pid;
110
+ const named = env[STAGE_LEASE_VAR]?.trim() || "";
111
+ const lanePid = Number(env[STAGE_LEASE_PID_VAR]);
112
+ const owner = named && Number.isInteger(lanePid) && lanePid > 0 ? lanePid : self;
113
+ const path = stageLeasePath(spec.state, spec.app);
114
+ mkdirSync(dirname(path), { recursive: true });
115
+
116
+ for (let attempt = 0; attempt < 3; attempt++) {
117
+ const holder = named || randomUUID();
118
+ const lease: StageLease = { holder, pid: owner, started: new Date(now()).toISOString(), what: spec.what };
119
+ try {
120
+ writeFileSync(path, `${JSON.stringify(lease)}\n`, { flag: "wx" });
121
+ env[STAGE_LEASE_VAR] = holder;
122
+ const ours = owner === self;
123
+ return {
124
+ ok: true,
125
+ holder,
126
+ inherited: false,
127
+ release: () => {
128
+ if (ours) dropStageLease(spec.state, spec.app, holder);
129
+ },
130
+ };
131
+ } catch (e) {
132
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
133
+ }
134
+ const held = readLease(path);
135
+ if (!held) {
136
+ let age = 0;
137
+ try {
138
+ age = now() - statSync(path).mtimeMs;
139
+ } catch {
140
+ continue; // gone between the two calls — try again
141
+ }
142
+ if (age < UNREADABLE_GRACE_MS) {
143
+ return { ok: false, held: null, refusal: `${spec.app}'s stage lease (${path}) is being written right now — try again in a few seconds.` };
144
+ }
145
+ dropStageLease(spec.state, spec.app, null);
146
+ continue;
147
+ }
148
+ if (named && held.holder === named) return { ok: true, holder: named, inherited: true, release: () => {} };
149
+ const age = now() - Date.parse(held.started);
150
+ if (!alive(held.pid) || !(age < STAGE_LEASE_MAX_MS)) {
151
+ dropStageLease(spec.state, spec.app, held.holder);
152
+ continue;
153
+ }
154
+ return {
155
+ ok: false,
156
+ held,
157
+ refusal:
158
+ `${spec.app}'s hosted stage is held by \`${held.what || "an unnamed holder"}\` (pid ${held.pid}, since ${held.started}, ${minutes(age)} ago). ` +
159
+ `A second holder would replace its Worker or its link key under it and turn its run red for nothing. ` +
160
+ `Wait for it to finish, then run this again. (The lease: ${path} — it is taken over by itself once pid ${held.pid} has exited.)`,
161
+ };
162
+ }
163
+ return { ok: false, held: readLease(path), refusal: `${spec.app}'s stage lease (${path}) changed hands three times while this run tried to take it — try again.` };
164
+ }
165
+
166
+ /** Remove `<app>`'s lease — only if `holder` still holds it (`null`: whatever is there). */
167
+ export function dropStageLease(state: string, app: string, holder: string | null): boolean {
168
+ const path = stageLeasePath(state, app);
169
+ if (holder !== null && readLease(path)?.holder !== holder) return false;
170
+ try {
171
+ unlinkSync(path);
172
+ return true;
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
@@ -54,12 +54,14 @@
54
54
  * refusal without wrangler, git or a network.
55
55
  */
56
56
  import { spawn, spawnSync } from "node:child_process";
57
- import { existsSync, readdirSync, readFileSync } from "node:fs";
57
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
58
+ import { tmpdir } from "node:os";
58
59
  import { join } from "node:path";
59
60
  import { liveColumns, liveColumnsQueries, missingColumnStatements, schemaTables } from "cursedops/d1-columns";
60
61
  import { wranglerRows } from "cursedops/d1-import";
61
62
  import { forgeCodeRoot, forgeState } from "cursedops/roots";
62
63
  import { appendLedger, deferral, readShipSetting, type ShipEventLine, type ShipNowWhere, type ShipSetting } from "cursedops/ship";
64
+ import { type StageLeaseResult, takeStageLease } from "cursedops/stage-lease";
63
65
 
64
66
  /** The two deployments every Worker app here has. */
65
67
  export type WorkerEnv = "production" | "stage";
@@ -338,6 +340,25 @@ export interface DeployDeps {
338
340
  shipWhere?: ShipNowWhere;
339
341
  /** Where a deferred production deploy's `ship` line goes. {@link workerDeployDeps} appends it to the ledger. */
340
342
  recordShip?: (line: ShipEventLine) => void;
343
+ /**
344
+ * Take the app's hosted stage for the whole deploy (`./stageLease.ts`) — every deploy touches the
345
+ * stage, a production one deploys and walks it first. {@link workerDeployDeps} takes the
346
+ * generation's lease; absent (or `null` with no state root), nothing is leased.
347
+ */
348
+ lease?: (app: string, what: string) => StageLeaseResult | null;
349
+ /**
350
+ * A fresh file for wrangler's ND-JSON output (`WRANGLER_OUTPUT_FILE_PATH`), which is where a failed
351
+ * `wrangler deploy` says WHY in a form a program can read ({@link transientDeployFailure}).
352
+ * {@link workerDeployDeps} makes one under the OS temp dir; absent, a failed deploy is not retried.
353
+ */
354
+ wranglerOutput?: () => WranglerOutputFile;
355
+ }
356
+
357
+ /** One `wrangler` run's output file: the env that points wrangler at it, its text, and its removal. */
358
+ export interface WranglerOutputFile {
359
+ env: Record<string, string>;
360
+ read: () => string;
361
+ dispose: () => void;
341
362
  }
342
363
 
343
364
  /**
@@ -413,6 +434,15 @@ export function workerDeployDeps(cwd: string): DeployDeps {
413
434
  },
414
435
  git: (args) => (spawnSync("git", [...args], { cwd, encoding: "utf8" }).stdout ?? "").trim(),
415
436
  read: (path) => readFileSync(join(cwd, path), "utf8"),
437
+ wranglerOutput: () => {
438
+ const dir = mkdtempSync(join(tmpdir(), "cursedops-wrangler-"));
439
+ const path = join(dir, "output.ndjson");
440
+ return {
441
+ env: { WRANGLER_OUTPUT_FILE_PATH: path },
442
+ read: () => (existsSync(path) ? readFileSync(path, "utf8") : ""),
443
+ dispose: () => rmSync(dir, { recursive: true, force: true }),
444
+ };
445
+ },
416
446
  record: (line) => {
417
447
  recordDeployEvent(forgeState(cwd), line);
418
448
  },
@@ -421,6 +451,10 @@ export function workerDeployDeps(cwd: string): DeployDeps {
421
451
  recordShip: (line) => {
422
452
  appendLedger(forgeState(cwd), line);
423
453
  },
454
+ lease: (app, what) => {
455
+ const state = forgeState(cwd);
456
+ return state ? takeStageLease({ state, app, what }) : null;
457
+ },
424
458
  };
425
459
  }
426
460
 
@@ -561,6 +595,38 @@ export function wranglerError(stdout: string): { reason: string; api: boolean }
561
595
  return { reason, api: name === "APIError" || (kind === "error" && /cloudflare api/i.test(reason)) };
562
596
  }
563
597
 
598
+ /**
599
+ * Why a failed `wrangler deploy` is worth ONE more try — or `null` when it would fail the same way.
600
+ *
601
+ * Measured 2026-10-11 (forge task 3053): roms' production deploy uploaded the Worker and its routes,
602
+ * then Cloudflare answered the cron-schedules PUT with `Internal authentication error: internal
603
+ * server error [code: 10500]`. wrangler exited 1, the ship's roms lane went red, and the ship batch
604
+ * re-ran a 50-minute `ship run` to redo a step that a second request would have done. A deploy is
605
+ * idempotent — the same script, the same triggers — so a Cloudflare-side failure is retried once.
606
+ * A bundling error, a refused binding or a script that throws at startup (10021) is not: wrangler's
607
+ * own `command-failed` line ({@link WranglerOutputFile}) must name the server's fault or a retry-after.
608
+ */
609
+ export function transientDeployFailure(ndjson: string): string | null {
610
+ type Failed = { code?: unknown; message?: unknown; retry_after_ms?: unknown };
611
+ let failed: Failed | null = null;
612
+ for (const line of ndjson.split("\n")) {
613
+ if (!line.trim()) continue;
614
+ try {
615
+ const entry = JSON.parse(line) as (Failed & { type?: unknown }) | null;
616
+ if (entry?.type === "command-failed") failed = entry;
617
+ } catch {
618
+ // a torn line from a killed run — the ones around it still count
619
+ }
620
+ }
621
+ if (!failed) return null;
622
+ const message = typeof failed.message === "string" ? failed.message : "";
623
+ const first = message.split("\n").find((l) => l.trim()) ?? "";
624
+ const reason = (detail: string) => `${first.trim() || "wrangler deploy failed"} — ${detail}`;
625
+ if (typeof failed.retry_after_ms === "number") return reason(`Cloudflare asked for a retry after ${failed.retry_after_ms} ms`);
626
+ const server = message.match(/internal server error|\[code: (?:10500|10013|7403)\]|\b50[234]\b|ETIMEDOUT|ECONNRESET|socket hang up|fetch failed/i);
627
+ return server ? reason(`"${server[0]}" is Cloudflare's side`) : null;
628
+ }
629
+
564
630
  /**
565
631
  * Did one schema chunk APPLY? Its exit code alone does not say.
566
632
  *
@@ -707,7 +773,9 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
707
773
  const commit = deps.git(["rev-parse", "HEAD"]);
708
774
  const porcelain = deps.git(["status", "--porcelain"]);
709
775
  const dirty = porcelain.length > 0;
776
+ let release = (): void => {};
710
777
  const finish = (result: DeployResult): DeployResult => {
778
+ release();
711
779
  enter("");
712
780
  try {
713
781
  deps.record?.({
@@ -755,6 +823,11 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
755
823
  if (spec.env === "production" && dirty) {
756
824
  return stop("clean-tree", `the tree is dirty — production would report ${commit.slice(0, 8)} and run something else:\n${porcelain}`);
757
825
  }
826
+ // 🔴 One holder per hosted stage (task 3048): a stage deploy under the ship's held suite swapped
827
+ // the Worker and its link key under it. Taken before anything builds, held until `finish`.
828
+ const lease = deps.lease?.(spec.app, `${spec.app} worker:deploy${spec.env === "stage" ? " --env stage" : ""} @ ${commit.slice(0, 8)}`);
829
+ if (lease && !lease.ok) return stop("stage-lease", lease.refusal);
830
+ if (lease?.ok) release = lease.release;
758
831
  for (const refusal of spec.beforeStage ?? []) {
759
832
  const why = refusal();
760
833
  if (why) return stop("refusal", why);
@@ -877,7 +950,22 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
877
950
  ? ["--var", `${spec.stampVars.commit}:${commit}`, "--var", `${spec.stampVars.dirty}:${shipsDirty ? "true" : "false"}`]
878
951
  : [];
879
952
  enter("upload");
880
- if (deps.run(["bunx", "wrangler", "deploy", ...envArgs, ...stamp], spec.credential) !== 0) return stop("deploy", "wrangler deploy failed.");
953
+ const deployArgv = ["bunx", "wrangler", "deploy", ...envArgs, ...stamp];
954
+ const deployOnce = (): { code: number; transient: string | null } => {
955
+ const output = deps.wranglerOutput?.();
956
+ try {
957
+ const code = deps.run(deployArgv, { ...spec.credential, ...output?.env });
958
+ return { code, transient: code !== 0 && output ? transientDeployFailure(output.read()) : null };
959
+ } finally {
960
+ output?.dispose();
961
+ }
962
+ };
963
+ let deployed = deployOnce();
964
+ if (deployed.code !== 0 && deployed.transient) {
965
+ error(` 🟡 ${deployed.transient} — retrying the deploy once`);
966
+ deployed = deployOnce();
967
+ }
968
+ if (deployed.code !== 0) return stop("deploy", "wrangler deploy failed.");
881
969
  if (spec.secrets) {
882
970
  step("attach the secrets — exactly this deployment's set, over a pipe");
883
971
  enter("secrets");