cursedops 0.15.0 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedops",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
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
+ }
@@ -60,6 +60,7 @@ import { liveColumns, liveColumnsQueries, missingColumnStatements, schemaTables
60
60
  import { wranglerRows } from "cursedops/d1-import";
61
61
  import { forgeCodeRoot, forgeState } from "cursedops/roots";
62
62
  import { appendLedger, deferral, readShipSetting, type ShipEventLine, type ShipNowWhere, type ShipSetting } from "cursedops/ship";
63
+ import { type StageLeaseResult, takeStageLease } from "cursedops/stage-lease";
63
64
 
64
65
  /** The two deployments every Worker app here has. */
65
66
  export type WorkerEnv = "production" | "stage";
@@ -338,6 +339,12 @@ export interface DeployDeps {
338
339
  shipWhere?: ShipNowWhere;
339
340
  /** Where a deferred production deploy's `ship` line goes. {@link workerDeployDeps} appends it to the ledger. */
340
341
  recordShip?: (line: ShipEventLine) => void;
342
+ /**
343
+ * Take the app's hosted stage for the whole deploy (`./stageLease.ts`) — every deploy touches the
344
+ * stage, a production one deploys and walks it first. {@link workerDeployDeps} takes the
345
+ * generation's lease; absent (or `null` with no state root), nothing is leased.
346
+ */
347
+ lease?: (app: string, what: string) => StageLeaseResult | null;
341
348
  }
342
349
 
343
350
  /**
@@ -421,6 +428,10 @@ export function workerDeployDeps(cwd: string): DeployDeps {
421
428
  recordShip: (line) => {
422
429
  appendLedger(forgeState(cwd), line);
423
430
  },
431
+ lease: (app, what) => {
432
+ const state = forgeState(cwd);
433
+ return state ? takeStageLease({ state, app, what }) : null;
434
+ },
424
435
  };
425
436
  }
426
437
 
@@ -707,7 +718,9 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
707
718
  const commit = deps.git(["rev-parse", "HEAD"]);
708
719
  const porcelain = deps.git(["status", "--porcelain"]);
709
720
  const dirty = porcelain.length > 0;
721
+ let release = (): void => {};
710
722
  const finish = (result: DeployResult): DeployResult => {
723
+ release();
711
724
  enter("");
712
725
  try {
713
726
  deps.record?.({
@@ -755,6 +768,11 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
755
768
  if (spec.env === "production" && dirty) {
756
769
  return stop("clean-tree", `the tree is dirty — production would report ${commit.slice(0, 8)} and run something else:\n${porcelain}`);
757
770
  }
771
+ // 🔴 One holder per hosted stage (task 3048): a stage deploy under the ship's held suite swapped
772
+ // the Worker and its link key under it. Taken before anything builds, held until `finish`.
773
+ const lease = deps.lease?.(spec.app, `${spec.app} worker:deploy${spec.env === "stage" ? " --env stage" : ""} @ ${commit.slice(0, 8)}`);
774
+ if (lease && !lease.ok) return stop("stage-lease", lease.refusal);
775
+ if (lease?.ok) release = lease.release;
758
776
  for (const refusal of spec.beforeStage ?? []) {
759
777
  const why = refusal();
760
778
  if (why) return stop("refusal", why);