feat(locks): add ccam lock status/acquire/release CLI (D)
This commit is contained in:
+142
@@ -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":
|
||||||
|
|||||||
Reference in New Issue
Block a user