From 4a8725f1364cb3ee9933a798299256ca88d67a99 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 4 Aug 2026 11:08:43 +0700 Subject: [PATCH] feat(locks): add ccam lock status/acquire/release CLI (D) --- bin/ccam.js | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/bin/ccam.js b/bin/ccam.js index 9077a9b..2611890 100755 --- a/bin/ccam.js +++ b/bin/ccam.js @@ -1900,6 +1900,132 @@ async function cmdLanesRuntime(sub, args) { } } +/** + * The default lock holder identity for the calling lane: `lane` when + * the lane has one allocated, else `lane` — a lane's own row id — as a + * fallback for a lane that has never brought its runtime up. `--holder` + * always overrides both. + * + * @param {string[]} argsAfterName - Args AFTER the lock name has already been + * consumed by the caller (mirrors `cmdStage`'s `resolveLaneArg(args.slice(1))` + * call) — `resolveLaneArg` treats a leading all-digits positional as a lane + * id, so the lock name itself must never reach it (a lock literally named + * e.g. "3" would otherwise be misread as lane 3). + */ +async function defaultHolder(argsAfterName) { + const explicit = (() => { + const i = argsAfterName.indexOf("--holder"); + return i > -1 ? argsAfterName[i + 1] : undefined; + })(); + if (explicit) return explicit; + + const resolved = await resolveLaneArg(argsAfterName); + if (!resolved) return null; + const { lane } = await get(`/api/lanes/${resolved.laneId}`); + return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`; +} + +function fmtLockRow(lock) { + const mins = Math.floor(lock.ageSec / 60); + return `${lock.name.padEnd(20)} held by ${lock.holder.padEnd(10)} for ${mins}m`; +} + +/** `ccam lock status []` — one lock, or every held lock. */ +async function cmdLockStatus(args) { + const name = args.find((arg) => !arg.startsWith("--")); + if (name) { + const { locks } = await get("/api/locks"); + const lock = locks.find((l) => l.name === name); + console.log(lock ? fmtLockRow(lock) : `${name}: free`); + return; + } + const { locks } = await get("/api/locks"); + if (!locks.length) { + console.log("no locks held"); + return; + } + for (const lock of locks) console.log(fmtLockRow(lock)); +} + +/** + * `ccam lock acquire [--holder X] [--timeout N]` — polls until the + * lock is free (or `--timeout` seconds elapse). Prints a status line every + * ~60s of continued waiting so a long wait never reads as a hung command — + * this is the CLI-side "heartbeat" the design calls for; it is terminal + * output, not a dashboard liveness signal. + */ +async function cmdLockAcquire(args) { + const name = args.find((arg) => !arg.startsWith("--")); + if (!name) { + console.error("usage: ccam lock acquire [--holder X] [--timeout seconds]"); + process.exitCode = 1; + return; + } + // Strip the lock name before handing args to defaultHolder/resolveLaneArg — + // see defaultHolder's doc comment for why the name must never reach it. + const holder = await defaultHolder(args.filter((a) => a !== name)); + if (!holder) return; // resolveLaneArg already printed an error + + const timeoutIdx = args.indexOf("--timeout"); + const timeoutMs = + timeoutIdx > -1 && args[timeoutIdx + 1] ? Number(args[timeoutIdx + 1]) * 1000 : null; + const deadline = timeoutMs ? Date.now() + timeoutMs : null; + const startedAt = Date.now(); + let lastPrinted = 0; + + for (;;) { + const result = await post( + `/api/locks/${encodeURIComponent(name)}/acquire`, + { holder }, + { + allowError: true, + } + ); + if (result.status === undefined || result.data?.acquired) { + console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`); + return; + } + if (Date.now() - lastPrinted >= 60_000) { + const waited = Math.floor((Date.now() - startedAt) / 1000); + console.log( + `… still waiting for lock "${name}" (held by ${result.data?.holder ?? "unknown"}, waited ${waited}s)` + ); + lastPrinted = Date.now(); + } + if (deadline && Date.now() >= deadline) { + console.error(`✖ timed out waiting for lock "${name}"`); + process.exitCode = 1; + return; + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } +} + +/** `ccam lock release [--holder X]`. */ +async function cmdLockRelease(args) { + const name = args.find((arg) => !arg.startsWith("--")); + if (!name) { + console.error("usage: ccam lock release [--holder X]"); + process.exitCode = 1; + return; + } + const holder = await defaultHolder(args.filter((a) => a !== name)); + if (!holder) return; + const result = await post( + `/api/locks/${encodeURIComponent(name)}/release`, + { holder }, + { + allowError: true, + } + ); + if (result.status) { + console.error(`✖ release lock "${name}" → ${result.data?.error?.message || result.status}`); + process.exitCode = 1; + return; + } + console.log(`${c.green("✔")} released lock "${name}"`); +} + /** * `ccam stage [flags]` — the lane equivalent of Shipyard's * `state.sh N set stage=…`. A skill calls this at each phase boundary so the @@ -2056,6 +2182,11 @@ const COMMAND_GROUPS = [ ["lanes runtime", "[]", "Slot, ports, service health and the last boot error"], ["lanes logs", "[] [--tail N]", "Tail one of the lane's hook or service logs"], ["lanes hook", "[] [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"], + [ + "lock status|acquire|release", + "[] [--holder X] [--timeout N]", + "Cross-lane named lock (serialize builds/e2e across all lanes; holder defaults to the calling lane)", + ], ["stage [flags]", "", "Report the current pipeline stage for a lane"], ], ], @@ -2858,6 +2989,17 @@ async function runCommand(argv) { return cmdLanesRuntime(rest[0], rest.slice(1)); } return cmdLanes(); + case "lock": { + const sub = rest[0]; + if (sub === "status") return cmdLockStatus(rest.slice(1)); + if (sub === "acquire") return cmdLockAcquire(rest.slice(1)); + if (sub === "release") return cmdLockRelease(rest.slice(1)); + console.error( + "usage: ccam lock status [] | ccam lock acquire [--holder X] [--timeout N] | ccam lock release [--holder X]" + ); + process.exitCode = 1; + return; + } case "stage": return cmdStage(rest); case "open":