/** * @file Express router for lanes — the durable per-working-directory unit of * parallel agent work. Read endpoints join each lane with its most recent event * timestamp so liveness can be computed without a separate heartbeat, and every * mutation re-broadcasts the lane over the existing WebSocket as `lane_update`. * Orchestration is deliberately absent: the driving Claude session declares its * own stage (`POST /:id/stage`); the dashboard never guesses a transition. * @author Nguyễn Ngọc Trí Vĩ */ const { Router } = require("express"); const fs = require("node:fs"); const path = require("node:path"); const { db } = require("../db"); const lanesLib = require("../lib/lanes"); const { listPipelines, getPipeline, nodeStates, progressPct } = require("../lib/pipelines"); const laneFeatures = require("../lib/lane-features"); const proofLib = require("../lib/proof"); const { broadcast } = require("../websocket"); const runs = require("../lib/run-spawner"); const { sameOriginGuard } = require("./run"); const { preflight } = require("../lib/lane-preflight"); const { LANES_ROOT, addWorktree, gitFacts, isGitRepo, listBranches, removeWorktree, resetWorktree, resolveBase, slugify, } = require("../lib/worktree"); const { withLaneLock } = require("../lib/lane-lock"); const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync"); const { installAgents } = require("../lib/lane-agents"); const { HOOKS, runHook, resolveProfile } = require("../lib/lane-profile"); const { slotDirs } = require("../lib/lane-slots"); const { upLane, downLane, runtimeFacts, requireProfile, provisionLane, resetLaneData, removeLaneData, } = require("../lib/lane-runtime"); const router = Router(); const MAX_WORKTREE_DIRECTORY_ATTEMPTS = 50; /** Bytes of a hook log returned by default — enough to see a failure's tail. */ const LOG_TAIL_DEFAULT = 64 * 1024; const LOG_TAIL_MAX = 1024 * 1024; /** Seconds since this lane's session last emitted an event; null if never. */ function lastEventAge(lane) { if (!lane.session_id) return null; const row = db .prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?") .get(lane.session_id); if (!row || !row.last) return null; const t = Date.parse(row.last); return Number.isNaN(t) ? null : Math.max(0, Math.round((Date.now() - t) / 1000)); } function payload(lane) { return lanesLib.lanePayload(lane, lastEventAge(lane)); } /** A feature row's pipeline view, computed the same way payload() computes * it for a live lane — lets the client render an archived feature with the * exact same PipelineMap component, no special-casing on the frontend. */ function featurePayload(feature) { const pipeline = getPipeline(feature.pipeline); return { ...feature, pipeline_name: pipeline.name, pipeline_nodes: nodeStates(pipeline, feature), progress: progressPct(pipeline, feature), }; } /** Push the current state of one lane to every connected client. */ function broadcastLane(id) { const lane = lanesLib.getLane(id); if (lane) broadcast("lane_update", { lane: payload(lane) }); } /** * Release the lane holding a run that has just finished. Registered as a * callback because the spawner must not require this router back: it is * already required FROM here, and broadcastLane needs this file's payload(). * * No lane lock: the read, the guard and the write are one synchronous * better-sqlite3 sequence with no `await` between them, so nothing can * interleave. Matching run_id is what keeps a lane that has already moved on to * a different run untouched. */ runs.setRunExitHandler(({ runId }) => { const lane = lanesLib.listLanes().find((l) => l.run_id === runId); if (!lane) return; lanesLib.updateLane(lane.id, { run_id: null, status: "idle" }); broadcastLane(lane.id); }); router.get("/", (_req, res) => { const lanes = lanesLib.listLanes().map(payload); res.json({ lanes, counts: { total: lanes.length, running: lanes.filter((l) => l.status === "running").length, needs_you: lanes.filter((l) => l.needs_action).length, dead: lanes.filter((l) => l.liveness === "dead").length, }, }); }); // Registered before "/:id" so the literal path is not swallowed by the param. router.get("/pipelines", (_req, res) => res.json({ pipelines: listPipelines() })); /** * Local branches of a candidate source repo, for the "Add lane" picker: pick * a repo, then pick which branch to fork the new worktree from, instead of * typing a branch name and hoping it exists. Same validation as `/worktree` * (below), since a repo this can't resolve branches for can't be provisioned * from either. Read-only, so no same-origin guard. */ router.get("/branches", async (req, res) => { const sourceRepo = typeof req.query.repo === "string" ? req.query.repo : ""; if (!sourceRepo || !path.isAbsolute(sourceRepo) || !fs.existsSync(sourceRepo)) { return res.status(400).json({ error: { code: "EBADSOURCEREPO", message: "repo must be an existing absolute path" }, }); } if (!(await isGitRepo(sourceRepo))) { return res.status(400).json({ error: { code: "EBADSOURCEREPO", message: "repo is not a git repository" }, }); } try { const { branches, current } = await listBranches(sourceRepo); res.json({ branches, current }); } catch (err) { res.status(500).json({ error: { code: err.code, message: err.message, git: err.git } }); } }); /** * Idempotent "which lane owns this directory?" — the Workspace page opens on a * cwd, not on a lane id, so it needs one lane to exist for that cwd without * ever creating a duplicate. Also registered before "/:id". */ router.post("/ensure", sameOriginGuard, (req, res) => { const body = req.body || {}; const owner = lanesLib.resolveLaneByCwd(body.cwd); if (owner) return res.json({ lane: payload(owner), created: false }); try { const lane = lanesLib.createLane({ cwd: body.cwd, title: body.title || "" }); broadcastLane(lane.id); return res.status(201).json({ lane: payload(lane), created: true }); } catch (err) { if (err.code === "EBADCWD") { return res.status(400).json({ error: { code: err.code, message: err.message } }); } // The cwd UNIQUE constraint is the arbiter: someone else won the race, so // re-read and return THEIR lane rather than reporting a conflict. if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) { const winner = lanesLib.resolveLaneByCwd(body.cwd); if (winner) return res.json({ lane: payload(winner), created: false }); } return res.status(500).json({ error: { code: err.code, message: err.message } }); } }); 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" } }); res.json({ lane: payload(lane) }); }); router.post("/", sameOriginGuard, (req, res) => { try { const lane = lanesLib.createLane(req.body || {}); broadcastLane(lane.id); res.status(201).json({ lane: payload(lane) }); } catch (err) { if (err.code === "EBADCWD") { return res.status(400).json({ error: { code: err.code, message: err.message } }); } 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" } }); } res.status(500).json({ error: { message: err.message } }); } }); router.patch("/:id", sameOriginGuard, (req, res) => { if (!lanesLib.getLane(req.params.id)) { return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } }); } let lane; try { lane = lanesLib.updateLane(req.params.id, req.body || {}); } catch (err) { // A bad `kind` is invalid input, not a server fault — every sibling route // answers 400 here, so this one must too instead of throwing into Express. if (err.code === "EBADKIND") { return res.status(400).json({ error: { code: err.code, message: err.message } }); } return res.status(500).json({ error: { code: err.code, message: err.message } }); } broadcastLane(lane.id); res.json({ lane: payload(lane) }); }); router.post("/:id/stage", sameOriginGuard, (req, res) => { if (!lanesLib.getLane(req.params.id)) { return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } }); } const lane = lanesLib.setStage(req.params.id, req.body || {}); broadcastLane(lane.id); res.json({ lane: payload(lane) }); }); 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" } }); const action = String(req.query.action || ""); if (!["reset", "remove", "purge"].includes(action)) { return res .status(400) .json({ error: { code: "EBADACTION", message: `unknown action ${action}` } }); } try { res.json(await preflight(lane, action)); } catch (err) { res.status(500).json({ error: { code: err.code, message: err.message } }); } }); /** * Per-feature state and archive (B) — `lib/lane-features.js`. `GET * /:id/features` lists every feature the lane has ever activated (archived * or live); `GET /:id/features/:slug` shows one, including an archived * one's saved pipeline; `POST /:id/features/activate` switches the live * lane to a feature by slug, archiving whichever one was active first. */ 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" } }); 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" } }); const feature = laneFeatures.getFeature(lane.id, req.params.slug); if (!feature) { return res.status(404).json({ error: { code: "ENOFEATURE", message: "no such feature" } }); } res.json({ feature: featurePayload(feature) }); }); 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" } }); const slug = typeof req.body?.slug === "string" ? req.body.slug : ""; if (!slug) { return res.status(400).json({ error: { code: "EBADSLUG", message: "slug is required" } }); } try { const { lane: updated, feature } = laneFeatures.activateFeature(lane.id, slug, { title: req.body?.title, }); broadcastLane(updated.id); res.json({ lane: payload(updated), feature: featurePayload(feature) }); } catch (err) { if (err.code === "EBADSLUG") { return res.status(400).json({ error: { code: err.code, message: err.message } }); } res.status(500).json({ error: { code: err.code, message: err.message } }); } }); 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" } }); res.json({ features: proofLib.listProof(lane.id) }); }); router.get("/:id/proof/:slug/:group/:file", (req, res) => { const lane = lanesLib.getLane(req.params.id); if (!lane) return res.status(404).send("not found"); const p = proofLib.proofFile(lane.id, req.params.slug, req.params.group, req.params.file); if (!p) return res.status(404).send("not found"); const ext = path.extname(p).toLowerCase(); const contentType = ext === ".html" ? "text/html; charset=utf-8" : ext === ".png" ? "image/png" : "image/jpeg"; res.type(contentType).sendFile(p); }); 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" } }); try { const result = proofLib.deleteProof(lane.id, { slug: req.params.slug, group: req.body?.group, images: req.body?.images, }); res.json(result); } catch (err) { if (err.code === "ENOFEATURE") { return res.status(404).json({ error: { code: err.code, message: err.message } }); } if (err.code === "EBADPATH") { return res.status(400).json({ error: { code: err.code, message: err.message } }); } res.status(500).json({ error: { code: err.code, message: err.message } }); } }); 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" } }); res.json(proofLib.ensureProofLink(lane)); }); /** * Install the ship-feature-lane pipeline's agent templates (qc-local, * senior-gate-reviewer) into this lane's own .claude/agents/. Static file * copy — no templating, no credentials to inject (this repo has no * seed-account system yet; see the E3 design spec's Decisions table). * Never automatic, same as proof-link: a session calls this explicitly. */ router.post("/:id/agents/install", 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" } }); try { res.json(await installAgents(lane)); } catch (err) { if (err.code === "ENOTGITREPO") { return res.status(400).json({ error: { code: err.code, message: err.message } }); } res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } }); } }); /** * A lane's working-copy facts: branch, short HEAD, that commit's subject, and * the uncommitted counts. Read-only, so no same-origin guard — that guard * exists for the destructive actions. * * Deliberately NOT part of `GET /api/lanes`: this shells out to git three * times, and that payload is polled and re-broadcast on every hook-driven * lane_update. Any failure — no such directory, not a repo, git itself * erroring — is reported as `available: false` rather than a 500, because a * lane pointing at a plain directory is a normal state, not a fault. * * The `/:id/:action` catch-all below cannot shadow this one — that route is a * POST and Express matches on method as well as path. Verified by moving this * registration after it: the suite stayed green. */ 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" } }); try { res.json({ available: true, ...(await gitFacts(lane.cwd)) }); } catch { res.json({ available: false }); } }); /** * Create a dashboard-managed worktree lane. Git work happens after the 202 * response because provisioning a large repository can take seconds. */ router.post("/worktree", sameOriginGuard, async (req, res) => { const body = req.body || {}; const sourceRepo = body.sourceRepo; if ( typeof sourceRepo !== "string" || !path.isAbsolute(sourceRepo) || !fs.existsSync(sourceRepo) || !(await isGitRepo(sourceRepo)) ) { return res.status(400).json({ error: { code: "EBADSOURCEREPO", message: "sourceRepo must be an existing absolute git repository", }, }); } let slug; try { slug = slugify(body.slug || body.title); } catch (err) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } const resolvedSourceRepo = path.resolve(sourceRepo); const repoName = path.basename(resolvedSourceRepo); const originalSlug = slug; let dir = path.join(LANES_ROOT, `${repoName}__${slug}`); let attempts = 0; while (fs.existsSync(dir) && attempts < MAX_WORKTREE_DIRECTORY_ATTEMPTS) { attempts += 1; const suffix = attempts + 1; slug = `${originalSlug}-${suffix}`; dir = path.join(LANES_ROOT, `${repoName}__${slug}`); } if (fs.existsSync(dir)) { return res.status(409).json({ error: { code: "EWORKTREEDIRCOLLISION", message: "could not allocate a unique worktree directory after 50 attempts", }, }); } const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/"; const branch = `${branchPrefix}${slug}`; let lane; try { lane = lanesLib.createLane({ title: body.title || "", cwd: dir, branch, kind: "managed", source_repo: resolvedSourceRepo, base_branch: body.base || null, slug, }); lane = lanesLib.updateLane(lane.id, { status: "provisioning" }); } catch (err) { 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" } }); } return res.status(500).json({ error: { code: err.code, message: err.message } }); } res.status(202).json({ lane: payload(lane) }); void withLaneLock(lane.id, async () => { try { const baseBranch = await resolveBase( resolvedSourceRepo, body.base || process.env.LANE_BASE_BRANCH || "main" ); await addWorktree({ sourceRepo: resolvedSourceRepo, dir, branch, base: baseBranch }); // base_branch is a provisioning fact, not a patchable field — see PATCHABLE. lanesLib.setProvisioningFacts(lane.id, { base_branch: baseBranch }); // A2 data isolation: only when the repo actually declares a profile — a // worktree lane with none is a normal state (nothing about A1 required // one either), so this is a no-op rather than a provisioning failure. const worktreeLane = lanesLib.getLane(lane.id); const profile = resolveProfile(worktreeLane); if (profile) { const onLine = (line, stream) => broadcast("lane_hook_output", { laneId: lane.id, hook: "provision", stream, line }); await provisionLane(worktreeLane, { onLine }); } lanesLib.updateLane(lane.id, { status: "idle", notes: null }); } catch (err) { lanesLib.updateLane(lane.id, { status: "failed", notes: err.git?.stderr || err.message, }); } broadcastLane(lane.id); }); }); const ACTIONS = new Set(["start", "stop", "message", "clear", "reset", "remove", "purge"]); // The modes the spawner accepts, same as POST /api/run. const RUN_MODES = new Set(["headless", "conversation"]); const DESTRUCTIVE_ACTIONS = new Set(["reset", "remove", "purge"]); const RUN_EXIT_POLL_MS = 50; // killRun escalates from SIGTERM to SIGKILL after five seconds. Leave enough // time for that escalation and for Node to receive the child's real exit. const RUN_EXIT_TIMEOUT_MS = 7500; function lifecycleError(code, message) { return Object.assign(new Error(message), { code }); } function expectedFields(action) { return action === "purge" ? ["sessions", "events", "tokenRows"] : ["head", "dirty", "untracked", "unpushed"]; } /** Refuse a destructive action when the facts shown in its preflight have moved. */ function assertExpectedPreflight(action, current, expected) { const fields = expectedFields(action); if ( !expected || typeof expected !== "object" || Array.isArray(expected) || fields.some((field) => !Object.hasOwn(expected, field)) ) { throw lifecycleError( "EEXPECT", `${action} requires a complete expect object with: ${fields.join(", ")}` ); } const changed = fields.some((field) => current[field] !== expected[field]); if (changed) { const err = lifecycleError("ESTALE", "lane state changed since preflight"); err.expected = expected; err.current = current; throw err; } } function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** Kill a lane run and wait for the child's real `exit` event before touching its cwd. */ async function stopLaneRun(lane) { if (!lane.run_id) return; try { runs.killRun(lane.run_id); } catch { /* a concurrently completed run is already safe */ } const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS; let run = runs.getRun(lane.run_id); while (run && !run.actualExitedAt) { if (Date.now() >= deadline) { throw lifecycleError( "ERUNTIMEOUT", `lane run ${lane.run_id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds` ); } await wait(RUN_EXIT_POLL_MS); run = runs.getRun(lane.run_id); } lanesLib.updateLane(lane.id, { run_id: null }); } function sendLifecycleError(res, err) { if (["ENOTMANAGED", "EOUTSIDEROOT", "ENOTWORKTREE", "EEXPECT"].includes(err.code)) { return res.status(400).json({ error: { code: err.code, message: err.message } }); } if (["ESTALE", "EUNPUSHED"].includes(err.code)) { return res.status(409).json({ error: { code: err.code, message: err.message, ...(err.code === "ESTALE" ? { expected: err.expected, current: err.current } : {}), }, }); } if (err.git) { return res.status(500).json({ error: { code: err.code, message: err.message, stderr: err.git.stderr }, }); } return res.status(500).json({ error: { code: err.code, message: err.message } }); } /* --------------------------------------------------------------------------- * Runtime: a lane's own stack, isolated by slot-derived ports and directories. * * These routes are registered BEFORE the "/:id/:action" catch-all below, which * would otherwise swallow "up" and "down" as unknown actions. They are also * deliberately NOT folded into that catch-all: it drives a lane's Claude RUN, * while these drive the application the lane is working on — two different * lifecycles that happen to share a lane id. * * None of them writes `stage`, `status` or `notes`. A booted stack is not an * agent at work, and only `slot`/`ports` describe the runtime. * ------------------------------------------------------------------------ */ /** Map a runtime error onto its status code. */ function sendRuntimeError(res, err) { const badRequest = [ "ENOPROFILE", "ENOHOOK", "EBADLANEDIR", "EBADSVC", "EBADBRANCH", "EUNRESOLVED", "EMERGEUNCOMMITTED", ]; if (badRequest.includes(err.code)) { return res.status(400).json({ error: { code: err.code, message: err.message, ...(err.searched ? { searched: err.searched } : {}), }, }); } if (err.code === "ESLOTS") { return res.status(409).json({ error: { code: err.code, message: err.message } }); } if (err.code === "EPORTBUSY") { return res.status(409).json({ error: { code: err.code, message: err.message, port: err.preferred, pids: err.pids }, }); } return res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message, logPath: err.logPath }, }); } /** Resolve `:id` or answer 404. Returns null once the response has been sent. */ function laneOr404(req, res) { const lane = lanesLib.getLane(req.params.id); if (!lane) { res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } }); return null; } return lane; } /** * What is running for this lane, computed fresh. * * Follows `GET /:id/git`'s contract: a lane with no profile answers * `{available:false}` with HTTP 200, because that is a normal state and not a * fault. It probes ports and stats pid files, which is why it is its own endpoint * rather than a field on the polled lane list. */ router.get("/:id/runtime", async (req, res) => { const lane = laneOr404(req, res); if (!lane) return; try { res.json(await runtimeFacts(lane)); } catch (err) { sendRuntimeError(res, err); } }); /** * Tail one of the lane's hook or service logs. * * `:svc` is resolved against the log directory's real contents and the result is * confined to that directory after `realpath`, so a name from the request can * never escape it. */ router.get("/:id/logs/:svc", (req, res) => { const lane = laneOr404(req, res); if (!lane) return; if (!lane.slot) return res.json({ available: false }); const { logDir } = slotDirs(lane.slot); const file = path.resolve(logDir, `${req.params.svc}.log`); let real; try { real = fs.realpathSync(file); const relative = path.relative(fs.realpathSync(logDir), real); if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("escapes log dir"); } catch { return res.status(404).json({ error: { code: "ENOLOG", message: "no such log" } }); } const requested = Number(req.query.tail); const tail = Math.min( Number.isInteger(requested) && requested > 0 ? requested : LOG_TAIL_DEFAULT, LOG_TAIL_MAX ); const { size } = fs.statSync(real); const start = Math.max(0, size - tail); const fd = fs.openSync(real, "r"); try { const buffer = Buffer.alloc(size - start); fs.readSync(fd, buffer, 0, buffer.length, start); res.json({ available: true, svc: req.params.svc, size, truncated: start > 0, text: buffer.toString("utf8"), }); } finally { fs.closeSync(fd); } }); /** * Boot the lane's stack. 202 + background, like `POST /worktree`: a build can run * for minutes and the caller should not hold a socket open for it. Progress * streams as `lane_hook_output`; completion re-broadcasts the lane, whose `ports` * the boot may have changed. */ router.post("/:id/up", sameOriginGuard, (req, res) => { const lane = laneOr404(req, res); if (!lane) return; try { requireProfile(lane); } catch (err) { return sendRuntimeError(res, err); } const build = req.body?.build !== false; const qc = req.body?.qc === true; res.status(202).json({ ok: true, laneId: lane.id }); void withLaneLock(lane.id, async () => { const onLine = (line, stream) => broadcast("lane_hook_output", { laneId: lane.id, hook: "up", stream, line }); try { const facts = await upLane(lanesLib.getLane(lane.id), { build, qc, onLine }); broadcast("lane_runtime", { laneId: lane.id, runtime: facts }); } catch (err) { broadcast("lane_runtime", { laneId: lane.id, error: { code: err.code || "ERUNTIME", message: err.message }, }); } broadcastLane(lane.id); }); }); /** Stop the lane's stack. Fast and idempotent, so it answers synchronously. */ router.post("/:id/down", sameOriginGuard, async (req, res) => { const lane = laneOr404(req, res); if (!lane) return; try { const result = await withLaneLock(lane.id, () => downLane(lanesLib.getLane(lane.id))); const facts = await runtimeFacts(lanesLib.getLane(lane.id)); broadcast("lane_runtime", { laneId: lane.id, runtime: facts }); res.json({ ok: true, killed: result.killed, runtime: facts }); } catch (err) { sendRuntimeError(res, err); } }); /** * Run one of the profile's hooks on the lane — the surface a driving session uses * for `ci-gate`, `e2e`, `migrate` and friends. * * `:name` is checked against the hook allowlist BEFORE anything is spawned, and * `args` travels as an array of strings straight into argv. Neither is ever * joined into a command string. */ router.post("/:id/hook/:name", sameOriginGuard, (req, res) => { const lane = laneOr404(req, res); if (!lane) return; const { name } = req.params; if (!HOOKS.includes(name)) { return res .status(400) .json({ error: { code: "ENOHOOK", message: `unknown hook ${name}`, allowed: HOOKS } }); } const args = Array.isArray(req.body?.args) ? req.body.args.map(String) : []; let profile; try { profile = requireProfile(lane); } catch (err) { return sendRuntimeError(res, err); } if (!lane.slot) { return res.status(409).json({ error: { code: "ENOSLOT", message: "lane has no runtime yet — bring it up first" }, }); } res.status(202).json({ ok: true, laneId: lane.id, hook: name }); void withLaneLock(lane.id, async () => { const onLine = (line, stream) => broadcast("lane_hook_output", { laneId: lane.id, hook: name, stream, line }); try { const result = await runHook(lanesLib.getLane(lane.id), profile, name, args, { onLine }); broadcast("lane_hook_result", { laneId: lane.id, hook: name, code: result.code }); } catch (err) { broadcast("lane_hook_result", { laneId: lane.id, hook: name, code: null, error: { code: err.code || "ERUNTIME", message: err.message }, }); } }); }); /** * The ONE sanctioned merge in the ship-feature-lane pipeline: origin/development * INTO a feature branch, gated by a migration-number collision preflight. * Synchronous — a fetch + collision-check + merge is seconds of git work, not * the minutes a build/test hook can take, so this follows GET /:id/git's * pattern rather than the hook route's 202-and-broadcast. * * Returns 200 with {code: 0|4|5, ...} for every DOCUMENTED outcome — a * migration collision or a left-in-place conflict is an expected result, not * an HTTP error. A malformed request, a missing profile, or an out-of-order * --continue is the only case that answers with an `error` body. * * Never writes stage/status/notes — same boundary the hook and runtime * routes already keep; the caller decides what a collision or conflict means * for the lane's declared stage. */ router.post("/:id/sync-base", sameOriginGuard, async (req, res) => { const lane = laneOr404(req, res); if (!lane) return; let profile; try { profile = requireProfile(lane); } catch (err) { return sendRuntimeError(res, err); } const mode = ["check", "merge", "continue"].includes(req.body?.mode) ? req.body.mode : "merge"; const branch = typeof req.body?.branch === "string" && req.body.branch ? req.body.branch : undefined; try { const result = await withLaneLock(lane.id, () => { const current = lanesLib.getLane(lane.id); if (mode === "check") return checkSync(current, profile, branch); if (mode === "continue") return continueSync(current, profile, branch); return mergeSync(current, profile, branch); }); res.json(result); } catch (err) { sendRuntimeError(res, err); } }); /** * Lane control. Deliberately thin: every action maps onto one existing * run-spawner call. There is no queue, no chaining, no gate evaluation — the * dashboard drives a lane, it does not orchestrate a pipeline. */ router.post("/:id/:action", sameOriginGuard, async (req, res) => { const { action } = req.params; if (!ACTIONS.has(action)) { return res .status(400) .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" } }); const body = req.body || {}; if (DESTRUCTIVE_ACTIONS.has(action)) { if (body.confirm !== true) { return res.status(400).json({ error: { code: "ECONFIRM", message: `${action} requires confirm: true` }, }); } try { const result = await withLaneLock(lane.id, async () => { const lockedLane = lanesLib.getLane(lane.id); if (!lockedLane) throw lifecycleError("ENOLANE", "lane not found"); await stopLaneRun(lockedLane); // A running stack holds files open in the very directory reset and remove // are about to rewrite or delete, and its processes would outlive the lane // still bound to its ports. Stop it before touching git. Idempotent and a // no-op for a lane that was never brought up. if (action === "reset" || action === "remove") { await downLane(lanesLib.getLane(lane.id)); } const current = lanesLib.getLane(lane.id); const facts = await preflight(current, action); assertExpectedPreflight(action, facts, body.expect); if ( (action === "reset" || (action === "remove" && current.kind === "managed")) && facts.unpushed > 0 && body.force !== true ) { throw lifecycleError( "EUNPUSHED", `${action} requires force: true when commits are unpushed` ); } if (action === "reset") { await resetWorktree(current); const resetLane = lanesLib.clearLane(current.id); // A2 data isolation, only when the lane actually has both a profile // and an allocated slot — a lane that was never brought up has // nothing of this kind to reset. const profile = resolveProfile(resetLane); if (profile && resetLane.slot) { const onLine = (line, stream) => broadcast("lane_hook_output", { laneId: resetLane.id, hook: "reset", stream, line }); await resetLaneData(resetLane, profile, { keepDb: body.keepDb === true, onLine }); } return { lane: lanesLib.getLane(current.id) }; } if (action === "remove") { // Drop the lane's own database(s) before anything else — its state // directory (the drop-created marker) is about to be deleted too. const profile = resolveProfile(current); if (profile && current.slot) await removeLaneData(current, profile); // Forgetting an adopted lane only removes dashboard metadata. The // filesystem destroy guard is deliberately reached only for managed // worktrees, where removal can actually touch a directory. if (current.kind === "managed") await removeWorktree(current); // Runtime bookkeeping outlives the row otherwise: pid files and hook // logs under .state/lane/ would be inherited by whichever lane // claims that slot next. Deleting the row is what frees the slot — // usedSlots() reads the table, so there is nothing else to release. if (current.slot) { fs.rmSync(slotDirs(current.slot).stateDir, { recursive: true, force: true }); } lanesLib.deleteLane(current.id); return { removed: current.id }; } return { purged: lanesLib.purgeLaneSessions(current.id) }; }); if (action === "remove") { broadcast("lane_update", { removed: result.removed }); return res.json({ ok: true }); } if (action === "purge") { broadcastLane(lane.id); return res.json({ ok: true, purged: result.purged }); } broadcastLane(lane.id); return res.json({ lane: payload(result.lane) }); } catch (err) { if (err.code === "ENOLANE") { return res.status(404).json({ error: { code: err.code, message: err.message } }); } return sendLifecycleError(res, err); } } try { switch (action) { case "start": { // Same two modes POST /api/run accepts. Unlike that route, an unknown // value is refused rather than silently coerced to a conversation. if (body.mode != null && !RUN_MODES.has(body.mode)) { return res.status(400).json({ error: { code: "EBADMODE", message: `mode must be one of: headless, conversation` }, }); } // Overwriting run_id while its child is alive orphans that child: a later // reset would kill and await only the RECORDED run, then `git clean -fd` // the directory the orphan is still writing into — the exact hazard // actualExitedAt exists to close. Stop the first run before starting a // second. The check and the spawn happen under the per-lane lock so that // atomicity is guaranteed rather than an accident of this code having no // `await` between them — a future edit that adds one must not reopen the // race. const outcome = await withLaneLock(lane.id, async () => { const current = lanesLib.getLane(lane.id); // A concurrent `remove` could have deleted the row while this request // waited for the lock — the lock makes that race visible instead of // spawning a run for a lane that no longer exists. if (!current) return { missing: true }; const live = current.run_id ? runs.getRun(current.run_id) : null; if (live && (live.status === "spawning" || live.status === "running")) { return { conflict: true }; } const handle = runs.spawnRun({ mode: body.mode || "conversation", laneId: current.id, prompt: body.prompt || "", cwd: current.cwd, model: body.model, permissionMode: body.permissionMode, effort: body.effort, resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined), }); lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" }); return {}; }); if (outcome.missing) { return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } }); } if (outcome.conflict) { return res .status(409) .json({ error: { code: "ERUNLIVE", message: "lane already has a live run" } }); } break; } case "stop": { // A lane with no live run is already stopped — say so, don't 500. if (lane.run_id) { try { runs.killRun(lane.run_id); } catch { /* already gone */ } } lanesLib.updateLane(lane.id, { status: "idle", run_id: null }); break; } case "message": { if (!lane.run_id) { return res .status(409) .json({ error: { code: "ENORUN", message: "lane has no live run" } }); } // Check that the recorded run is actually live (spawning or running). // If a run finished recently, its run_id is still recorded but sendInput // would throw ENOTRUNNING. Return 409 so the client knows it's not a server error. const run = runs.getRun(lane.run_id); if (!run || (run.status !== "spawning" && run.status !== "running")) { return res .status(409) .json({ error: { code: "ENORUN", message: "lane has no live run" } }); } runs.sendInput(lane.run_id, String(body.text || "")); lanesLib.updateLane(lane.id, { needs_action: null }); break; } case "clear": lanesLib.clearLane(lane.id); break; default: break; } } catch (err) { return res.status(500).json({ error: { code: err.code, message: err.message } }); } broadcastLane(lane.id); res.json({ lane: payload(lanesLib.getLane(lane.id)) }); }); 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" } }); broadcast("lane_update", { removed: Number(req.params.id) }); res.json({ ok: true }); }); module.exports = router; module.exports.broadcastLane = broadcastLane;