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
+49 -30
View File
@@ -229,7 +229,9 @@ router.get("/pipeline-stats", (req, res) => {
if (laneFilter !== undefined && laneFilter !== "" && laneFilter !== "all") {
laneId = Number(laneFilter);
if (!Number.isInteger(laneId) || laneId < 1) {
return res.status(400).json({ error: { code: "EBADLANE", message: "lane must be an id" } });
return res
.status(400)
.json({ error: { code: "EBADLANE", message: "workspace must be an id" } });
}
}
@@ -500,7 +502,8 @@ router.get("/browse", (req, res) => {
router.get("/:id", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
res.json({ lane: payload(lane) });
});
@@ -523,7 +526,7 @@ router.post("/", sameOriginGuard, (req, res) => {
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
return res
.status(409)
.json({ error: { code: "EDUPCWD", message: "a lane already owns that cwd" } });
.json({ error: { code: "EDUPCWD", message: "a workspace already owns that cwd" } });
}
res.status(500).json({ error: { message: err.message } });
}
@@ -531,7 +534,7 @@ router.post("/", sameOriginGuard, (req, res) => {
router.patch("/:id", sameOriginGuard, (req, res) => {
if (!lanesLib.getLane(req.params.id)) {
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
}
let lane;
try {
@@ -558,7 +561,7 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
router.post("/:id/stage", sameOriginGuard, (req, res) => {
const current = lanesLib.getLane(req.params.id);
if (!current) {
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
}
// A stage naming no node of this lane's pipeline used to be stored and then
// silently ignored by every reader — `agenthub stage test` on a ship-feature
@@ -575,7 +578,7 @@ router.post("/:id/stage", sameOriginGuard, (req, res) => {
error: {
code: "ENOPIPELINE",
message:
"no pipeline template is installed, so this lane has no stages to declare " +
"no pipeline template is installed, so this workspace has no stages to declare " +
"(drop a template into DASHBOARD_PIPELINES_DIR to turn stages on)",
stages: [],
},
@@ -600,7 +603,8 @@ router.post("/:id/stage", sameOriginGuard, (req, res) => {
router.get("/:id/preflight", async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const action = String(req.query.action || "");
if (!["reset", "remove", "purge"].includes(action)) {
return res
@@ -623,13 +627,15 @@ router.get("/:id/preflight", async (req, res) => {
*/
router.get("/:id/features", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
res.json({ features: laneFeatures.listFeatures(lane.id).map(featurePayload) });
});
router.get("/:id/features/:slug", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const feature = laneFeatures.getFeature(lane.id, req.params.slug);
if (!feature) {
return res.status(404).json({ error: { code: "ENOFEATURE", message: "no such feature" } });
@@ -639,7 +645,8 @@ router.get("/:id/features/:slug", (req, res) => {
router.post("/:id/features/activate", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const slug = typeof req.body?.slug === "string" ? req.body.slug : "";
if (!slug) {
return res.status(400).json({ error: { code: "EBADSLUG", message: "slug is required" } });
@@ -660,7 +667,8 @@ router.post("/:id/features/activate", sameOriginGuard, (req, res) => {
router.get("/:id/proof", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
res.json({ features: proofLib.listProof(lane.id) });
});
@@ -677,7 +685,8 @@ router.get("/:id/proof/:slug/:group/:file", (req, res) => {
router.delete("/:id/proof/:slug", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
try {
const result = proofLib.deleteProof(lane.id, {
slug: req.params.slug,
@@ -698,7 +707,8 @@ router.delete("/:id/proof/:slug", sameOriginGuard, (req, res) => {
router.post("/:id/proof-link", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
res.json(proofLib.ensureProofLink(lane));
});
@@ -712,7 +722,8 @@ router.post("/:id/proof-link", sameOriginGuard, (req, res) => {
*/
router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
try {
res.json(await syncMcp(lane));
} catch (err) {
@@ -734,7 +745,8 @@ router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
*/
router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const profile = (req.body || {}).profile;
if (typeof profile !== "string" || !profile) {
return res.status(400).json({ error: { code: "EBADREQ", message: "profile is required" } });
@@ -775,7 +787,8 @@ router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
*/
router.post("/:id/profile/init", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const facts = detectNode(lane.cwd);
if (!facts) {
@@ -800,7 +813,8 @@ router.post("/:id/profile/init", sameOriginGuard, (req, res) => {
*/
router.get("/:id/integrations/:name", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
res.json({ enabled: isIntegrationEnabled(lane, req.params.name) });
});
@@ -821,7 +835,8 @@ router.get("/:id/integrations/:name", (req, res) => {
*/
router.get("/:id/git", async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
try {
res.json({ available: true, ...(await gitFacts(lane.cwd)) });
} catch {
@@ -1138,7 +1153,7 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
return res.status(400).json({
error: {
code: "EEMPTYREPO",
message: "sourceRepo has no commits yet — add it as a Repo lane and commit first",
message: "sourceRepo has no commits yet — add it as a Repo workspace and commit first",
},
});
}
@@ -1201,7 +1216,7 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
return res
.status(409)
.json({ error: { code: "EDUPCWD", message: "a lane already owns that cwd" } });
.json({ error: { code: "EDUPCWD", message: "a workspace already owns that cwd" } });
}
return res.status(500).json({ error: { code: err.code, message: err.message } });
}
@@ -1273,7 +1288,7 @@ function assertExpectedPreflight(action, current, expected) {
}
const changed = fields.some((field) => current[field] !== expected[field]);
if (changed) {
const err = lifecycleError("ESTALE", "lane state changed since preflight");
const err = lifecycleError("ESTALE", "workspace state changed since preflight");
err.expected = expected;
err.current = current;
throw err;
@@ -1316,7 +1331,7 @@ async function stopLaneRun(lane) {
if (Date.now() >= deadline) {
throw lifecycleError(
"ERUNTIMEOUT",
`lane run ${id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
`workspace run ${id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
);
}
await wait(RUN_EXIT_POLL_MS);
@@ -1410,7 +1425,7 @@ function gitErrorCode(err) {
function laneOr404(req, res) {
const lane = lanesLib.getLane(req.params.id);
if (!lane) {
res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
return null;
}
return lane;
@@ -1556,7 +1571,7 @@ router.post("/:id/hook/:name", sameOriginGuard, (req, res) => {
}
if (!lane.slot) {
return res.status(409).json({
error: { code: "ENOSLOT", message: "lane has no runtime yet — bring it up first" },
error: { code: "ENOSLOT", message: "workspace has no runtime yet — bring it up first" },
});
}
@@ -1635,7 +1650,8 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
.json({ error: { code: "EBADACTION", message: `unknown action ${action}` } });
}
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!lane)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
const body = req.body || {};
if (DESTRUCTIVE_ACTIONS.has(action)) {
@@ -1647,7 +1663,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
try {
const result = await withLaneLock(lane.id, async () => {
const lockedLane = lanesLib.getLane(lane.id);
if (!lockedLane) throw lifecycleError("ENOLANE", "lane not found");
if (!lockedLane) throw lifecycleError("ENOLANE", "workspace not found");
await stopLaneRun(lockedLane);
// A running stack holds files open in the very directory `reset` is about
@@ -1782,12 +1798,14 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
return {};
});
if (outcome.missing) {
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
return res
.status(404)
.json({ error: { code: "ENOLANE", message: "workspace not found" } });
}
if (outcome.conflict) {
return res
.status(409)
.json({ error: { code: "ERUNLIVE", message: "lane already has a live run" } });
.json({ error: { code: "ERUNLIVE", message: "workspace already has a live run" } });
}
break;
}
@@ -1808,7 +1826,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
error: {
code: "EUNSUPPORTED",
message:
"sending input to a lane's run is no longer supported via REST — open the lane's terminal in Workspace and type directly (attaches over WebSocket to the same tmux session)",
"sending input to a workspace's run is no longer supported via REST — open the workspace's terminal in Workspace and type directly (attaches over WebSocket to the same tmux session)",
},
});
}
@@ -1839,7 +1857,8 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
router.delete("/:id", sameOriginGuard, (req, res) => {
const ok = lanesLib.deleteLane(req.params.id);
if (!ok) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
if (!ok)
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
broadcast("lane_update", { removed: Number(req.params.id) });
res.json({ ok: true });
});