feature-factory 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/LICENSE +21 -0
- package/README.md +278 -0
- package/WORKFLOW.md +2001 -0
- package/agents/backend-builder.md +102 -0
- package/agents/codebase-researcher.md +122 -0
- package/agents/design-interpreter.md +71 -0
- package/agents/frontend-builder.md +110 -0
- package/agents/implementation-validator.md +78 -0
- package/agents/spec-writer.md +95 -0
- package/agents/story-reader.md +70 -0
- package/agents/story-writer.md +62 -0
- package/agents/test-verifier.md +94 -0
- package/agents/work-decomposer.md +188 -0
- package/agents/work-reviewer.md +131 -0
- package/bin/factory.js +1499 -0
- package/bin/init-publication.js +73 -0
- package/core/atomic-write.js +135 -0
- package/core/contracts.js +394 -0
- package/core/effective-push.js +88 -0
- package/core/executable.js +29 -0
- package/core/run-lock.js +269 -0
- package/core/write-core.js +146 -0
- package/observe/index.js +366 -0
- package/observe/repair-record.js +300 -0
- package/observe/repair-reverification.js +169 -0
- package/observe/repository-config.js +56 -0
- package/observe/review.js +362 -0
- package/package.json +35 -0
- package/state/index.js +64 -0
- package/state/review-archive.js +48 -0
- package/state/schema.js +339 -0
- package/state/session-lock.js +104 -0
- package/state/transition.js +26 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const ARITY_ERROR = "factory effective-push: expected exactly three positional arguments: <bootstrap|check> <operator-repository> <sandbox-repository>";
|
|
4
|
+
const EMPTY_ERROR = "factory effective-push: positional arguments must be non-empty";
|
|
5
|
+
const OPERATION_ERROR = "factory effective-push: operation must be bootstrap or check";
|
|
6
|
+
|
|
7
|
+
function failure(message) {
|
|
8
|
+
return new Error(message);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function execute(run, args) {
|
|
12
|
+
// No `encoding`: stdout stays a Buffer. Decoding to utf8 replaces every distinct
|
|
13
|
+
// invalid byte sequence with the same U+FFFD, so two unequal targets could compare
|
|
14
|
+
// equal here while `git push` used the original raw bytes. A local-path remote on
|
|
15
|
+
// Unix may legitimately hold non-UTF-8 bytes, so this is reachable, not theoretical.
|
|
16
|
+
return run("git", args, {
|
|
17
|
+
shell: false,
|
|
18
|
+
env: { ...process.env, LC_ALL: "C" },
|
|
19
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function capture(run, repository) {
|
|
24
|
+
let result;
|
|
25
|
+
try {
|
|
26
|
+
result = execute(run, ["-C", repository, "remote", "get-url", "--push", "origin"]);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (!result || result.error || result.signal !== null && result.signal !== undefined
|
|
31
|
+
|| result.status !== 0 || result.stdout === null || result.stdout === undefined) return null;
|
|
32
|
+
// A test double may still hand back a string; normalise to bytes either way.
|
|
33
|
+
const raw = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(String(result.stdout), "utf8");
|
|
34
|
+
// Exactly one LF, because git contributes exactly one record terminator. Stripping every
|
|
35
|
+
// trailing LF would make a target that itself ends in LF indistinguishable from one that
|
|
36
|
+
// does not: operator bytes `path\n` are emitted as `path\n\n` and sandbox bytes `path` as
|
|
37
|
+
// `path\n`, and a greedy strip reduces both to `path` and accepts two unequal targets.
|
|
38
|
+
// Absent terminator means this is not the output shape being parsed, so fail closed.
|
|
39
|
+
if (raw.length === 0 || raw[raw.length - 1] !== 0x0a) return null;
|
|
40
|
+
const target = raw.subarray(0, raw.length - 1);
|
|
41
|
+
return target.length > 0 ? Buffer.from(target) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// argv is bytes-as-string, so a target that does not survive a utf8 round trip cannot be
|
|
45
|
+
// placed there without silently altering it. Refuse instead of configuring something other
|
|
46
|
+
// than what was captured.
|
|
47
|
+
function argvSafe(target) {
|
|
48
|
+
return Buffer.from(target.toString("utf8"), "utf8").equals(target);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function configure(run, repository, target) {
|
|
52
|
+
try {
|
|
53
|
+
return execute(run, ["-C", repository, "config", "--replace-all", "remote.origin.pushurl", target])?.status === 0;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function enforceEffectivePushTarget(positionals, { spawnSync: run = spawnSync } = {}) {
|
|
60
|
+
if (!Array.isArray(positionals) || positionals.length !== 3) throw failure(ARITY_ERROR);
|
|
61
|
+
if (positionals.some((value) => typeof value !== "string" || value.length === 0)) throw failure(EMPTY_ERROR);
|
|
62
|
+
const [operation, operatorRepository, sandboxRepository] = positionals;
|
|
63
|
+
if (operation !== "bootstrap" && operation !== "check") throw failure(OPERATION_ERROR);
|
|
64
|
+
|
|
65
|
+
let operatorTarget = capture(run, operatorRepository);
|
|
66
|
+
if (operatorTarget === null) {
|
|
67
|
+
throw failure(`factory sandbox: operator effective push target unavailable; sandbox retained at ${sandboxRepository}`);
|
|
68
|
+
}
|
|
69
|
+
if (operation === "bootstrap") {
|
|
70
|
+
if (!argvSafe(operatorTarget)) {
|
|
71
|
+
throw failure(`factory sandbox: operator effective push target is not representable for configuration; sandbox retained at ${sandboxRepository}`);
|
|
72
|
+
}
|
|
73
|
+
if (!configure(run, sandboxRepository, operatorTarget.toString("utf8"))) {
|
|
74
|
+
throw failure(`factory sandbox: sandbox effective push target unavailable at ${sandboxRepository}`);
|
|
75
|
+
}
|
|
76
|
+
operatorTarget = capture(run, operatorRepository);
|
|
77
|
+
if (operatorTarget === null) {
|
|
78
|
+
throw failure(`factory sandbox: operator effective push target unavailable; sandbox retained at ${sandboxRepository}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const sandboxTarget = capture(run, sandboxRepository);
|
|
82
|
+
if (sandboxTarget === null) {
|
|
83
|
+
throw failure(`factory sandbox: sandbox effective push target unavailable at ${sandboxRepository}`);
|
|
84
|
+
}
|
|
85
|
+
if (!sandboxTarget.equals(operatorTarget)) {
|
|
86
|
+
throw failure(`factory sandbox: sandbox effective push target does not match operator target; sandbox retained at ${sandboxRepository}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
2
|
+
import { posix } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Resolves argv[0] the way observe's shell-free spawn does, so a passing seed check cannot be followed by a
|
|
5
|
+
// spawn that fails for the same reason. POSIX only, and that boundary is enforced here rather than inferred:
|
|
6
|
+
// a first version approximated Windows lookup and did not match it, and simply deleting the branch was worse
|
|
7
|
+
// -- `observe` spawns cross-platform, nothing declares this package POSIX-only, so on Windows the check would
|
|
8
|
+
// have split `PATH` on `:` and disagreed with the spawn it exists to predict. A guard that cannot establish
|
|
9
|
+
// its claim must refuse, not guess.
|
|
10
|
+
export function resolveSpawnExecutable(argv0, options = {}) {
|
|
11
|
+
const platform = options.platform ?? process.platform;
|
|
12
|
+
if (platform === "win32") return { ok: false, reason: "unsupported-platform", platform };
|
|
13
|
+
const cwd = options.cwd ?? process.cwd(), env = options.env ?? process.env;
|
|
14
|
+
const stat = options.stat ?? statSync, access = options.access ?? accessSync;
|
|
15
|
+
const defaultPath = options.posixDefaultPath ?? "/usr/bin:/bin", direct = argv0.includes("/");
|
|
16
|
+
const searchPath = Object.hasOwn(env, "PATH") ? env.PATH : null;
|
|
17
|
+
const source = searchPath === null ? `the POSIX default search path ${defaultPath}` : "POSIX PATH";
|
|
18
|
+
// An empty PATH entry means the current directory, which is why `entry || "."` is not a no-op.
|
|
19
|
+
const candidates = direct ? [posix.isAbsolute(argv0) ? argv0 : posix.resolve(cwd, argv0)]
|
|
20
|
+
: (searchPath ?? defaultPath).split(":").map((entry) => posix.join(posix.resolve(cwd, entry || "."), argv0));
|
|
21
|
+
for (const candidate of candidates) {
|
|
22
|
+
try {
|
|
23
|
+
if (!stat(candidate).isFile()) continue;
|
|
24
|
+
access(candidate, constants.X_OK);
|
|
25
|
+
return { ok: true, path: candidate, source: direct ? "its direct path" : source };
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
return { ok: false, reason: "not-executable", source: direct ? "its direct path" : source };
|
|
29
|
+
}
|
package/core/run-lock.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// Ported unchanged from src/run-state.js:120-485 (the run-json lock cluster).
|
|
2
|
+
// The 0c spike reused this lock as-is, which is why it is lifted rather than
|
|
3
|
+
// rewritten: hand-rolling lock reclaim and steal logic is where subtle crash bugs
|
|
4
|
+
// live. Only the imports and the extracted constants below are new.
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
|
+
import { lstat, mkdir, open, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { hostname } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 1000;
|
|
12
|
+
const DEFAULT_LOCK_RETRY_DELAY_MS = 10;
|
|
13
|
+
const DEFAULT_STALE_LOCK_MS = 60000;
|
|
14
|
+
const DEFAULT_MISSING_OWNER_STEAL_MS = 5000;
|
|
15
|
+
const LOCK_DIR = "run-json.lock";
|
|
16
|
+
const LOCK_OWNER_FILE = "owner.json";
|
|
17
|
+
|
|
18
|
+
export async function withRunJsonLock(runDir, fn, options = {}) {
|
|
19
|
+
if (typeof fn !== "function") throw new Error("withRunJsonLock requires a callback");
|
|
20
|
+
const { onBeforeSteal } = options;
|
|
21
|
+
if (onBeforeSteal !== undefined && typeof onBeforeSteal !== "function") {
|
|
22
|
+
throw new Error("onBeforeSteal must be a function");
|
|
23
|
+
}
|
|
24
|
+
const timeoutMs = normalizePositiveInteger(options.timeoutMs, DEFAULT_LOCK_TIMEOUT_MS);
|
|
25
|
+
normalizePositiveInteger(options.staleLockMs, DEFAULT_STALE_LOCK_MS);
|
|
26
|
+
normalizePositiveInteger(options.missingOwnerStealMs, DEFAULT_MISSING_OWNER_STEAL_MS);
|
|
27
|
+
const lockDir = join(runDir, LOCK_DIR);
|
|
28
|
+
const ownerPath = join(lockDir, LOCK_OWNER_FILE);
|
|
29
|
+
const deadline = Date.now() + timeoutMs;
|
|
30
|
+
let stealAttempted = false;
|
|
31
|
+
let createdIdentity = null;
|
|
32
|
+
let owner = null;
|
|
33
|
+
let ownerPublished = false;
|
|
34
|
+
let publishedEvidence = null;
|
|
35
|
+
|
|
36
|
+
while (true) {
|
|
37
|
+
try {
|
|
38
|
+
await mkdir(lockDir);
|
|
39
|
+
createdIdentity = await lockDirectoryIdentity(lockDir);
|
|
40
|
+
break;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error?.code !== "EEXIST") throw error;
|
|
43
|
+
if (!stealAttempted) {
|
|
44
|
+
const observedIdentity = await lockDirectoryIdentity(lockDir);
|
|
45
|
+
const observedEvidence = await readLockOwnerEvidence(ownerPath);
|
|
46
|
+
if (canStealRunJsonLock(observedEvidence?.owner, options)) {
|
|
47
|
+
stealAttempted = true;
|
|
48
|
+
if (observedIdentity && await stealByRename(runDir, lockDir, observedIdentity, observedEvidence, onBeforeSteal)) continue;
|
|
49
|
+
} else if (!observedEvidence && await ownerlessLockIsReclaimable(lockDir, ownerPath, options)) {
|
|
50
|
+
stealAttempted = true;
|
|
51
|
+
if (observedIdentity && await stealByRename(runDir, lockDir, observedIdentity, null, onBeforeSteal)) continue;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (Date.now() >= deadline) throw new Error(`timed out waiting for run.json lock at ${lockDir}`);
|
|
55
|
+
await delay(Math.min(DEFAULT_LOCK_RETRY_DELAY_MS, Math.max(1, deadline - Date.now())));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
owner = { pid: process.pid, hostname: hostname(), acquired_at: new Date().toISOString(), nonce: randomUUID() };
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
if (!sameLockDirectoryIdentity(createdIdentity, await lockDirectoryIdentity(lockDir))) {
|
|
63
|
+
throw new Error(`run.json lock ownership changed before owner publication at ${lockDir}`);
|
|
64
|
+
}
|
|
65
|
+
await writeFile(ownerPath, `${JSON.stringify(owner, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
|
66
|
+
publishedEvidence = await readLockOwnerEvidence(ownerPath);
|
|
67
|
+
if (!sameLockOwner(owner, publishedEvidence?.owner)) throw new Error(`run.json lock owner publication failed at ${lockDir}`);
|
|
68
|
+
ownerPublished = true;
|
|
69
|
+
return await fn({ lock_dir: lockDir, owner });
|
|
70
|
+
} finally {
|
|
71
|
+
if (ownerPublished) {
|
|
72
|
+
await releaseOwnedRunJsonLock(runDir, lockDir, createdIdentity, publishedEvidence);
|
|
73
|
+
} else if (!ownerPublished && !(await lockOwnerEntryExists(ownerPath))) {
|
|
74
|
+
await quarantineAndRemoveOwnedLock(runDir, lockDir, createdIdentity);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function canStealRunJsonLock(owner, options = {}) {
|
|
80
|
+
return isDurableLockOwner(owner) && inspectLockOwnerLiveness(owner, options) === "dead";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function readLockOwnerEvidence(ownerPath) {
|
|
84
|
+
let handle;
|
|
85
|
+
try {
|
|
86
|
+
handle = await open(ownerPath, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
87
|
+
const parsed = JSON.parse(await handle.readFile("utf8"));
|
|
88
|
+
if (!isDurableLockOwner(parsed)) return null;
|
|
89
|
+
const value = await handle.stat();
|
|
90
|
+
return { owner: parsed, identity: { dev: value.dev, ino: value.ino } };
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
} finally {
|
|
94
|
+
await handle?.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Stealing a stale lock is one atomic rename.
|
|
99
|
+
//
|
|
100
|
+
// A nonce-keyed reclaim-claim protocol used to guard this, re-verifying a claim
|
|
101
|
+
// file four times across the steal. It was unnecessary: `rename` is atomic, so of
|
|
102
|
+
// two racers exactly one succeeds and the loser gets ENOENT and retries the
|
|
103
|
+
// acquire loop. More importantly the lock is not the correctness boundary - the
|
|
104
|
+
// write core re-reads run.json and deep-compares it immediately before its own
|
|
105
|
+
// rename, so even a wrongly stolen lock cannot produce a lost update. The
|
|
106
|
+
// ceremony was protecting the lock as though the lock were the invariant.
|
|
107
|
+
//
|
|
108
|
+
// The identity check still earns its place: it refuses to rename away a lock that
|
|
109
|
+
// is no longer the one we judged stale, which is the case where another process
|
|
110
|
+
// already stole and re-acquired.
|
|
111
|
+
async function stealByRename(runDir, lockDir, observedIdentity, observedEvidence, onBeforeSteal) {
|
|
112
|
+
if (onBeforeSteal) await onBeforeSteal({ runDir, lockDir, owner: observedEvidence?.owner ?? null });
|
|
113
|
+
// The owner must still be the one we judged stale, and the directory's dev/ino cannot establish
|
|
114
|
+
// that: Linux reuses inode numbers, so a lock deleted and recreated here presents the identity we
|
|
115
|
+
// recorded and the post-rename check sees nothing wrong while a live lock is renamed away. The
|
|
116
|
+
// nonce is the discriminator. The note that once stood here — a pre-check is undetectable — was
|
|
117
|
+
// falsified only on APFS, which does not reuse the inode; CI failed on Linux and passed on macOS.
|
|
118
|
+
// The post-rename check stays, the two together are narrower, and the write core is still the
|
|
119
|
+
// real boundary, so the residual check-then-act window cannot produce a lost update.
|
|
120
|
+
if (observedEvidence
|
|
121
|
+
&& !sameLockOwnerEvidence(observedEvidence, await readLockOwnerEvidence(join(lockDir, LOCK_OWNER_FILE)))) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
let quarantine;
|
|
125
|
+
try {
|
|
126
|
+
quarantine = await renameOwnedLockToQuarantine(runDir, lockDir, observedIdentity);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
// Losing the race is a retry, not a failure: ENOENT is the rename losing the path, ELOCKIDENTITY
|
|
129
|
+
// is noticing the winner before renaming. Only ENOENT was handled, so the second case failed the
|
|
130
|
+
// whole run — rare until the owner pre-check widened that window, and then CI hit it.
|
|
131
|
+
if (error?.code === "ENOENT" || error?.code === "ELOCKIDENTITY") return false;
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function releaseOwnedRunJsonLock(runDir, lockDir, expectedIdentity, expectedEvidence) {
|
|
139
|
+
if (!sameLockDirectoryIdentity(expectedIdentity, await lockDirectoryIdentity(lockDir))) return;
|
|
140
|
+
if (!sameLockOwnerEvidence(expectedEvidence, await readLockOwnerEvidence(join(lockDir, LOCK_OWNER_FILE)))) return;
|
|
141
|
+
const quarantine = await renameOwnedLockToQuarantine(runDir, lockDir, expectedIdentity);
|
|
142
|
+
if (!sameLockOwnerEvidence(expectedEvidence, await readLockOwnerEvidence(join(quarantine, LOCK_OWNER_FILE)))) {
|
|
143
|
+
throw new Error(`run.json lock cleanup identity changed at ${quarantine}`);
|
|
144
|
+
}
|
|
145
|
+
if (!sameLockDirectoryIdentity(expectedIdentity, await lockDirectoryIdentity(quarantine))
|
|
146
|
+
|| !sameLockOwnerEvidence(expectedEvidence, await readLockOwnerEvidence(join(quarantine, LOCK_OWNER_FILE)))) return;
|
|
147
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function quarantineAndRemoveOwnedLock(runDir, lockDir, expectedIdentity) {
|
|
151
|
+
if (!sameLockDirectoryIdentity(expectedIdentity, await lockDirectoryIdentity(lockDir))) return;
|
|
152
|
+
const quarantine = await renameOwnedLockToQuarantine(runDir, lockDir, expectedIdentity);
|
|
153
|
+
if (!sameLockDirectoryIdentity(expectedIdentity, await lockDirectoryIdentity(quarantine))) return;
|
|
154
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function renameOwnedLockToQuarantine(runDir, lockDir, expectedIdentity) {
|
|
158
|
+
if (!sameLockDirectoryIdentity(expectedIdentity, await lockDirectoryIdentity(lockDir))) {
|
|
159
|
+
// Coded so a *steal* can treat it as losing a race and retry. Release and cleanup check identity
|
|
160
|
+
// first, so for them it stays the error it is.
|
|
161
|
+
throw Object.assign(new Error(`run.json lock directory identity changed at ${lockDir}`), { code: "ELOCKIDENTITY" });
|
|
162
|
+
}
|
|
163
|
+
const quarantine = join(runDir, `.run-json.lock-quarantine-${randomUUID()}`);
|
|
164
|
+
await rename(lockDir, quarantine);
|
|
165
|
+
if (!sameLockDirectoryIdentity(expectedIdentity, await lockDirectoryIdentity(quarantine))) {
|
|
166
|
+
throw new Error(`run.json lock quarantine identity changed at ${quarantine}`);
|
|
167
|
+
}
|
|
168
|
+
return quarantine;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Staleness is decided by TTL on `acquired_at`, not process identity: this lock spans
|
|
172
|
+
// one transition, so exceeding the TTL means its holder is gone. Immediate dead-owner
|
|
173
|
+
// probing adds complexity for convenience rather than correctness in a single-operator tool.
|
|
174
|
+
function inspectLockOwnerLiveness(owner, options = {}) {
|
|
175
|
+
if (!isDurableLockOwner(owner)) return "indeterminate";
|
|
176
|
+
// A lock taken on another host cannot be adjudicated from here at all.
|
|
177
|
+
if (owner.hostname !== hostname()) return "indeterminate";
|
|
178
|
+
const staleAfterMs = normalizePositiveInteger(options.staleLockMs, DEFAULT_STALE_LOCK_MS);
|
|
179
|
+
const heldForMs = Date.now() - Date.parse(owner.acquired_at);
|
|
180
|
+
if (!Number.isFinite(heldForMs)) return "indeterminate";
|
|
181
|
+
// A future `acquired_at` yields a negative age, which is never greater than the
|
|
182
|
+
// TTL, so clock skew already fails closed here. An explicit `heldForMs < 0`
|
|
183
|
+
// branch was removed after its falsification showed removing it changed nothing.
|
|
184
|
+
return heldForMs > staleAfterMs ? "dead" : "alive";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function lockOwnerEntryExists(ownerPath) {
|
|
188
|
+
try {
|
|
189
|
+
await lstat(ownerPath);
|
|
190
|
+
return true;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error?.code === "ENOENT") return false;
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isDurableLockOwner(owner) {
|
|
198
|
+
return isRecord(owner)
|
|
199
|
+
&& Number.isInteger(owner.pid)
|
|
200
|
+
&& owner.pid > 0
|
|
201
|
+
&& stringValue(owner.hostname)
|
|
202
|
+
&& Number.isFinite(Date.parse(owner.acquired_at || ""))
|
|
203
|
+
&& typeof owner.nonce === "string"
|
|
204
|
+
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(owner.nonce);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function sameLockOwner(left, right) {
|
|
208
|
+
return isDurableLockOwner(left) && isDurableLockOwner(right) && left.nonce === right.nonce;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sameLockOwnerEvidence(left, right) {
|
|
212
|
+
return Boolean(left && right
|
|
213
|
+
&& sameLockOwner(left.owner, right.owner)
|
|
214
|
+
&& left.identity.dev === right.identity.dev
|
|
215
|
+
&& left.identity.ino === right.identity.ino);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function lockDirectoryIdentity(lockDir) {
|
|
219
|
+
try {
|
|
220
|
+
const value = await lstat(lockDir);
|
|
221
|
+
if (!value.isDirectory() || value.isSymbolicLink()) return null;
|
|
222
|
+
return { dev: value.dev, ino: value.ino, mtimeMs: value.mtimeMs };
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sameLockDirectoryIdentity(left, right) {
|
|
229
|
+
return Boolean(left && right && left.dev === right.dev && left.ino === right.ino);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function normalizePositiveInteger(value, fallback) {
|
|
233
|
+
if (value === undefined || value === null) return fallback;
|
|
234
|
+
if (!Number.isInteger(value) || value <= 0) throw new Error("lock timing options must be positive integers");
|
|
235
|
+
return value;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Small local helpers, lifted with the cluster.
|
|
239
|
+
function delay(ms) {
|
|
240
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// An ownerless lock is a directory with no valid owner record: a crash between the
|
|
244
|
+
// mkdir and publishing owner.json. It is only reclaimable after a grace window,
|
|
245
|
+
// because that gap is microseconds wide in the normal case and stealing inside it
|
|
246
|
+
// would take a lock from a process that is about to publish.
|
|
247
|
+
//
|
|
248
|
+
// This replaces a canReclaimOwnerlessRunJsonLock that was deleted during the reclaim
|
|
249
|
+
// simplification while its call site remained — a live ReferenceError on the
|
|
250
|
+
// ownerless path, found by opencode.
|
|
251
|
+
async function ownerlessLockIsReclaimable(lockDir, ownerPath, options = {}) {
|
|
252
|
+
if (await lockOwnerEntryExists(ownerPath)) return false;
|
|
253
|
+
const graceMs = normalizePositiveInteger(options.missingOwnerStealMs, DEFAULT_MISSING_OWNER_STEAL_MS);
|
|
254
|
+
try {
|
|
255
|
+
const observed = await stat(lockDir);
|
|
256
|
+
const ageMs = Date.now() - observed.mtimeMs;
|
|
257
|
+
return Number.isFinite(ageMs) && ageMs > graceMs;
|
|
258
|
+
} catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isRecord(value) {
|
|
264
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function stringValue(value) {
|
|
268
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
269
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { readFile, rename } from "node:fs/promises";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { writeProtectedJsonAtomic } from "./atomic-write.js";
|
|
5
|
+
import { withRunJsonLock } from "./run-lock.js";
|
|
6
|
+
|
|
7
|
+
const RUN_FILE = "run.json";
|
|
8
|
+
|
|
9
|
+
export async function coordinateRunJsonTransition(runDir, options) {
|
|
10
|
+
const {
|
|
11
|
+
contracts,
|
|
12
|
+
descriptor,
|
|
13
|
+
validateRun,
|
|
14
|
+
reobservers = new Map(),
|
|
15
|
+
atomicWriteHooks,
|
|
16
|
+
finalGuard,
|
|
17
|
+
} = options ?? {};
|
|
18
|
+
const registry = contractRegistry(contracts);
|
|
19
|
+
const participants = participantRegistry(descriptor, registry);
|
|
20
|
+
if (typeof validateRun !== "function") throw new Error("validateRun must be a function");
|
|
21
|
+
if (!(reobservers instanceof Map)) throw new Error("reobservers must be a Map");
|
|
22
|
+
|
|
23
|
+
return withRunJsonLock(runDir, async () => {
|
|
24
|
+
const initial = deepFreeze(await readRunState(runDir, validateRun));
|
|
25
|
+
const before = projectAll(registry, initial);
|
|
26
|
+
const applied = descriptor.apply(structuredClone(initial));
|
|
27
|
+
if (!applied || typeof applied !== "object" || Array.isArray(applied)) {
|
|
28
|
+
throw new Error("transition apply must return a state object");
|
|
29
|
+
}
|
|
30
|
+
validateRun(applied);
|
|
31
|
+
const candidate = deepFreeze(applied);
|
|
32
|
+
const after = projectAll(registry, candidate);
|
|
33
|
+
|
|
34
|
+
for (const [familyId, contract] of registry) {
|
|
35
|
+
contract.validateTransition({
|
|
36
|
+
mode: participants.get(familyId),
|
|
37
|
+
before: before.get(familyId),
|
|
38
|
+
after: after.get(familyId),
|
|
39
|
+
current: initial,
|
|
40
|
+
candidate,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await writeProtectedJsonAtomic(runDir, RUN_FILE, candidate, {
|
|
45
|
+
hooks: atomicWriteHooks,
|
|
46
|
+
fsOps: {
|
|
47
|
+
rename: async (source, destination) => {
|
|
48
|
+
|
|
49
|
+
// First comparison: gives the reobservers a state to reason about that is
|
|
50
|
+
// known current as of this moment.
|
|
51
|
+
const observed = deepFreeze(await readRunState(runDir, validateRun));
|
|
52
|
+
assertUnchanged(observed, initial);
|
|
53
|
+
|
|
54
|
+
const observedProjections = projectAll(registry, observed);
|
|
55
|
+
for (const [familyId, contract] of registry) {
|
|
56
|
+
await contract.reobserve({
|
|
57
|
+
mode: participants.get(familyId),
|
|
58
|
+
current: observedProjections.get(familyId),
|
|
59
|
+
candidate: after.get(familyId),
|
|
60
|
+
observe: reobservers.get(familyId),
|
|
61
|
+
state: observed,
|
|
62
|
+
nextState: candidate,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Second comparison, immediately before the rename and after anything the
|
|
67
|
+
// reobservers did. Comparing only once left a window: a reobserver - or a
|
|
68
|
+
// concurrent writer running while one awaited - could commit a valid
|
|
69
|
+
// record after the check, and this rename would silently overwrite it.
|
|
70
|
+
// Only the synchronous final guard may run between this line and the rename.
|
|
71
|
+
const finalObserved = deepFreeze(await readRunState(runDir, validateRun));
|
|
72
|
+
assertUnchanged(finalObserved, initial);
|
|
73
|
+
if (typeof finalGuard === "function") {
|
|
74
|
+
const guarded = finalGuard({ state: finalObserved, candidate });
|
|
75
|
+
if (guarded && typeof guarded.then === "function") throw new Error("final commit guard must be synchronous");
|
|
76
|
+
}
|
|
77
|
+
await rename(source, destination);
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
return candidate;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function assertUnchanged(observed, initial) {
|
|
86
|
+
if (!isDeepStrictEqual(observed, initial)) {
|
|
87
|
+
throw new Error("run state changed before protected replacement");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function readRunState(runDir, validateRun) {
|
|
92
|
+
const state = JSON.parse(await readFile(join(runDir, RUN_FILE), "utf8"));
|
|
93
|
+
return validateRun(state);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function contractRegistry(contracts) {
|
|
97
|
+
if (!Array.isArray(contracts) || !Object.isFrozen(contracts)) {
|
|
98
|
+
throw new Error("contracts must be a frozen array");
|
|
99
|
+
}
|
|
100
|
+
const registry = new Map();
|
|
101
|
+
for (const contract of contracts) {
|
|
102
|
+
if (!contract || !Object.isFrozen(contract) || typeof contract.id !== "string" || !contract.id) {
|
|
103
|
+
throw new Error("each contract must be frozen and have an id");
|
|
104
|
+
}
|
|
105
|
+
for (const method of ["project", "validateProjection", "validateTransition", "reobserve"]) {
|
|
106
|
+
if (typeof contract[method] !== "function") throw new Error(`contract '${contract.id}' is missing ${method}`);
|
|
107
|
+
}
|
|
108
|
+
if (registry.has(contract.id)) throw new Error(`duplicate contract '${contract.id}'`);
|
|
109
|
+
registry.set(contract.id, contract);
|
|
110
|
+
}
|
|
111
|
+
return registry;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function participantRegistry(descriptor, contracts) {
|
|
115
|
+
if (!descriptor || !Object.isFrozen(descriptor) || typeof descriptor.apply !== "function"
|
|
116
|
+
|| !Array.isArray(descriptor.participants) || !Object.isFrozen(descriptor.participants)) {
|
|
117
|
+
throw new Error("descriptor and participants must be frozen");
|
|
118
|
+
}
|
|
119
|
+
const participants = new Map();
|
|
120
|
+
for (const participant of descriptor.participants) {
|
|
121
|
+
if (!participant || !Object.isFrozen(participant) || typeof participant.familyId !== "string"
|
|
122
|
+
|| !participant.familyId || typeof participant.mode !== "string" || !participant.mode) {
|
|
123
|
+
throw new Error("each participant must be frozen and declare familyId and mode");
|
|
124
|
+
}
|
|
125
|
+
if (!contracts.has(participant.familyId)) throw new Error(`unknown contract '${participant.familyId}'`);
|
|
126
|
+
if (participants.has(participant.familyId)) throw new Error(`duplicate participant '${participant.familyId}'`);
|
|
127
|
+
participants.set(participant.familyId, participant.mode);
|
|
128
|
+
}
|
|
129
|
+
return participants;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function projectAll(contracts, state) {
|
|
133
|
+
const projections = new Map();
|
|
134
|
+
for (const [familyId, contract] of contracts) {
|
|
135
|
+
const projection = contract.project(state);
|
|
136
|
+
contract.validateProjection(projection);
|
|
137
|
+
projections.set(familyId, projection);
|
|
138
|
+
}
|
|
139
|
+
return projections;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function deepFreeze(value) {
|
|
143
|
+
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
|
144
|
+
for (const nested of Object.values(value)) deepFreeze(nested);
|
|
145
|
+
return Object.freeze(value);
|
|
146
|
+
}
|