cool-workflow 0.2.2 → 0.2.4
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/apps/architecture-review/app.json +1 -1
- package/apps/architecture-review-fast/app.json +1 -1
- package/apps/end-to-end-golden-path/app.json +1 -1
- package/apps/pr-review-fix-ci/app.json +1 -1
- package/apps/release-cut/app.json +1 -1
- package/apps/research-synthesis/app.json +1 -1
- package/dist/cli/dispatch.js +13 -6
- package/dist/cli/entry.js +41 -1
- package/dist/cli/io.js +12 -22
- package/dist/cli/parseargv.js +1 -0
- package/dist/core/capability-data.js +3 -2
- package/dist/core/format/completion.js +68 -0
- package/dist/core/format/help.js +31 -6
- package/dist/core/format/safe-json.js +73 -0
- package/dist/core/format/state-explosion-text.js +1 -1
- package/dist/core/multi-agent/candidate-scoring.js +5 -1
- package/dist/core/multi-agent/collaboration.js +3 -3
- package/dist/core/multi-agent/coordinator.js +5 -5
- package/dist/core/multi-agent/runtime.js +4 -4
- package/dist/core/multi-agent/topology.js +3 -3
- package/dist/core/pipeline/dispatch.js +18 -4
- package/dist/core/pipeline/drive-decide.js +2 -1
- package/dist/core/state/migrations.js +16 -1
- package/dist/core/state/state-explosion/digest.js +13 -13
- package/dist/core/state/state-explosion/graph.js +17 -11
- package/dist/core/state/state-explosion/report.js +6 -6
- package/dist/core/util/cli-args.js +33 -0
- package/dist/core/util/numeric-flag.js +40 -0
- package/dist/core/version.js +1 -1
- package/dist/mcp/server.js +99 -11
- package/dist/shell/audit-cli.js +57 -25
- package/dist/shell/coordinator-io.js +73 -13
- package/dist/shell/dispatch.js +1 -1
- package/dist/shell/doctor.js +80 -1
- package/dist/shell/drive.js +251 -49
- package/dist/shell/eval-text.js +2 -2
- package/dist/shell/evidence-reasoning.js +4 -4
- package/dist/shell/execution-backend/agent.js +30 -2
- package/dist/shell/execution-backend/container.js +4 -1
- package/dist/shell/execution-backend/local.js +19 -11
- package/dist/shell/feedback-cli.js +6 -6
- package/dist/shell/fs-atomic.js +218 -29
- package/dist/shell/man-cli.js +6 -0
- package/dist/shell/metrics-cli.js +2 -1
- package/dist/shell/multi-agent-cli.js +367 -323
- package/dist/shell/multi-agent-host.js +9 -9
- package/dist/shell/multi-agent-operator-ux.js +80 -38
- package/dist/shell/node-store.js +10 -4
- package/dist/shell/observability.js +1 -1
- package/dist/shell/operator-ux-text.js +22 -22
- package/dist/shell/operator-ux.js +15 -14
- package/dist/shell/orchestrator.js +49 -38
- package/dist/shell/pipeline-cli.js +107 -42
- package/dist/shell/reclamation-io.js +88 -10
- package/dist/shell/registry-cli.js +23 -17
- package/dist/shell/remote-source.js +13 -8
- package/dist/shell/report-cli.js +45 -0
- package/dist/shell/report.js +2 -1
- package/dist/shell/run-registry-io.js +78 -19
- package/dist/shell/run-store.js +74 -2
- package/dist/shell/scheduling-io.js +12 -13
- package/dist/shell/state-cli.js +2 -7
- package/dist/shell/state-explosion-cli.js +17 -9
- package/dist/shell/topology-io.js +36 -5
- package/dist/shell/trust-audit.js +224 -22
- package/dist/shell/trust-policy-io.js +1 -1
- package/dist/shell/worker-cli.js +35 -31
- package/dist/shell/workflow-app-loader.js +67 -1
- package/dist/wiring/capability-table/basics.js +33 -8
- package/dist/wiring/capability-table/exec-backend.js +28 -19
- package/dist/wiring/capability-table/multi-agent.js +187 -180
- package/dist/wiring/capability-table/parity.js +1 -0
- package/dist/wiring/capability-table/pipeline.js +64 -52
- package/dist/wiring/capability-table/registry-core.js +30 -11
- package/dist/wiring/capability-table/reporting.js +147 -106
- package/dist/wiring/capability-table/scheduling-registry.js +201 -167
- package/dist/wiring/capability-table/state.js +59 -51
- package/dist/wiring/capability-table/trust-ledger.js +52 -28
- package/dist/wiring/capability-table/workflow-apps.js +85 -70
- package/docs/agent-delegation-drive.7.md +4 -0
- package/docs/candidate-scoring.7.md +1 -1
- package/docs/canonical-workflow-apps.7.md +7 -7
- package/docs/cli-mcp-parity.7.md +26 -16
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +4 -0
- package/docs/coordinator-blackboard.7.md +17 -17
- package/docs/dogfood-one-real-repo.7.md +11 -11
- package/docs/durable-state-and-locking.7.md +4 -0
- package/docs/end-to-end-golden-path.7.md +14 -14
- package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
- package/docs/execution-backends.7.md +4 -0
- package/docs/getting-started.md +37 -37
- package/docs/multi-agent-cli-mcp-surface.7.md +21 -17
- package/docs/multi-agent-eval-replay-harness.7.md +17 -13
- package/docs/multi-agent-operator-ux.7.md +23 -19
- package/docs/multi-agent-runtime-core.7.md +22 -22
- package/docs/multi-agent-topologies.7.md +6 -6
- package/docs/multi-agent-trust-policy-audit.7.md +11 -11
- package/docs/node-snapshot-diff-replay.7.md +4 -0
- package/docs/observability-cost-accounting.7.md +4 -0
- package/docs/operator-ux.7.md +34 -34
- package/docs/pipeline-runner.7.md +4 -4
- package/docs/project-index.md +31 -4
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +5 -1
- package/docs/release-tooling.7.md +22 -0
- package/docs/routines.md +4 -4
- package/docs/run-registry-control-plane.7.md +23 -19
- package/docs/run-retention-reclamation.7.md +24 -3
- package/docs/scheduled-tasks.md +14 -14
- package/docs/security-trust-hardening.7.md +15 -15
- package/docs/state-explosion-management.7.md +14 -10
- package/docs/team-collaboration.7.md +4 -0
- package/docs/trust-audit-anchor.7.md +4 -0
- package/docs/unix-principles.md +3 -1
- package/docs/verifier-gated-commit.7.md +1 -1
- package/docs/web-desktop-workbench.7.md +4 -0
- package/docs/workflow-app-framework.7.md +13 -13
- package/manifest/plugin.manifest.json +1 -1
- package/package.json +3 -2
- package/scripts/agents/claude-p-agent.js +2 -2
- package/scripts/block-unapproved-tag.sh +42 -6
- package/scripts/bump-version.js +27 -10
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +52 -2
- package/scripts/dogfood-release.js +1 -1
- package/scripts/fake-date-for-reproduction.js +44 -0
- package/scripts/golden-path.js +4 -4
- package/scripts/purity-baseline.json +0 -27
- package/scripts/release-flow.js +194 -13
- package/scripts/release-oneclick.js +407 -0
- package/scripts/verdict-keygen.js +83 -0
- package/scripts/verify-bump-reproduction.sh +148 -0
- package/scripts/verify-verdict-signature.js +61 -0
- package/scripts/version-sync-check.js +39 -22
- package/skills/cool-workflow/SKILL.md +9 -9
- package/skills/cool-workflow/references/commands.md +89 -88
- package/ui/workbench/app.css +37 -1
- package/ui/workbench/app.js +124 -6
package/dist/shell/fs-atomic.js
CHANGED
|
@@ -41,6 +41,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
41
41
|
})();
|
|
42
42
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
43
|
exports.writeJson = writeJson;
|
|
44
|
+
exports.writeTextDurable = writeTextDurable;
|
|
44
45
|
exports.readJson = readJson;
|
|
45
46
|
exports.safeFileName = safeFileName;
|
|
46
47
|
exports.assertSafeRunId = assertSafeRunId;
|
|
@@ -55,19 +56,22 @@ const path = __importStar(require("node:path"));
|
|
|
55
56
|
// + "\n", written via temp-file + optional fsync + rename (atomic).
|
|
56
57
|
// ---------------------------------------------------------------------------
|
|
57
58
|
let atomicWriteCounter = 0;
|
|
58
|
-
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* sees either the old bytes or the new bytes, never a torn
|
|
62
|
-
* failure the temp file is removed (best-effort) and
|
|
63
|
-
* the old bytes at `file` are never touched. */
|
|
64
|
-
function
|
|
59
|
+
/** Shared core of `writeJson`/`writeTextDurable`: write `contents` to a
|
|
60
|
+
* unique temp file, optionally fsync it, close, then rename over `file`.
|
|
61
|
+
* ORDER IS THE SAFETY PROPERTY — `rename(2)` is atomic on POSIX, so a
|
|
62
|
+
* reader always sees either the old bytes or the new bytes, never a torn
|
|
63
|
+
* file. On a rename failure the temp file is removed (best-effort) and
|
|
64
|
+
* the error is rethrown — the old bytes at `file` are never touched. */
|
|
65
|
+
function writeBytesAtomic(file, contents, durable) {
|
|
65
66
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
66
67
|
const tmp = `${file}.tmp.${process.pid}.${atomicWriteCounter++}`;
|
|
67
|
-
|
|
68
|
+
// 0o600: this is run/ledger/registry state, not a file meant to be shared
|
|
69
|
+
// with other local users. rename(2) preserves the mode set at creation, so
|
|
70
|
+
// this covers the final file too.
|
|
71
|
+
const fd = fs.openSync(tmp, "w", 0o600);
|
|
68
72
|
try {
|
|
69
|
-
fs.writeFileSync(fd,
|
|
70
|
-
if (
|
|
73
|
+
fs.writeFileSync(fd, contents, "utf8");
|
|
74
|
+
if (durable)
|
|
71
75
|
fs.fsyncSync(fd);
|
|
72
76
|
}
|
|
73
77
|
finally {
|
|
@@ -85,7 +89,7 @@ function writeJson(file, value, options = {}) {
|
|
|
85
89
|
}
|
|
86
90
|
throw error;
|
|
87
91
|
}
|
|
88
|
-
if (
|
|
92
|
+
if (durable) {
|
|
89
93
|
try {
|
|
90
94
|
const dirFd = fs.openSync(path.dirname(file), "r");
|
|
91
95
|
try {
|
|
@@ -100,6 +104,17 @@ function writeJson(file, value, options = {}) {
|
|
|
100
104
|
}
|
|
101
105
|
}
|
|
102
106
|
}
|
|
107
|
+
/** Atomic, optionally-durable JSON write — see `writeBytesAtomic`. */
|
|
108
|
+
function writeJson(file, value, options = {}) {
|
|
109
|
+
writeBytesAtomic(file, `${JSON.stringify(value, null, 2)}\n`, Boolean(options.durable));
|
|
110
|
+
}
|
|
111
|
+
/** Same atomic-write contract as `writeJson` (temp file + optional fsync +
|
|
112
|
+
* rename), but for exact pre-built text instead of a value to serialize —
|
|
113
|
+
* used by callers replacing an NDJSON-style file (e.g. a repaired
|
|
114
|
+
* trust-audit event log) where the caller already has the final bytes. */
|
|
115
|
+
function writeTextDurable(file, text, options = {}) {
|
|
116
|
+
writeBytesAtomic(file, text, Boolean(options.durable));
|
|
117
|
+
}
|
|
103
118
|
/** Read + JSON.parse a file. Throws `File not found: <file>` when absent,
|
|
104
119
|
* `Invalid JSON in <file>: <message>` on a parse error. */
|
|
105
120
|
function readJson(file) {
|
|
@@ -172,7 +187,7 @@ function isContainedPath(candidate, allowed) {
|
|
|
172
187
|
// ---------------------------------------------------------------------------
|
|
173
188
|
function durableAppendFileSync(file, data) {
|
|
174
189
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
175
|
-
const fd = fs.openSync(file, "a");
|
|
190
|
+
const fd = fs.openSync(file, "a", 0o600);
|
|
176
191
|
try {
|
|
177
192
|
fs.writeFileSync(fd, data, "utf8");
|
|
178
193
|
fs.fsyncSync(fd);
|
|
@@ -184,11 +199,14 @@ function durableAppendFileSync(file, data) {
|
|
|
184
199
|
// ---------------------------------------------------------------------------
|
|
185
200
|
// withFileLock — portable advisory cross-process lock.
|
|
186
201
|
//
|
|
187
|
-
// Lock file: `<targetPath>.lock`, created
|
|
188
|
-
// `"<pid>@<ISO>\n"`.
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
202
|
+
// Lock file: `<targetPath>.lock`, created by hard-linking a per-attempt temp
|
|
203
|
+
// file onto the lock path (single-winner `link(2)`), body `"<pid>@<ISO>\n"`.
|
|
204
|
+
// Up to 240 tries; on EEXIST, a lock whose mtime is older than
|
|
205
|
+
// FILE_LOCK_STALE_MS (30_000) is stolen — judged and deleted only while
|
|
206
|
+
// holding the single-winner `<lock>.steal` guard (see
|
|
207
|
+
// stealStaleLockUnderGuard) — and retried AT ONCE; else sleep 25ms
|
|
208
|
+
// (Atomics.wait busy-safe sleep) and retry. Any non-EEXIST link error is
|
|
209
|
+
// rethrown. No lock after 240 tries throws
|
|
192
210
|
// `could not acquire file lock for <targetPath>`.
|
|
193
211
|
//
|
|
194
212
|
// Right before fn() the lock mtime is refreshed (utimesSync, best-effort).
|
|
@@ -198,6 +216,17 @@ function durableAppendFileSync(file, data) {
|
|
|
198
216
|
// only when still owned by this pid.
|
|
199
217
|
// ---------------------------------------------------------------------------
|
|
200
218
|
const FILE_LOCK_STALE_MS = 30_000;
|
|
219
|
+
// A lock stale for this long is stolen regardless of what `isLockOwnerAlive`
|
|
220
|
+
// says — the fallback for the one case a liveness check cannot resolve: the
|
|
221
|
+
// recorded pid has been recycled by an unrelated live process, which would
|
|
222
|
+
// otherwise make a dead owner's pid look "alive" forever and wedge the lock
|
|
223
|
+
// permanently. 10x the normal window keeps this astronomically unlikely to
|
|
224
|
+
// fire against a genuinely still-working holder.
|
|
225
|
+
const FILE_LOCK_FORCE_STALE_MS = FILE_LOCK_STALE_MS * 10;
|
|
226
|
+
// A steal guard older than this belongs to a stealer that crashed inside the
|
|
227
|
+
// guarded window (which is a handful of syscalls, microseconds for any live
|
|
228
|
+
// process) — remove it so stealing cannot wedge forever.
|
|
229
|
+
const FILE_LOCK_STEAL_GUARD_STALE_MS = FILE_LOCK_STALE_MS;
|
|
201
230
|
// Lock paths this process holds right now. A nested withFileLock on the
|
|
202
231
|
// SAME target runs its fn directly (re-entrant) instead of waiting on its
|
|
203
232
|
// own lock file until the 240 tries run out — that lets a whole
|
|
@@ -207,6 +236,132 @@ const HELD_LOCKS = new Set();
|
|
|
207
236
|
function sleepSync(ms) {
|
|
208
237
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
209
238
|
}
|
|
239
|
+
/** Whether the pid recorded in a lock body ("<pid>@<ISO>\n") is still a
|
|
240
|
+
* live process on this machine. Locks here are local-advisory only (no
|
|
241
|
+
* network-shared run directories in this tool's model), so
|
|
242
|
+
* `process.kill(pid, 0)` — signal 0 delivers nothing, it just probes
|
|
243
|
+
* existence/permission — gives a direct, same-instant answer, unlike
|
|
244
|
+
* mtime, which is a snapshot a caller can act on after it is already
|
|
245
|
+
* outdated. This is the property that actually closes the steal race: a
|
|
246
|
+
* lock belonging to a currently running process can never pass this
|
|
247
|
+
* check, no matter how a mtime-based timing window lines up, because its
|
|
248
|
+
* owner really is alive right now. Malformed content or a missing pid is
|
|
249
|
+
* treated as "not verifiably alive" so the mtime-only path still applies.
|
|
250
|
+
* `EPERM` (a process with that pid exists but this process cannot signal
|
|
251
|
+
* it) is treated as alive — the conservative, never-wrongly-steal
|
|
252
|
+
* direction. */
|
|
253
|
+
function isLockOwnerAlive(lockBody) {
|
|
254
|
+
const match = /^(\d+)@/.exec(lockBody);
|
|
255
|
+
if (!match)
|
|
256
|
+
return false;
|
|
257
|
+
const pid = Number(match[1]);
|
|
258
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
259
|
+
return false;
|
|
260
|
+
try {
|
|
261
|
+
process.kill(pid, 0);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
return Boolean(error && typeof error === "object" && error.code === "EPERM");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** Steal a stale lock, serialized through a single-winner guard file.
|
|
269
|
+
*
|
|
270
|
+
* Every earlier steal design judged the lock and then deleted it as two
|
|
271
|
+
* separate steps with no exclusion around them, and every narrowing of
|
|
272
|
+
* that gap (content-equality re-read, liveness gate, unlinkSync over
|
|
273
|
+
* rmSync) still lost a CI run eventually: under load, the judging
|
|
274
|
+
* process can be preempted between its last read and its unlink, a
|
|
275
|
+
* concurrent stealer completes the whole steal-and-reacquire in that
|
|
276
|
+
* gap, and the sleeper's unlink then removes the NEW owner's lock —
|
|
277
|
+
* two processes end up inside the critical section (reproduced in PR
|
|
278
|
+
* #420's CI on arm64 Node 22, trial 10, after the linkSync acquire fix
|
|
279
|
+
* landed). The gap cannot be closed from the judging side; POSIX has no
|
|
280
|
+
* compare-and-delete.
|
|
281
|
+
*
|
|
282
|
+
* So close it by exclusion instead: only the holder of `<lock>.steal`
|
|
283
|
+
* (acquired with the same single-winner linkSync used for the lock
|
|
284
|
+
* itself) may judge and delete. While the judged lock file still
|
|
285
|
+
* exists, no waiter can link-acquire the lock path, so nothing can
|
|
286
|
+
* replace the lock between a guard-held verdict and the unlink; and no
|
|
287
|
+
* second stealer exists to double-delete. Before the unlink, the
|
|
288
|
+
* verdict is additionally pinned to the exact file it judged (same
|
|
289
|
+
* inode, same mtime) — insurance for the force-stale path, where an
|
|
290
|
+
* owner release + fresh re-acquire between the guard's stat and read
|
|
291
|
+
* could otherwise slip through.
|
|
292
|
+
*
|
|
293
|
+
* A guard whose holder crashed inside the guarded window (microseconds
|
|
294
|
+
* of syscalls for any live process) is removed after
|
|
295
|
+
* FILE_LOCK_STEAL_GUARD_STALE_MS so stealing cannot wedge; that cleanup
|
|
296
|
+
* is itself a plain stat-and-unlink, but reintroducing the original
|
|
297
|
+
* race through it needs a crashed stealer AND two cleaners AND a fresh
|
|
298
|
+
* guard all colliding inside one syscall-wide window — compounded odds
|
|
299
|
+
* the hot path never sees.
|
|
300
|
+
*
|
|
301
|
+
* Returns true when this process held the guard and rendered a verdict
|
|
302
|
+
* (steal done or judged-not-stale) — the caller should retry the
|
|
303
|
+
* acquire at once. Returns false when the guard was busy — the caller
|
|
304
|
+
* should back off like ordinary contention. */
|
|
305
|
+
function stealStaleLockUnderGuard(lock, pid, attempt) {
|
|
306
|
+
const guard = `${lock}.steal`;
|
|
307
|
+
try {
|
|
308
|
+
const guardAge = Date.now() - fs.statSync(guard).mtimeMs;
|
|
309
|
+
if (guardAge > FILE_LOCK_STEAL_GUARD_STALE_MS)
|
|
310
|
+
fs.unlinkSync(guard);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
/* no guard, or another cleaner beat us to it */
|
|
314
|
+
}
|
|
315
|
+
const tmp = `${guard}.${pid}.${attempt}.tmp`;
|
|
316
|
+
let haveGuard = false;
|
|
317
|
+
try {
|
|
318
|
+
fs.writeFileSync(tmp, `${pid}@${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
319
|
+
fs.linkSync(tmp, guard);
|
|
320
|
+
haveGuard = true;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
/* EEXIST — another stealer holds the guard */
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
try {
|
|
327
|
+
fs.unlinkSync(tmp);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
/* already gone */
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (!haveGuard)
|
|
334
|
+
return false;
|
|
335
|
+
try {
|
|
336
|
+
// Re-judge INSIDE the guard: only what is verified here counts.
|
|
337
|
+
const judged = fs.statSync(lock);
|
|
338
|
+
const age = Date.now() - judged.mtimeMs;
|
|
339
|
+
if (age <= FILE_LOCK_STALE_MS)
|
|
340
|
+
return true;
|
|
341
|
+
const body = fs.readFileSync(lock, "utf8");
|
|
342
|
+
if (!(age > FILE_LOCK_FORCE_STALE_MS || !isLockOwnerAlive(body)))
|
|
343
|
+
return true;
|
|
344
|
+
// Pin the verdict to the exact file it judged: a lock re-acquired in
|
|
345
|
+
// the stat->read gap is a NEW inode (every acquire links a fresh tmp
|
|
346
|
+
// file) with a fresh mtime, so either check catches it.
|
|
347
|
+
const current = fs.statSync(lock);
|
|
348
|
+
if (current.ino === judged.ino && current.mtimeMs === judged.mtimeMs) {
|
|
349
|
+
fs.unlinkSync(lock);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
/* lock vanished — nothing left to steal */
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
try {
|
|
357
|
+
fs.unlinkSync(guard);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
/* guard already cleaned up */
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
210
365
|
/** Run `fn` while holding an advisory lock for `targetPath`; always released
|
|
211
366
|
* (unless the lock was stolen mid-operation, in which case releasing would
|
|
212
367
|
* corrupt the thief's critical section, so it is deliberately NOT released).
|
|
@@ -221,27 +376,61 @@ function withFileLock(targetPath, fn) {
|
|
|
221
376
|
const pid = String(process.pid);
|
|
222
377
|
let acquired = false;
|
|
223
378
|
for (let attempt = 0; attempt < 240 && !acquired; attempt++) {
|
|
379
|
+
// Acquire via `linkSync`, not `open(lock, "wx")`: directly testing the
|
|
380
|
+
// retry loop under contention showed `open(O_CREAT|O_EXCL)` is NOT
|
|
381
|
+
// reliably single-winner on every Node version this runs on — under
|
|
382
|
+
// CI load (reproduced on Node 18, both x64 and arm64), two separate
|
|
383
|
+
// processes could each `open(wx)` + write + read back and each see
|
|
384
|
+
// ONLY ITS OWN content, meaning the OS was not actually serializing
|
|
385
|
+
// the two creates against each other (see
|
|
386
|
+
// test/fs-atomic-lock-steal-race-smoke.js for the reproduction). A
|
|
387
|
+
// readback-verification pass added on top of `open(wx)` still could
|
|
388
|
+
// not close this, because the underlying create itself was the
|
|
389
|
+
// unreliable step, not just the later unlink. `link(2)`'s exclusivity
|
|
390
|
+
// guarantee is the older, more battle-tested primitive precisely for
|
|
391
|
+
// this class of lockfile race (classic Unix "lock via link" idiom) —
|
|
392
|
+
// stress-tested directly (24-process contention, 15/15 trials) with
|
|
393
|
+
// zero double-winners where the same rig reproduced `open(wx)`
|
|
394
|
+
// double-winners within a handful of trials.
|
|
395
|
+
const tmp = `${lock}.${pid}.${attempt}.tmp`;
|
|
396
|
+
let linkError = null;
|
|
224
397
|
try {
|
|
225
|
-
|
|
226
|
-
fs.
|
|
227
|
-
fs.closeSync(fd);
|
|
398
|
+
fs.writeFileSync(tmp, `${pid}@${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
399
|
+
fs.linkSync(tmp, lock);
|
|
228
400
|
acquired = true;
|
|
229
401
|
}
|
|
230
402
|
catch (error) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
403
|
+
linkError = error;
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
234
406
|
try {
|
|
235
|
-
|
|
236
|
-
fs.rmSync(lock, { force: true });
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
407
|
+
fs.unlinkSync(tmp);
|
|
239
408
|
}
|
|
240
409
|
catch {
|
|
241
|
-
|
|
410
|
+
/* already gone, or the link consumed it — either is fine */
|
|
242
411
|
}
|
|
243
|
-
sleepSync(25);
|
|
244
412
|
}
|
|
413
|
+
if (acquired)
|
|
414
|
+
break;
|
|
415
|
+
if (!(linkError && typeof linkError === "object" && linkError.code === "EEXIST")) {
|
|
416
|
+
throw linkError;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
const age = Date.now() - fs.statSync(lock).mtimeMs;
|
|
420
|
+
if (age > FILE_LOCK_STALE_MS) {
|
|
421
|
+
// The steal itself runs under a single-winner guard lock — see
|
|
422
|
+
// stealStaleLockUnderGuard for why judging staleness and deleting
|
|
423
|
+
// the lock without one can never be made safe from here.
|
|
424
|
+
if (stealStaleLockUnderGuard(lock, pid, attempt))
|
|
425
|
+
continue;
|
|
426
|
+
// Guard was busy — another process is mid-steal. Back off like
|
|
427
|
+
// ordinary contention instead of hot-spinning the attempt budget.
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
sleepSync(25);
|
|
245
434
|
}
|
|
246
435
|
if (!acquired)
|
|
247
436
|
throw new Error(`could not acquire file lock for ${targetPath}`);
|
package/dist/shell/man-cli.js
CHANGED
|
@@ -47,6 +47,7 @@ exports.ManPageNotFoundError = void 0;
|
|
|
47
47
|
exports.readManPage = readManPage;
|
|
48
48
|
const fs = __importStar(require("node:fs"));
|
|
49
49
|
const path = __importStar(require("node:path"));
|
|
50
|
+
const fs_atomic_1 = require("./fs-atomic");
|
|
50
51
|
class ManPageNotFoundError extends Error {
|
|
51
52
|
topic;
|
|
52
53
|
constructor(topic) {
|
|
@@ -77,6 +78,11 @@ function readManPage(topic) {
|
|
|
77
78
|
const docsDir = path.join(pluginRoot(), "docs");
|
|
78
79
|
const candidates = [path.join(docsDir, `${topic}.7.md`), path.join(docsDir, `${topic}.md`), path.join(docsDir, topic)];
|
|
79
80
|
for (const candidate of candidates) {
|
|
81
|
+
// A topic containing `..` or an absolute-path escape must never resolve
|
|
82
|
+
// outside docsDir — this bare third candidate would otherwise read any
|
|
83
|
+
// file on disk (e.g. `cw man ../../../../etc/passwd`).
|
|
84
|
+
if (!(0, fs_atomic_1.isContainedPath)(candidate, docsDir))
|
|
85
|
+
continue;
|
|
80
86
|
try {
|
|
81
87
|
if (fs.statSync(candidate).isFile())
|
|
82
88
|
return fs.readFileSync(candidate, "utf8");
|
|
@@ -44,6 +44,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
44
44
|
exports.metricsShowCli = metricsShowCli;
|
|
45
45
|
exports.metricsSummaryCli = metricsSummaryCli;
|
|
46
46
|
const path = __importStar(require("node:path"));
|
|
47
|
+
const numeric_flag_1 = require("../core/util/numeric-flag");
|
|
47
48
|
const run_store_1 = require("./run-store");
|
|
48
49
|
const observability_1 = require("./observability");
|
|
49
50
|
const run_registry_io_1 = require("./run-registry-io");
|
|
@@ -78,7 +79,7 @@ function metricsSummaryCli(args) {
|
|
|
78
79
|
const cwd = invocationCwd(args);
|
|
79
80
|
const scope = args.scope === "home" ? "home" : "repo";
|
|
80
81
|
const registry = new run_registry_io_1.RunRegistry(cwd);
|
|
81
|
-
const limit =
|
|
82
|
+
const limit = (0, numeric_flag_1.requiredNumberFlag)(args.limit, "--limit");
|
|
82
83
|
const listing = registry.list({ scope, includeArchived: true, limit });
|
|
83
84
|
const inputs = [];
|
|
84
85
|
let unreadableRuns = 0;
|