feat(locks): add ccam lock status/acquire/release CLI (D)

This commit is contained in:
2026-08-04 11:08:43 +07:00
parent 7488a375e3
commit 4a8725f136
+142
View File
@@ -1900,6 +1900,132 @@ async function cmdLanesRuntime(sub, args) {
} }
} }
/**
* The default lock holder identity for the calling lane: `lane<slot>` when
* the lane has one allocated, else `lane<id>` — 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 [<name>]` — 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 <name> [--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 <name> [--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 <name> [--holder X]`. */
async function cmdLockRelease(args) {
const name = args.find((arg) => !arg.startsWith("--"));
if (!name) {
console.error("usage: ccam lock release <name> [--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 <stage> [flags]` — the lane equivalent of Shipyard's * `ccam stage <stage> [flags]` — the lane equivalent of Shipyard's
* `state.sh N set stage=…`. A skill calls this at each phase boundary so the * `state.sh N set stage=…`. A skill calls this at each phase boundary so the
@@ -2056,6 +2182,11 @@ const COMMAND_GROUPS = [
["lanes runtime", "[<id>]", "Slot, ports, service health and the last boot error"], ["lanes runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
["lanes logs", "[<id>] <svc> [--tail N]", "Tail one of the lane's hook or service logs"], ["lanes logs", "[<id>] <svc> [--tail N]", "Tail one of the lane's hook or service logs"],
["lanes hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"], ["lanes hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
[
"lock status|acquire|release",
"[<name>] [--holder X] [--timeout N]",
"Cross-lane named lock (serialize builds/e2e across all lanes; holder defaults to the calling lane)",
],
["stage <stage> [flags]", "", "Report the current pipeline stage for a lane"], ["stage <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 cmdLanesRuntime(rest[0], rest.slice(1));
} }
return cmdLanes(); 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 [<name>] | ccam lock acquire <name> [--holder X] [--timeout N] | ccam lock release <name> [--holder X]"
);
process.exitCode = 1;
return;
}
case "stage": case "stage":
return cmdStage(rest); return cmdStage(rest);
case "open": case "open":