@sabaiway/agent-workflow-kit 5.3.0 → 5.5.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/CHANGELOG.md +138 -0
- package/README.md +2 -1
- package/SKILL.md +5 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +1 -1
- package/bridges/antigravity-cli-bridge/capability.json +1 -1
- package/bridges/codex-cli-bridge/SKILL.md +53 -5
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +622 -30
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +731 -3
- package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
- package/bridges/codex-cli-bridge/capability.json +15 -10
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +16 -12
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/core-evidence.md +1 -1
- package/references/modes/coverage-check.md +1 -1
- package/references/modes/dispatch.md +29 -0
- package/references/modes/gates.md +7 -2
- package/references/modes/receipt-deadline.md +3 -3
- package/references/modes/recommendations.md +3 -1
- package/references/modes/upgrade.md +1 -1
- package/references/modes/velocity.md +5 -1
- package/references/scripts/migrate-gates.mjs +102 -10
- package/references/scripts/migrate-gates.test.mjs +37 -0
- package/tools/commands.mjs +7 -0
- package/tools/core-evidence.mjs +79 -5
- package/tools/coverage-check.mjs +23 -7
- package/tools/coverage-producer.mjs +68 -0
- package/tools/coverage-state.mjs +24 -0
- package/tools/declared-paths.mjs +32 -0
- package/tools/detect-backends.mjs +5 -4
- package/tools/dispatch-record.mjs +10 -3
- package/tools/dispatch-store.mjs +392 -0
- package/tools/dispatch.mjs +1779 -0
- package/tools/doc-parity.mjs +27 -4
- package/tools/exec-producer.mjs +483 -0
- package/tools/exec-receipt.mjs +263 -0
- package/tools/flow-store.mjs +111 -462
- package/tools/gates-declaration.mjs +49 -0
- package/tools/gates-init.mjs +83 -6
- package/tools/receipt-deadline.mjs +25 -3
- package/tools/recommendations.mjs +63 -19
- package/tools/release-scan.mjs +33 -0
- package/tools/run-gates.mjs +111 -32
- package/tools/store-append.mjs +444 -0
- package/tools/velocity-profile.mjs +102 -23
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// store-append.mjs — the PARAMETERIZED lock/CAS serialized-append leaf (delegation Plan 1, Phase 2,
|
|
2
|
+
// D12). Extracted VERBATIM from flow-store.mjs, which is now its first caller; the delegation store
|
|
3
|
+
// (dispatch-store.mjs) is the second. No CLI, no side effects on import.
|
|
4
|
+
//
|
|
5
|
+
// Why a leaf rather than an import of the flow appender: `appendFlowRecordWithPreflight` hardwires
|
|
6
|
+
// the AW_FLOW_STORE seam and validateFlowRecord, so a second store could only reuse it by pretending
|
|
7
|
+
// to be the flow store. Everything store-SPECIFIC is injected instead — the path resolution, the env
|
|
8
|
+
// seam and knob names, the typed-STOP factory, the record validator, the store-text parser, and the
|
|
9
|
+
// semantic preflight — while the DISCIPLINE stays here in exactly one copy: bounded lock waits with
|
|
10
|
+
// named refusals per holder class, custody-checked release (only the inode the winning CAS fd proved
|
|
11
|
+
// is ever removed), fd-based no-follow reads, snapshot-bound rename guarding, and an append that
|
|
12
|
+
// runs its caller's semantic preflight on ONE captured snapshot inside the critical section.
|
|
13
|
+
//
|
|
14
|
+
// The nouns are parameters because the refusal messages are the user contract: a store's refusals
|
|
15
|
+
// must name THAT store. `nouns.adj` is the hyphenated adjective form ("flow-store" → "flow-store
|
|
16
|
+
// lock", "flow-store parent dir"), `nouns.store` the standalone noun ("flow store"), `nouns.record`
|
|
17
|
+
// what a rejected line is called ("flow record").
|
|
18
|
+
//
|
|
19
|
+
// Declared residuals no dependency-free core-Node mechanism can close (inherited unchanged from the
|
|
20
|
+
// extraction source): the pathname lstat→rename and reread→rename windows (no flock/fcntl, no
|
|
21
|
+
// inode-conditional unlink or rename) and bind-mount aliasing. Records remain forgeable — this is a
|
|
22
|
+
// self-discipline mechanism, not a security boundary.
|
|
23
|
+
|
|
24
|
+
import { readSync, rmSync, lstatSync, realpathSync, openSync, closeSync, fstatSync, renameSync, writeSync } from 'node:fs';
|
|
25
|
+
import { join, dirname, basename } from 'node:path';
|
|
26
|
+
import { hostname } from 'node:os';
|
|
27
|
+
import { writeContainedFileAtomic, lstatNoFollow } from './atomic-write.mjs';
|
|
28
|
+
import { parsePositiveIntKnob } from './changed-surface.mjs';
|
|
29
|
+
import { readRegularFileNoFollow, describeNonRegular } from './fs-read-nofollow.mjs';
|
|
30
|
+
|
|
31
|
+
// Wait bound + poll cadence defaults; a lane may override either, and the env knobs keep hermetic
|
|
32
|
+
// tests off wall-clock.
|
|
33
|
+
export const DEFAULT_LOCK_WAIT_MS = 10_000;
|
|
34
|
+
export const DEFAULT_LOCK_POLL_MS = 100;
|
|
35
|
+
|
|
36
|
+
// Sync sleep (the append is a sync flow end-to-end); injectable so a hermetic test can intercept it.
|
|
37
|
+
const sleepSyncMs = (ms) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
|
|
38
|
+
|
|
39
|
+
// Monotonic — a system clock stepped backwards must not stretch the wait bound.
|
|
40
|
+
const monotonicNowMs = () => performance.now();
|
|
41
|
+
|
|
42
|
+
// POSIX single-quoting for paths pasted into recovery commands — a raw interpolation would execute
|
|
43
|
+
// path bytes on paste.
|
|
44
|
+
const shellQuotePath = (p) => `'${p.replaceAll("'", "'\\''")}'`;
|
|
45
|
+
|
|
46
|
+
// Bounded positional comparison of a HELD fd against the snapshot: at most snapshot-length bytes
|
|
47
|
+
// plus ONE growth-probe byte (positional — the fd offset sits at EOF). Changed bytes, truncation,
|
|
48
|
+
// or growth report false.
|
|
49
|
+
const READ_CHUNK_BYTES = 65536;
|
|
50
|
+
const fdContentEquals = (fd, expected) => {
|
|
51
|
+
const buf = Buffer.alloc(READ_CHUNK_BYTES);
|
|
52
|
+
let position = 0;
|
|
53
|
+
while (position < expected.length) {
|
|
54
|
+
const want = Math.min(buf.length, expected.length - position);
|
|
55
|
+
const n = readSync(fd, buf, 0, want, position);
|
|
56
|
+
if (n === 0) return false; // truncated below the snapshot length
|
|
57
|
+
if (!buf.subarray(0, n).equals(expected.subarray(position, position + n))) return false;
|
|
58
|
+
position += n;
|
|
59
|
+
}
|
|
60
|
+
return readSync(fd, buf, 0, 1, position) === 0; // any byte here means the store GREW
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Trusted only with parsed, valid metadata; anything else is the crash/corruption lane — never
|
|
64
|
+
// probed, never stolen.
|
|
65
|
+
const isValidHolder = (holder) =>
|
|
66
|
+
holder !== null && typeof holder === 'object' && !Array.isArray(holder)
|
|
67
|
+
&& Number.isInteger(holder.pid) && holder.pid > 0
|
|
68
|
+
&& typeof holder.host === 'string' && holder.host.length > 0;
|
|
69
|
+
|
|
70
|
+
const describeHolder = (holder) => `pid ${holder.pid} (host ${holder.host}, started ${holder.startedAt ?? 'unknown'})`;
|
|
71
|
+
|
|
72
|
+
// ESRCH on a same-host signal-0 probe only; a foreign host is unprobeable — never treated as dead.
|
|
73
|
+
const isProvablyDead = (holder) => {
|
|
74
|
+
if (holder.host !== hostname()) return false;
|
|
75
|
+
try {
|
|
76
|
+
process.kill(holder.pid, 0);
|
|
77
|
+
return false;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return err && err.code === 'ESRCH';
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// createStoreAppendLane(config) → the append surface for ONE store. Every message the lane emits
|
|
84
|
+
// names that store; every seam it opens is the caller's.
|
|
85
|
+
// nouns { store, adj, record } — the refusal vocabulary (see the header)
|
|
86
|
+
// envNames { store, waitKnob, pollKnob } — the override seam + the two lock knobs
|
|
87
|
+
// stop the typed-STOP factory (one per store, so callers can classify by code)
|
|
88
|
+
// resolveStorePath(cwd, env) → absolute path, or null when there is no store to write
|
|
89
|
+
// resolveLockPath(storePath) → the sibling lock path
|
|
90
|
+
// validateRecord(snapshot) → { ok } | { ok: false, reason }
|
|
91
|
+
// parseStoreText(raw) → { records, malformed, malformedReasons }
|
|
92
|
+
// lockWaitMs / lockPollMs — the defaults the env knobs override
|
|
93
|
+
export const createStoreAppendLane = ({
|
|
94
|
+
nouns, envNames, stop, resolveStorePath, resolveLockPath, validateRecord, parseStoreText,
|
|
95
|
+
lockWaitMs = DEFAULT_LOCK_WAIT_MS, lockPollMs = DEFAULT_LOCK_POLL_MS,
|
|
96
|
+
}) => {
|
|
97
|
+
const LOCK_NOUN = `${nouns.adj} lock`;
|
|
98
|
+
|
|
99
|
+
const foreignObjectStop = (noun, path, className, isDirectory) =>
|
|
100
|
+
stop(`the ${noun} ${path} is a ${className}, not a regular file — refusing to touch it. To recover: inspect it, then remove it by hand: ${isDirectory ? 'rmdir' : 'rm'} -- ${shellQuotePath(path)} — it is never removed silently (fail closed)`);
|
|
101
|
+
|
|
102
|
+
// A non-regular object at the store or lock path is never read (a FIFO read blocks forever) and
|
|
103
|
+
// never removed silently — an immediate named refusal. Returns the lstat result (null = absent).
|
|
104
|
+
const assertRegularOrAbsent = (path, noun, lstat) => {
|
|
105
|
+
const st = lstatNoFollow(path, lstat);
|
|
106
|
+
if (st && !st.isFile()) throw foreignObjectStop(noun, path, describeNonRegular(st), st.isDirectory());
|
|
107
|
+
return st;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// The shared parser accepts any digit string — hundreds of digits parse to Infinity and would
|
|
111
|
+
// erase the wait bound; gated locally because the shared helper feeds the frozen core-evidence.
|
|
112
|
+
const parseLockKnob = (env, name, fallback) => {
|
|
113
|
+
const value = parsePositiveIntKnob(env, name, fallback, stop);
|
|
114
|
+
if (!Number.isSafeInteger(value)) {
|
|
115
|
+
throw stop(`${name} must be a positive safe integer — the provided value overflows (fail closed)`);
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Containment + canonical pinning, once per append: a symlinked IMMEDIATE parent refuses by name;
|
|
121
|
+
// the ancestor chain is then realpath-rebased so every spelling funnels to ONE physical store+lock
|
|
122
|
+
// pair (refusing ancestor links would break legitimately symlinked prefixes like a distro /home).
|
|
123
|
+
// realpath ENOENT keeps the lexical path — a missing parent still refuses at lock creation.
|
|
124
|
+
const canonicalWritePaths = (resolvedStorePath, lstat) => {
|
|
125
|
+
const parent = dirname(resolvedStorePath);
|
|
126
|
+
if (lstatNoFollow(parent, lstat)?.isSymbolicLink()) {
|
|
127
|
+
throw stop(`${parent} is a symlink — refusing to write the ${nouns.store} through a symlinked parent (pre-mutation containment)`);
|
|
128
|
+
}
|
|
129
|
+
let canonicalParent;
|
|
130
|
+
try {
|
|
131
|
+
canonicalParent = realpathSync(parent);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (err && err.code === 'ENOENT') canonicalParent = parent;
|
|
134
|
+
else throw stop(`cannot canonicalize the ${nouns.adj} parent dir ${parent} (${(err && err.code) || (err && err.message) || err}) — refusing to write through an unresolvable path (fail closed)`);
|
|
135
|
+
}
|
|
136
|
+
const storePath = join(canonicalParent, basename(resolvedStorePath));
|
|
137
|
+
const lockPath = resolveLockPath(storePath);
|
|
138
|
+
assertRegularOrAbsent(storePath, nouns.store, lstat);
|
|
139
|
+
assertRegularOrAbsent(lockPath, LOCK_NOUN, lstat);
|
|
140
|
+
return { storePath, lockPath };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Returns the OWNED canonical { storePath, lockPath, lockFd, lockIdentity }; throws BEFORE
|
|
144
|
+
// ownership on every refusal lane. The caller must reuse exactly these values end-to-end.
|
|
145
|
+
const acquireLock = (resolvedStorePath, env, deps) => {
|
|
146
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
147
|
+
const openLock = deps.openLock ?? ((p) => openSync(p, 'wx'));
|
|
148
|
+
const sleep = deps.sleep ?? sleepSyncMs;
|
|
149
|
+
const now = deps.now ?? monotonicNowMs;
|
|
150
|
+
const waitBoundMs = parseLockKnob(env, envNames.waitKnob, lockWaitMs);
|
|
151
|
+
const pollMs = parseLockKnob(env, envNames.pollKnob, lockPollMs);
|
|
152
|
+
const { storePath, lockPath } = canonicalWritePaths(resolvedStorePath, lstat);
|
|
153
|
+
const holderBody = JSON.stringify({ pid: process.pid, host: hostname(), startedAt: new Date().toISOString() });
|
|
154
|
+
const deadline = now() + waitBoundMs;
|
|
155
|
+
// Every retry lane passes this gate — else lock churn extends the wait past the bound forever.
|
|
156
|
+
const refuseIfPastDeadline = (why) => {
|
|
157
|
+
if (now() >= deadline) {
|
|
158
|
+
throw stop(`the ${LOCK_NOUN} ${lockPath} could not be acquired within the ${waitBoundMs}ms wait (${why}) — retry, or raise ${envNames.waitKnob}`);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
for (;;) {
|
|
162
|
+
// CAS: exclusive-create ('wx' also refuses a symlink leaf); the winning fd stamps the holder
|
|
163
|
+
// and yields the lock's {dev, ino} — a pathname stat could already see a replacement.
|
|
164
|
+
let fd = null;
|
|
165
|
+
try {
|
|
166
|
+
fd = openLock(lockPath);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (!err || err.code !== 'EEXIST') {
|
|
169
|
+
throw stop(`cannot create the ${LOCK_NOUN} ${lockPath} (${(err && err.code) || (err && err.message) || err}) — the store's parent dir must exist and be writable`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (fd !== null) {
|
|
173
|
+
let won = false;
|
|
174
|
+
try {
|
|
175
|
+
writeSync(fd, holderBody);
|
|
176
|
+
const st = fstatSync(fd);
|
|
177
|
+
won = true;
|
|
178
|
+
// The fd stays open through the whole append — its inode cannot be recycled under us.
|
|
179
|
+
return { storePath, lockPath, lockFd: fd, lockIdentity: { dev: st.dev, ino: st.ino } };
|
|
180
|
+
} catch (err) {
|
|
181
|
+
// Without the fd-proven identity, removing the pathname would be an unproven-ownership rm.
|
|
182
|
+
throw stop(`cannot stamp or verify the just-created ${LOCK_NOUN} ${lockPath} (${(err && err.code) || (err && err.message) || err}) — the lock file is left in place; inspect it, then remove it by hand: rm -- ${shellQuotePath(lockPath)} (fail closed)`);
|
|
183
|
+
} finally {
|
|
184
|
+
if (!won) { try { closeSync(fd); } catch { /* the stamp failure above already decided the lane */ } }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// The holder read HOLDS its fd (keepFd) until the lane decides: while the fd is open the
|
|
188
|
+
// inode cannot be recycled, so the DEAD re-verify below can trust an identity match only
|
|
189
|
+
// together with the held inode still being linked (FLOW-LOCK-HOLDER-FD-RECHECK). The lane
|
|
190
|
+
// verdict is computed FIRST (its error captured), the held fd then closes unconditionally,
|
|
191
|
+
// and a close failure is a typed STOP — thrown alone, or preserved on the primary error as
|
|
192
|
+
// holderCloseFailure (the release never-mask discipline; P28).
|
|
193
|
+
const holderIo = deps.holderIo ?? {};
|
|
194
|
+
// The read itself is wrapped: on the early error/foreign lanes the reader closes its own fd
|
|
195
|
+
// in a finally, and a close throw there would otherwise escape as a RAW error outside the
|
|
196
|
+
// typed-STOP guarantee.
|
|
197
|
+
let holderRead;
|
|
198
|
+
try {
|
|
199
|
+
holderRead = readRegularFileNoFollow(lockPath, { ...holderIo, keepFd: true });
|
|
200
|
+
} catch (err) {
|
|
201
|
+
throw stop(`cannot read the ${LOCK_NOUN} holder (${(err && err.code) || (err && err.message) || err}) — the read/close custody failed (fail closed)`);
|
|
202
|
+
}
|
|
203
|
+
if (holderRead.closeFailure !== undefined) {
|
|
204
|
+
throw stop(`cannot read the ${LOCK_NOUN} holder (${holderRead.closeFailure}) — the read/close custody failed (fail closed)`);
|
|
205
|
+
}
|
|
206
|
+
const holderFd = holderRead.outcome === 'ok' ? holderRead.fd : null;
|
|
207
|
+
let verdict = null;
|
|
208
|
+
let primary = null;
|
|
209
|
+
try {
|
|
210
|
+
verdict = (() => {
|
|
211
|
+
if (holderRead.outcome === 'absent') {
|
|
212
|
+
refuseIfPastDeadline('the lock kept appearing and vanishing (churn)');
|
|
213
|
+
return { retry: true }; // released between attempts — retry the CAS at once
|
|
214
|
+
}
|
|
215
|
+
if (holderRead.outcome === 'foreign') throw foreignObjectStop(LOCK_NOUN, lockPath, holderRead.className, holderRead.isDirectory);
|
|
216
|
+
let holder = null;
|
|
217
|
+
if (holderRead.outcome === 'ok') {
|
|
218
|
+
try {
|
|
219
|
+
holder = JSON.parse(holderRead.content);
|
|
220
|
+
} catch { holder = null; }
|
|
221
|
+
}
|
|
222
|
+
const validHolder = isValidHolder(holder);
|
|
223
|
+
if (validHolder && isProvablyDead(holder)) {
|
|
224
|
+
// The DEAD verdict binds to the inode the holder was read from — a lock released or
|
|
225
|
+
// replaced since then means the observed holder is gone: retry, never refuse a
|
|
226
|
+
// vanished lock.
|
|
227
|
+
let lockNow = null;
|
|
228
|
+
try {
|
|
229
|
+
lockNow = lstatNoFollow(lockPath, lstat); // null ONLY on a true ENOENT
|
|
230
|
+
} catch (err) {
|
|
231
|
+
throw stop(`cannot re-verify the ${LOCK_NOUN} identity before the DEAD refusal (${(err && err.code) || (err && err.message) || err}) — refusing to guess (fail closed)`);
|
|
232
|
+
}
|
|
233
|
+
if (lockNow == null || lockNow.dev !== holderRead.dev || lockNow.ino !== holderRead.ino) {
|
|
234
|
+
refuseIfPastDeadline('the observed dead holder was released (churn)');
|
|
235
|
+
return { retry: true };
|
|
236
|
+
}
|
|
237
|
+
// A pathname identity match alone can be a recycled lie (release + re-create landing
|
|
238
|
+
// the same {dev, ino}); the held fd settles it — an unlinked held inode (nlink 0)
|
|
239
|
+
// proves the observed holder's lock is GONE, whatever the pathname claims.
|
|
240
|
+
let heldNow;
|
|
241
|
+
try {
|
|
242
|
+
heldNow = (holderIo.fstat ?? fstatSync)(holderFd);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
throw stop(`cannot re-verify the ${LOCK_NOUN} through its held descriptor (${(err && err.code) || (err && err.message) || err}) — refusing to guess (fail closed)`);
|
|
245
|
+
}
|
|
246
|
+
if (heldNow.nlink === 0) {
|
|
247
|
+
refuseIfPastDeadline('the observed dead holder was released (churn)');
|
|
248
|
+
return { retry: true };
|
|
249
|
+
}
|
|
250
|
+
throw stop(`the ${LOCK_NOUN} ${lockPath} is held by a DEAD process (${describeHolder(holder)}) — a crashed appender left it behind. To recover: inspect it, then remove it by hand: rm -- ${shellQuotePath(lockPath)} — it is never stolen silently (a steal could tear a live append; fail closed)`);
|
|
251
|
+
}
|
|
252
|
+
// ONE observation drives the deadline check AND the sleep cap — no overshoot by a full poll.
|
|
253
|
+
const observedAt = now();
|
|
254
|
+
if (observedAt >= deadline) {
|
|
255
|
+
if (!validHolder) {
|
|
256
|
+
throw stop(`the ${LOCK_NOUN} ${lockPath} carries an UNREADABLE or malformed holder after the full ${waitBoundMs}ms wait — a crashed appender may have died before writing its holder line, or the file is corrupted. To recover: inspect it, then remove it by hand: rm -- ${shellQuotePath(lockPath)} — it is never stolen silently (fail closed)`);
|
|
257
|
+
}
|
|
258
|
+
if (holder.host !== hostname()) {
|
|
259
|
+
throw stop(`the ${LOCK_NOUN} ${lockPath} is still held by pid ${holder.pid} on host ${holder.host} (liveness unprobeable from ${hostname()}) after the full ${waitBoundMs}ms wait — retry after that holder finishes, or raise ${envNames.waitKnob}`);
|
|
260
|
+
}
|
|
261
|
+
throw stop(`the ${LOCK_NOUN} ${lockPath} is still held by ${describeHolder(holder)} after the full ${waitBoundMs}ms wait — retry after the holder finishes, or raise ${envNames.waitKnob}`);
|
|
262
|
+
}
|
|
263
|
+
return { sleepMs: Math.min(pollMs, deadline - observedAt) };
|
|
264
|
+
})();
|
|
265
|
+
} catch (err) {
|
|
266
|
+
primary = err;
|
|
267
|
+
}
|
|
268
|
+
if (holderFd !== null) {
|
|
269
|
+
try {
|
|
270
|
+
(holderIo.close ?? closeSync)(holderFd);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
const closeStop = stop(`cannot close the held ${nouns.adj} holder descriptor (${(err && err.code) || (err && err.message) || err}) — the fd-custody guarantee is violated (fail closed)`);
|
|
273
|
+
if (primary == null) primary = closeStop;
|
|
274
|
+
else primary.holderCloseFailure = closeStop.message;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (primary != null) throw primary;
|
|
278
|
+
if (verdict.retry) continue;
|
|
279
|
+
sleep(verdict.sleepMs);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// ONE custody-checked release: only the inode the winning fd proved is ever removed (the fd is
|
|
284
|
+
// still open, so a pathname {dev, ino} match is proof of the same file); absent or replaced =
|
|
285
|
+
// a mutual-exclusion violation, the foreign lock stays. Closes the fd on EVERY outcome without
|
|
286
|
+
// losing a close failure. Returns a typed STOP or null, never throws — the caller sequences it
|
|
287
|
+
// after the body's own error so neither masks the other.
|
|
288
|
+
const releaseLock = (lockPath, lockFd, lockIdentity, deps) => {
|
|
289
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
290
|
+
const rm = deps.rm ?? ((p) => rmSync(p, { force: true }));
|
|
291
|
+
const close = deps.close ?? closeSync;
|
|
292
|
+
let issue = null;
|
|
293
|
+
let st = null;
|
|
294
|
+
try {
|
|
295
|
+
st = lstatNoFollow(lockPath, lstat); // null ONLY on a true ENOENT
|
|
296
|
+
} catch (err) {
|
|
297
|
+
issue = stop(`cannot verify the ${LOCK_NOUN} before release (${(err && err.code) || (err && err.message) || err}) — the lock is left in place; inspect ${lockPath} (fail closed)`);
|
|
298
|
+
}
|
|
299
|
+
if (issue == null) {
|
|
300
|
+
if (st == null || st.dev !== lockIdentity.dev || st.ino !== lockIdentity.ino) {
|
|
301
|
+
issue = stop(`the ${LOCK_NOUN} ${lockPath} was removed or replaced under this append — mutual exclusion was violated and another appender may have run concurrently; the current lock (if any) is left untouched; inspect the store and the lock (fail closed)`);
|
|
302
|
+
} else {
|
|
303
|
+
try {
|
|
304
|
+
rm(lockPath);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
issue = stop(`cannot remove the ${LOCK_NOUN} at release (${(err && err.code) || (err && err.message) || err}) — inspect ${lockPath} (fail closed)`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
close(lockFd);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
const closeStop = stop(`cannot close the ${LOCK_NOUN} descriptor at release (${(err && err.code) || (err && err.message) || err})`);
|
|
314
|
+
if (issue == null) issue = closeStop;
|
|
315
|
+
else issue.closeFailure = closeStop.message;
|
|
316
|
+
}
|
|
317
|
+
return issue;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// ONE serialization captured up front; validation and every preflight walk run on its PARSED
|
|
321
|
+
// snapshot — a toJSON or getter can never make the written line differ from what validated.
|
|
322
|
+
const captureRecordSnapshot = (record) => {
|
|
323
|
+
let line;
|
|
324
|
+
let snapshot;
|
|
325
|
+
try {
|
|
326
|
+
line = JSON.stringify(record);
|
|
327
|
+
snapshot = JSON.parse(line);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
throw stop(`cannot capture a canonical serialization of the record (${(err && err.message) || err}) — refusing to write (fail closed)`);
|
|
330
|
+
}
|
|
331
|
+
const v = validateRecord(snapshot);
|
|
332
|
+
if (!v.ok) throw stop(`refusing to write a malformed ${nouns.record}: ${v.reason}`);
|
|
333
|
+
return { line, snapshot };
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// Captured-result shape ({ value } | { err }) — never throws past the caller, so release always
|
|
337
|
+
// runs. The snapshot fd is held until after the final rename and closed on every exit lane.
|
|
338
|
+
// `preflight({ records, snapshot, line, storePath })` is the caller's SEMANTIC half: it runs on
|
|
339
|
+
// the locked snapshot after the byte-replay refusal and throws its own named stops.
|
|
340
|
+
const appendUnderLock = ({ storePath, makeRecord, preflight, deps }) => {
|
|
341
|
+
let snapshotFd = null;
|
|
342
|
+
try {
|
|
343
|
+
const storeRead = readRegularFileNoFollow(storePath, { keepFd: true });
|
|
344
|
+
if (storeRead.outcome === 'ok') snapshotFd = storeRead.fd;
|
|
345
|
+
if (storeRead.outcome === 'foreign') throw foreignObjectStop(nouns.store, storePath, storeRead.className, storeRead.isDirectory);
|
|
346
|
+
if (storeRead.outcome === 'error') throw stop(`cannot read the ${nouns.store} before appending (${storeRead.code}) — refusing to overwrite it (fail closed)`);
|
|
347
|
+
// A second hard-link path would derive its OWN lock and the two appends would race one inode.
|
|
348
|
+
if (storeRead.outcome === 'ok' && storeRead.nlink !== 1) {
|
|
349
|
+
throw stop(`the ${nouns.store} ${storePath} has ${storeRead.nlink} hard links — two path-derived locks would race one inode; remove the extra links and retry (fail closed)`);
|
|
350
|
+
}
|
|
351
|
+
const existing = storeRead.outcome === 'absent' ? '' : storeRead.content;
|
|
352
|
+
const parsed = parseStoreText(existing);
|
|
353
|
+
if (parsed.malformed > 0) {
|
|
354
|
+
throw stop(`refusing to append to a ${nouns.store} carrying ${parsed.malformed} malformed line(s) (${parsed.malformedReasons[0]}) — inspect ${storePath}; nothing was written (fail closed)`);
|
|
355
|
+
}
|
|
356
|
+
const { line, snapshot } = makeRecord(parsed.records);
|
|
357
|
+
if (existing.split('\n').some((l) => l === line)) {
|
|
358
|
+
throw stop('refusing a byte-identical replayed line (duplicate) — a genuine new record carries new content or timestamp; nothing was written');
|
|
359
|
+
}
|
|
360
|
+
if (preflight != null) preflight({ records: parsed.records, snapshot, line, storePath });
|
|
361
|
+
const prefix = existing === '' ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
|
362
|
+
// The final rename is bound to the SNAPSHOT: (a) the held fd is re-read and byte-compared
|
|
363
|
+
// (a same-inode in-place mutation refuses instead of being clobbered with stale bytes), then
|
|
364
|
+
// (b) the leaf must still show the snapshot inode — or still-absent for a fresh store —
|
|
365
|
+
// immediately before the rename. Rides the frozen writer's deps.rename seam.
|
|
366
|
+
const renameBase = deps.rename ?? renameSync;
|
|
367
|
+
const guardedRename = (from, to) => {
|
|
368
|
+
if (to === storePath) {
|
|
369
|
+
if (storeRead.outcome === 'ok') {
|
|
370
|
+
let same;
|
|
371
|
+
try {
|
|
372
|
+
same = fdContentEquals(snapshotFd, storeRead.bytes);
|
|
373
|
+
} catch (err) {
|
|
374
|
+
throw stop(`cannot re-read the ${nouns.store} snapshot before the final rename (${(err && err.code) || (err && err.message) || err}) — nothing was written (fail closed)`);
|
|
375
|
+
}
|
|
376
|
+
if (!same) {
|
|
377
|
+
throw stop(`the ${nouns.store} ${storePath} content changed under the lock (same-inode in-place mutation) — refusing the final rename; nothing was written (fail closed)`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
let leaf = null;
|
|
381
|
+
try {
|
|
382
|
+
leaf = lstatNoFollow(to, deps.lstat ?? lstatSync);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
throw stop(`cannot verify the ${nouns.store} leaf before the final rename (${(err && err.code) || (err && err.message) || err}) — nothing was written (fail closed)`);
|
|
385
|
+
}
|
|
386
|
+
const identityHeld = storeRead.outcome === 'absent'
|
|
387
|
+
? leaf == null
|
|
388
|
+
: leaf != null && leaf.isFile() && leaf.dev === storeRead.dev && leaf.ino === storeRead.ino;
|
|
389
|
+
if (!identityHeld) {
|
|
390
|
+
throw stop(`the ${nouns.store} ${storePath} changed identity under the lock (concurrent or foreign mutation) — refusing the final rename; nothing was written (fail closed)`);
|
|
391
|
+
}
|
|
392
|
+
if (leaf != null && leaf.nlink !== 1) {
|
|
393
|
+
throw stop(`the ${nouns.store} ${storePath} has ${leaf.nlink} hard links — two path-derived locks would race one inode; remove the extra links and retry (fail closed)`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return renameBase(from, to);
|
|
397
|
+
};
|
|
398
|
+
writeContainedFileAtomic(dirname(storePath), storePath, `${prefix}${line}\n`, { ...deps, rename: guardedRename }, { stop, label: storePath });
|
|
399
|
+
if (snapshotFd !== null) {
|
|
400
|
+
const fd = snapshotFd;
|
|
401
|
+
snapshotFd = null;
|
|
402
|
+
closeSync(fd); // a success-lane close failure surfaces as the append's own error
|
|
403
|
+
}
|
|
404
|
+
return { value: { writtenPath: storePath, record: snapshot } };
|
|
405
|
+
} catch (err) {
|
|
406
|
+
return { err };
|
|
407
|
+
} finally {
|
|
408
|
+
if (snapshotFd !== null) { try { closeSync(snapshotFd); } catch { /* the failure above stays primary */ } }
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
// resolveOrStop(cwd, env, purpose) → the absolute store path; the named refusal when there is
|
|
413
|
+
// none. `purpose` completes the sentence ("append to", "probe", …) so every lane says what it
|
|
414
|
+
// was about to do.
|
|
415
|
+
const resolveOrStop = (cwd, env, purpose) => {
|
|
416
|
+
const resolved = resolveStorePath(cwd, env);
|
|
417
|
+
if (resolved == null) {
|
|
418
|
+
throw stop(`not inside a git work tree (and no ${envNames.store} override) — there is no ${nouns.store} to ${purpose}`);
|
|
419
|
+
}
|
|
420
|
+
return resolved;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// The lock-serialized core every append lane shares: resolve → acquire → makeRecord (UNDER the
|
|
424
|
+
// lock, over the captured store snapshot) → semantic preflight → atomic write → custody release.
|
|
425
|
+
// A factory lane COMPUTES its record inside the critical section (a monotonic index or a budget
|
|
426
|
+
// state cannot be derived lock-free), so makeRecord runs under the lock by contract.
|
|
427
|
+
const appendResolvedRecord = ({ cwd, env, deps, makeRecord, preflight = null }) => {
|
|
428
|
+
const resolved = resolveOrStop(cwd, env, 'append to');
|
|
429
|
+
const { storePath, lockPath, lockFd, lockIdentity } = acquireLock(resolved, env, deps);
|
|
430
|
+
const body = appendUnderLock({ storePath, makeRecord, preflight, deps });
|
|
431
|
+
const releaseIssue = releaseLock(lockPath, lockFd, lockIdentity, deps);
|
|
432
|
+
if (body.err) {
|
|
433
|
+
if (releaseIssue) {
|
|
434
|
+
body.err.releaseViolation = releaseIssue.message;
|
|
435
|
+
if (releaseIssue.closeFailure) body.err.releaseCloseFailure = releaseIssue.closeFailure;
|
|
436
|
+
}
|
|
437
|
+
throw body.err;
|
|
438
|
+
}
|
|
439
|
+
if (releaseIssue) throw releaseIssue;
|
|
440
|
+
return body.value;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
return { resolveOrStop, acquireLock, releaseLock, captureRecordSnapshot, appendUnderLock, appendResolvedRecord };
|
|
444
|
+
};
|