feat(ui): call lanes "workspaces" in the UI, CLI output and docs

Copy-only rename (workspace redesign, phase 0). The word a user reads
changes — "Lane" → "Workspace", "Add lane" → "Add workspace" — in both
locales, in agenthub CLI prose and help, in the server's error messages
and in the docs. Every identifier stays: /api/lanes, `agenthub lanes`,
--lane, AGENTHUB_LANE_ID, error codes, tmux names, DB tables, file,
component and i18n key names. docs/LANES.md keeps its name, retitled
"Workspaces (lanes)".
This commit is contained in:
2026-10-01 16:03:24 +07:00
parent e01cb26bde
commit 5b3884b309
43 changed files with 928 additions and 891 deletions
+66 -54
View File
@@ -1629,19 +1629,21 @@ async function cmdLanesAdd(args) {
({ lane } = await get(`/api/lanes/${provisioning.id}`));
}
if (lane.status === "provisioning") {
console.error(`✖ Timed out waiting for worktree lane #${lane.id} to finish provisioning.`);
console.error(
`✖ Timed out waiting for worktree workspace #${lane.id} to finish provisioning.`
);
process.exitCode = 1;
return;
}
if (lane.status === "failed") {
console.error(
`✖ Worktree lane #${lane.id} failed: ${lane.notes || "no failure details reported"}`
`✖ Worktree workspace #${lane.id} failed: ${lane.notes || "no failure details reported"}`
);
process.exitCode = 1;
return;
}
console.log(
`${c.green("✔")} Worktree lane #${lane.id} ready: ${lane.title || lane.cwd} (status: ${lane.status})`
`${c.green("✔")} Worktree workspace #${lane.id} ready: ${lane.title || lane.cwd} (status: ${lane.status})`
);
return;
}
@@ -1656,7 +1658,7 @@ async function cmdLanesAdd(args) {
if (pipeline) body.pipeline = pipeline;
if (tool) body.tool = tool;
const { lane } = await post("/api/lanes", body);
console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`);
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
}
/**
@@ -1795,7 +1797,7 @@ async function cmdLanesLifecycle(action, args) {
}
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
const keepDb = action === "reset" && args.includes("--keep-db");
if (preflight.database) {
const fate = action === "remove" ? "kept" : keepDb ? "kept as-is" : "dropped and recreated";
@@ -1812,7 +1814,9 @@ async function cmdLanesLifecycle(action, args) {
}
if (!args.includes("--yes")) {
console.error(`Refusing to ${action} lane #${id} without --yes. Re-run with --yes to proceed.`);
console.error(
`Refusing to ${action} workspace #${id} without --yes. Re-run with --yes to proceed.`
);
process.exitCode = 1;
return;
}
@@ -1826,7 +1830,7 @@ async function cmdLanesLifecycle(action, args) {
preflight.blocked.includes("adopted")
) {
console.error(
`Lane #${id} points at a directory you own. It cannot be reset; use \`agenthub lanes remove ${id} --yes\` to drop only the dashboard's record of it.`
`Workspace #${id} points at a directory you own. It cannot be reset; use \`agenthub lanes remove ${id} --yes\` to drop only the dashboard's record of it.`
);
process.exitCode = 1;
return;
@@ -1839,7 +1843,7 @@ async function cmdLanesLifecycle(action, args) {
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
if (result.status) {
const error = result.data?.error || {};
console.error(`✖ ${action} lane #${id} → ${error.message || `HTTP ${result.status}`}`);
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
if (result.status === 409 && error.code === "ESTALE") {
printLaneFacts("Expected preflight:", error.expected || {}, fields);
printLaneFacts("Current preflight:", error.current || {}, fields);
@@ -1848,12 +1852,12 @@ async function cmdLanesLifecycle(action, args) {
return;
}
if (action === "reset") console.log(`${c.green("✔")} Reset lane #${id}.`);
if (action === "remove") console.log(`${c.green("✔")} Removed lane #${id}.`);
if (action === "reset") console.log(`${c.green("✔")} Reset workspace #${id}.`);
if (action === "remove") console.log(`${c.green("✔")} Removed workspace #${id}.`);
if (action === "purge") {
const purged = result.purged || {};
console.log(
`${c.green("✔")} Purged lane #${id}: ${purged.sessions} sessions, ${purged.events} events, ${purged.tokenRows} token rows.`
`${c.green("✔")} Purged workspace #${id}: ${purged.sessions} sessions, ${purged.events} events, ${purged.tokenRows} token rows.`
);
}
}
@@ -1893,7 +1897,9 @@ function detectionLeadsDeclaration(l) {
async function cmdLanes() {
const { lanes, counts } = await get("/api/lanes");
if (!lanes.length) {
console.log("no lanes yet — create one with: agenthub lanes add --cwd <path> --title <text>");
console.log(
"no workspaces yet — create one with: agenthub lanes add --cwd <path> --title <text>"
);
return;
}
for (const l of lanes) {
@@ -1906,7 +1912,7 @@ async function cmdLanes() {
);
}
console.log(
`\n${counts.total} lanes · ${counts.running} running · ${counts.needs_you} need you · ${counts.dead} dead`
`\n${counts.total} workspaces · ${counts.running} running · ${counts.needs_you} need you · ${counts.dead} dead`
);
}
@@ -1971,7 +1977,7 @@ async function resolveLaneArg(args) {
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
.sort((a, b) => b.cwd.length - a.cwd.length);
if (!owning.length) {
console.error(`no lane owns ${cwd} — create one with: agenthub lanes add --cwd ${cwd}`);
console.error(`no workspace owns ${cwd} — create one with: agenthub lanes add --cwd ${cwd}`);
process.exitCode = 1;
return null;
}
@@ -1981,7 +1987,7 @@ async function resolveLaneArg(args) {
const candidates = tool ? nearest.filter((l) => (l.tool || "claude") === tool) : nearest;
if (!candidates.length) {
console.error(
`no ${tool} lane owns ${owning[0].cwd} — lanes here: ${nearest
`no ${tool} workspace owns ${owning[0].cwd} — workspaces here: ${nearest
.map((l) => `#${l.id} (${l.tool || "claude"})`)
.join(", ")}`
);
@@ -1990,7 +1996,7 @@ async function resolveLaneArg(args) {
}
if (candidates.length > 1) {
console.error(
`${candidates.length} lanes own ${owning[0].cwd} — pass --tool <${candidates
`${candidates.length} workspaces own ${owning[0].cwd} — pass --tool <${candidates
.map((l) => l.tool || "claude")
.join("|")}> or --lane <id>: ${candidates
.map((l) => `#${l.id} (${l.tool || "claude"})`)
@@ -2005,7 +2011,7 @@ async function resolveLaneArg(args) {
/** One line per declared port: name, number, listening, and any base drift. */
function printRuntime(runtime) {
if (!runtime.available) {
console.log("no .agenthub/profile for this lane — nothing to run.");
console.log("no .agenthub/profile for this workspace — nothing to run.");
if (runtime.searched) for (const p of runtime.searched) console.log(` looked in ${p}`);
return;
}
@@ -2064,14 +2070,14 @@ async function cmdLanesRuntime(sub, args) {
if (laneArgs.includes("--qc")) body.qc = true;
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
if (result.status) {
console.error(`✖ up lane #${laneId} → ${result.data?.error?.message || result.status}`);
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
// The server answers 202 and boots in the background; poll until the stack
// reports healthy or a boot error lands, so the command exits on a real
// outcome rather than on "accepted".
console.log(`lane #${laneId} booting…`);
console.log(`workspace #${laneId} booting…`);
const deadline = Date.now() + 15 * 60 * 1000;
for (;;) {
await new Promise((r) => setTimeout(r, 2000));
@@ -2098,11 +2104,13 @@ async function cmdLanesRuntime(sub, args) {
if (sub === "down") {
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
if (result.status) {
console.error(`✖ down lane #${laneId} → ${result.data?.error?.message || result.status}`);
console.error(
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
);
process.exitCode = 1;
return;
}
console.log(`lane #${laneId} down (${result.killed?.length || 0} processes stopped)`);
console.log(`workspace #${laneId} down (${result.killed?.length || 0} processes stopped)`);
return;
}
@@ -2117,7 +2125,7 @@ async function cmdLanesRuntime(sub, args) {
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
if (!log.available) {
console.log("no logs for this lane yet.");
console.log("no logs for this workspace yet.");
return;
}
if (log.truncated) console.log(`… (showing the tail of ${log.size} bytes)`);
@@ -2143,7 +2151,7 @@ async function cmdLanesRuntime(sub, args) {
return;
}
console.log(
`lane #${laneId} running hook ${name} — follow it with: agenthub lanes logs ${laneId} ${name}`
`workspace #${laneId} running hook ${name} — follow it with: agenthub lanes logs ${laneId} ${name}`
);
}
@@ -2166,7 +2174,7 @@ async function cmdLanesRuntime(sub, args) {
}
if (result.code === 5) {
console.error(`✖ lane #${laneId} — MIGRATION NUMBER COLLISION (nothing merged):`);
console.error(`✖ workspace #${laneId} — MIGRATION NUMBER COLLISION (nothing merged):`);
for (const c of result.collisions) {
console.error(` ${c.file} collides with ${c.collidesWith} — rename to ${c.suggestion}`);
}
@@ -2175,7 +2183,7 @@ async function cmdLanesRuntime(sub, args) {
}
if (result.code === 4) {
console.error(`✖ lane #${laneId} — MERGE CONFLICT (left in place).`);
console.error(`✖ workspace #${laneId} — MERGE CONFLICT (left in place).`);
console.error(` conflicted: ${result.conflictedFiles.join(", ")}`);
console.error(" resolve, then: git add <resolved files> && git commit --no-edit");
console.error(
@@ -2202,12 +2210,12 @@ async function cmdLanesRuntime(sub, args) {
console.log("DEV_OVERLAP: none");
}
}
console.log(`lane #${laneId} preflight vs origin/development: OK`);
console.log(`workspace #${laneId} preflight vs origin/development: OK`);
return;
}
console.log(
`lane #${laneId} — synced with origin/development (re-enter the pipeline at the gates)`
`workspace #${laneId} — synced with origin/development (re-enter the pipeline at the gates)`
);
return;
}
@@ -2382,7 +2390,7 @@ async function cmdFeatureActivate(args) {
title: flag("title"),
});
console.log(
`${c.green("✔")} lane #${lane.id} now on feature "${feature.slug}" (stage: ${feature.stage}, ${feature.progress}%)`
`${c.green("✔")} workspace #${lane.id} now on feature "${feature.slug}" (stage: ${feature.stage}, ${feature.progress}%)`
);
}
@@ -2444,13 +2452,13 @@ async function cmdLanesIntegration(args) {
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
async function cmdLanesCi(args) {
if (args[0] !== "status") {
console.error("usage: agenthub lanes ci status <branch-or-sha> [<lane-id>]");
console.error("usage: agenthub lanes ci status <branch-or-sha> [<id>]");
process.exitCode = 1;
return;
}
const ref = args[1];
if (!ref) {
console.error("usage: agenthub lanes ci status <branch-or-sha> [<lane-id>]");
console.error("usage: agenthub lanes ci status <branch-or-sha> [<id>]");
process.exitCode = 1;
return;
}
@@ -2515,7 +2523,7 @@ async function cmdLanesNode(args) {
const rest = positional.filter((p) => p !== String(resolved.laneId));
const key = rest[0];
const listNodes = () => {
console.log(`lane #${lane.id} → ${lane.pipeline} (${nodes.length} nodes)`);
console.log(`workspace #${lane.id} → ${lane.pipeline} (${nodes.length} nodes)`);
for (const n of nodes) {
const mark = n.state === "current" ? c.cyan("→") : " ";
console.log(
@@ -2562,7 +2570,7 @@ async function cmdLanesNode(args) {
}
console.log(
`${c.dim(`lane #${lane.id} · node ${node.id} (${node.label})`)}\n` +
`${c.dim(`workspace #${lane.id} · node ${node.id} (${node.label})`)}\n` +
`${c.cyan("→")} ${argv.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ")}`
);
const child = spawn(argv[0], argv.slice(1), { stdio: "inherit", cwd: lane.cwd });
@@ -2593,7 +2601,7 @@ async function cmdLanesPipeline(args) {
if (!target) {
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
const { pipelines } = await get("/api/lanes/pipelines");
console.log(`lane #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
return;
@@ -2601,7 +2609,7 @@ async function cmdLanesPipeline(args) {
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
console.log(
`${c.green("✔")} lane #${lane.id} → pipeline ${lane.pipeline} ` +
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
);
// Switching templates re-resolves the SAME declared stage string against a
@@ -2610,7 +2618,7 @@ async function cmdLanesPipeline(args) {
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
console.error(
c.yellow(
`! the lane's current stage "${lane.stage}" matches no node in "${lane.pipeline}" — ` +
`! the workspace's current stage "${lane.stage}" matches no node in "${lane.pipeline}" — ` +
`declare one of: ${lane.pipeline_nodes.map((n) => n.id).join(", ")}`
)
);
@@ -2694,7 +2702,7 @@ async function cmdStage(args) {
note: flag("note"),
result: flag("result"),
});
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);
console.log(`workspace #${lane.id} → ${lane.stage} (${lane.progress}%)`);
}
// ── Command catalog ─────────────────────────────────────────────────────────
@@ -2791,18 +2799,18 @@ const COMMAND_GROUPS = [
],
],
[
"Lanes",
"Workspaces",
[
["lanes", "", "List lanes with stage, liveness and progress"],
["lanes", "", "List workspaces with stage, liveness and progress"],
[
"lanes add",
"--cwd <path> --title <text> [--tool <id>]",
"Create a new lane bound to a directory (--tool: an AI Tools id, default claude)",
"Create a new workspace bound to a directory (--tool: an AI Tools id, default claude)",
],
[
"lanes add",
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]",
"Provision a managed worktree lane",
"Provision a managed worktree workspace",
],
[
"lanes profile init",
@@ -2812,40 +2820,40 @@ const COMMAND_GROUPS = [
[
"lanes profile check",
"[<path>]",
"Validate a profile (path defaults to cwd, not a lane id)",
"Validate a profile (path defaults to cwd, not a workspace id)",
],
[
"lanes pipeline",
"[<template-id>] [<id>]",
"Show, or switch, which pipeline template a lane renders against",
"Show, or switch, which pipeline template a workspace renders against",
],
[
"lanes node",
"[<node-id>] [extra…] [<id>] [--print]",
"Run ONE pipeline node's own command in the lane's cwd (no id: list every node and its command)",
"Run ONE pipeline node's own command in the workspace's cwd (no id: list every node and its command)",
],
[
"lanes shell",
"[<id>]",
"Attach a real terminal to the exact tmux session the dashboard uses for a lane's run",
"Attach a real terminal to the exact tmux session the dashboard uses for a workspace's run",
],
[
"lanes reset|remove|purge",
"<id> [--force] [--keep-db] --yes",
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)",
"Show preflight facts, then perform a destructive workspace action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)",
],
[
"lanes up|down",
"[<id>] [--no-build] [--qc]",
"Boot or stop the lane's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the lane owning this directory)",
"Boot or stop the workspace's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the workspace owning this directory)",
],
["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 workspace's hook or service logs"],
["lanes hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
[
"lanes sync-base",
"[<id>] [--check|--continue] [branch]",
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the lane's current branch)",
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the workspace's current branch)",
],
[
"lanes proof-link",
@@ -2855,12 +2863,12 @@ const COMMAND_GROUPS = [
[
"lanes mcp sync",
"[<id>]",
"Relocate the source repo's MCP servers into <lane>/.mcp.json and seed Chromium profiles",
"Relocate the source repo's MCP servers into <workspace>/.mcp.json and seed Chromium profiles",
],
[
"lanes integration",
"<name> [<id>]",
"Check whether a named integration (tracker, dev_qc, ci_wait) is on for this lane — exit 0/1",
"Check whether a named integration (tracker, dev_qc, ci_wait) is on for this workspace — exit 0/1",
],
[
"lanes ci status",
@@ -2870,19 +2878,23 @@ const COMMAND_GROUPS = [
[
"lanes gc",
"[--dry-run]",
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every lane",
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every workspace",
],
[
"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)",
"Cross-workspace named lock (serialize builds/e2e across all workspaces; holder defaults to the calling workspace)",
],
[
"stage <stage> [flags]",
"",
"Report the current pipeline stage for a lane (--tool <id> when one cwd has lanes for two tools)",
"Report the current pipeline stage for a workspace (--tool <id> when one cwd has workspaces for two tools)",
],
[
"feature list",
"[<id>]",
"List every feature this workspace has activated, archived or live",
],
["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
[
"feature activate",
"<slug> [--title text] [<id>]",