cool-workflow 0.2.1 → 0.2.3
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 +29 -91
- package/dist/cli/entry.js +41 -1
- package/dist/cli/io.js +6 -2
- package/dist/cli/parseargv.js +6 -2
- package/dist/core/capability-data.js +271 -0
- package/dist/core/capability-table.js +16 -3504
- package/dist/core/format/completion.js +68 -0
- package/dist/core/format/help.js +50 -8
- 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 +6 -5
- package/dist/core/multi-agent/coordinator.js +9 -8
- package/dist/core/multi-agent/runtime.js +4 -4
- package/dist/core/multi-agent/topology.js +3 -3
- package/dist/core/pipeline/commit-gate.js +2 -1
- 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 +16 -15
- package/dist/core/state/state-explosion/graph.js +26 -19
- package/dist/core/state/state-explosion/helpers.js +2 -1
- package/dist/core/state/state-explosion/report.js +6 -6
- package/dist/core/trust/ledger.js +2 -1
- package/dist/core/types/execution-backend.js +18 -0
- package/dist/core/types/observability.js +7 -0
- package/dist/core/util/collate.js +23 -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 +99 -23
- package/dist/shell/commit-summary.js +2 -1
- 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 +269 -59
- package/dist/shell/eval-text.js +2 -2
- package/dist/shell/evidence-reasoning.js +8 -7
- package/dist/shell/execution-backend/agent.js +20 -1
- package/dist/shell/execution-backend/container.js +4 -1
- package/dist/shell/execution-backend/local.js +4 -1
- package/dist/shell/execution-backend/registry.js +2 -1
- package/dist/shell/execution-backend/types.js +0 -9
- package/dist/shell/feedback-cli.js +6 -6
- package/dist/shell/fs-atomic.js +232 -30
- 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 +82 -39
- package/dist/shell/node-store.js +10 -4
- package/dist/shell/observability.js +13 -12
- package/dist/shell/onramp.js +17 -1
- package/dist/shell/operator-ux-text.js +24 -23
- package/dist/shell/operator-ux.js +22 -20
- package/dist/shell/orchestrator.js +49 -38
- package/dist/shell/pipeline-cli.js +168 -111
- package/dist/shell/reclamation-io.js +16 -5
- package/dist/shell/registry-cli.js +19 -17
- package/dist/shell/remote-source.js +13 -8
- package/dist/shell/report-cli.js +45 -0
- package/dist/shell/report.js +4 -2
- package/dist/shell/run-registry-io.js +77 -19
- package/dist/shell/run-store.js +91 -2
- package/dist/shell/scheduling-io.js +12 -13
- package/dist/shell/state-cli.js +2 -7
- package/dist/shell/state-explosion-cli.js +19 -10
- package/dist/shell/topology-io.js +38 -6
- package/dist/shell/trust-audit.js +264 -23
- package/dist/shell/trust-policy-io.js +1 -1
- package/dist/shell/worker-cli.js +41 -29
- package/dist/shell/worker-isolation.js +34 -9
- package/dist/shell/workflow-app-loader.js +3 -2
- package/dist/wiring/capability-table/basics.js +91 -0
- package/dist/wiring/capability-table/exec-backend.js +171 -0
- package/dist/wiring/capability-table/index.js +42 -0
- package/dist/wiring/capability-table/multi-agent.js +630 -0
- package/dist/wiring/capability-table/parity.js +467 -0
- package/dist/wiring/capability-table/pipeline.js +292 -0
- package/dist/wiring/capability-table/registry-core.js +208 -0
- package/dist/wiring/capability-table/reporting.js +435 -0
- package/dist/wiring/capability-table/scheduling-registry.js +592 -0
- package/dist/wiring/capability-table/state.js +181 -0
- package/dist/wiring/capability-table/trust-ledger.js +158 -0
- package/dist/wiring/capability-table/workflow-apps.js +381 -0
- 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 +13 -3
- 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 +24 -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/index.md +1 -0
- 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 +48 -5
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +5 -1
- package/docs/release-tooling.7.md +4 -0
- package/docs/routines.md +4 -4
- package/docs/run-registry-control-plane.7.md +23 -19
- package/docs/run-retention-reclamation.7.md +4 -0
- package/docs/scheduled-tasks.md +14 -14
- package/docs/security-trust-hardening.7.md +43 -13
- package/docs/state-explosion-management.7.md +14 -10
- package/docs/team-collaboration.7.md +4 -0
- package/docs/trust-audit-anchor.7.md +71 -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 +4 -2
- package/scripts/agents/claude-p-agent.js +2 -2
- package/scripts/block-unapproved-tag.sh +23 -2
- package/scripts/bump-version.js +24 -2
- 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 +71 -0
- package/scripts/purity-gate.js +239 -0
- package/scripts/release-check.js +8 -1
- package/scripts/release-flow.js +57 -1
- 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 +33 -12
- 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/workflows/README.md +19 -0
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,42 +216,225 @@ 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;
|
|
230
|
+
// Lock paths this process holds right now. A nested withFileLock on the
|
|
231
|
+
// SAME target runs its fn directly (re-entrant) instead of waiting on its
|
|
232
|
+
// own lock file until the 240 tries run out — that lets a whole
|
|
233
|
+
// load -> change -> save cycle hold one lock while the save path inside
|
|
234
|
+
// it keeps its own withFileLock call unchanged.
|
|
235
|
+
const HELD_LOCKS = new Set();
|
|
201
236
|
function sleepSync(ms) {
|
|
202
237
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
203
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
|
+
}
|
|
204
365
|
/** Run `fn` while holding an advisory lock for `targetPath`; always released
|
|
205
366
|
* (unless the lock was stolen mid-operation, in which case releasing would
|
|
206
|
-
* corrupt the thief's critical section, so it is deliberately NOT released).
|
|
367
|
+
* corrupt the thief's critical section, so it is deliberately NOT released).
|
|
368
|
+
* Re-entrant inside one process: a nested call on the same target runs
|
|
369
|
+
* `fn` under the already-held lock. */
|
|
207
370
|
function withFileLock(targetPath, fn) {
|
|
208
371
|
const lock = `${targetPath}.lock`;
|
|
372
|
+
const heldKey = path.resolve(lock);
|
|
373
|
+
if (HELD_LOCKS.has(heldKey))
|
|
374
|
+
return fn();
|
|
209
375
|
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
210
376
|
const pid = String(process.pid);
|
|
211
377
|
let acquired = false;
|
|
212
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;
|
|
213
397
|
try {
|
|
214
|
-
|
|
215
|
-
fs.
|
|
216
|
-
fs.closeSync(fd);
|
|
398
|
+
fs.writeFileSync(tmp, `${pid}@${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
399
|
+
fs.linkSync(tmp, lock);
|
|
217
400
|
acquired = true;
|
|
218
401
|
}
|
|
219
402
|
catch (error) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
403
|
+
linkError = error;
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
223
406
|
try {
|
|
224
|
-
|
|
225
|
-
fs.rmSync(lock, { force: true });
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
407
|
+
fs.unlinkSync(tmp);
|
|
228
408
|
}
|
|
229
409
|
catch {
|
|
230
|
-
|
|
410
|
+
/* already gone, or the link consumed it — either is fine */
|
|
231
411
|
}
|
|
232
|
-
sleepSync(25);
|
|
233
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);
|
|
234
434
|
}
|
|
235
435
|
if (!acquired)
|
|
236
436
|
throw new Error(`could not acquire file lock for ${targetPath}`);
|
|
437
|
+
HELD_LOCKS.add(heldKey);
|
|
237
438
|
// Refresh mtime right before the critical section.
|
|
238
439
|
try {
|
|
239
440
|
fs.utimesSync(lock, new Date(), new Date());
|
|
@@ -263,6 +464,7 @@ function withFileLock(targetPath, fn) {
|
|
|
263
464
|
return result;
|
|
264
465
|
}
|
|
265
466
|
finally {
|
|
467
|
+
HELD_LOCKS.delete(heldKey);
|
|
266
468
|
try {
|
|
267
469
|
// Only release if we still own the lock.
|
|
268
470
|
const current = fs.readFileSync(lock, "utf8");
|
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;
|