5bd1c0323f
The lane strip's tiles are now drag-reorderable (Alt+←/→ is the keyboard
path), and a lane's name can be set in place from the detail header. The
"Lane 23 ·" prefix is gone from both — the bare `#23` stays on the tile,
because that number is how the CLI addresses a lane.
Card order is a per-viewer preference, not a lane fact: it lives in
localStorage (`agenthub.workspace.laneOrder`) beside the split-view layout
and writes nothing server-side, so `GET /api/lanes` and `agenthub lanes`
keep answering in id order. Renaming is the opposite — it is PATCH
/api/lanes/:id with {title}, the one lane column a user now types into
directly.
That made a patch value reachable by hand for the first time, so
`bindable` now rejects any non-scalar for every PATCHABLE column with
EBADFIELD, and the route answers 400 for that and for the
SQLITE_CONSTRAINT family, rather than handing better-sqlite3 a datatype
throw and reporting 500 on plain bad input.
The uncommitted-files tooltip became a real panel. `GET /api/lanes/:id/git`
now returns `files` as {path, status, added, deleted} records — deltas from
one `git diff --numstat HEAD`, null where git has none to report (untracked,
binary, renamed) — and the card renders them portalled to the body, so the
Workspace's own scroll box cannot clip the list.
1426 lines
54 KiB
JavaScript
1426 lines
54 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { Router } = require("express");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { db } = require("../db");
|
|
const lanesLib = require("../lib/lanes");
|
|
const {
|
|
listPipelines,
|
|
getPipeline,
|
|
nodeStates,
|
|
progressPct,
|
|
phaseIdx,
|
|
} = require("../lib/pipelines");
|
|
const laneFeatures = require("../lib/lane-features");
|
|
const proofLib = require("../lib/proof");
|
|
const { broadcast } = require("../websocket");
|
|
const runs = require("../lib/pty-run");
|
|
const { applyRouterToLane } = require("../lib/router-apply");
|
|
const { sameOriginGuard } = require("./run");
|
|
const { preflight } = require("../lib/lane-preflight");
|
|
const {
|
|
LANES_ROOT,
|
|
addWorktree,
|
|
gitFacts,
|
|
isGitRepo,
|
|
isValidBranchName,
|
|
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 { syncMcp } = require("../lib/lane-mcp");
|
|
const { HOOKS, runHook, resolveProfile, isIntegrationEnabled } = require("../lib/lane-profile");
|
|
const { slotDirs } = require("../lib/lane-slots");
|
|
const {
|
|
upLane,
|
|
downLane,
|
|
runtimeFacts,
|
|
requireProfile,
|
|
provisionLane,
|
|
resetLaneData,
|
|
removeLaneData,
|
|
} = require("../lib/lane-runtime");
|
|
const { reapOrphanMcp, capOversizedLogs } = require("../lib/lane-gc");
|
|
const { detectNode, scaffoldProfile } = require("../lib/lane-detect");
|
|
const { ciStatus } = require("../lib/ci-status");
|
|
|
|
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));
|
|
}
|
|
|
|
/**
|
|
* Self-heals a stale `run_id`: a tmux-backed run has no exit event to push a
|
|
* release notification, so liveness is re-checked here, on every read,
|
|
* instead — the same "computed fact, never stored" principle this repo
|
|
* already applies to lane runtime up/down. A lane whose run_id points at a
|
|
* tmux session that's gone (the pane's process exited, or it was killed
|
|
* outside the dashboard entirely) gets released the next time anything reads
|
|
* it, exactly like the old push-based handler did, just pulled instead of
|
|
* pushed.
|
|
*/
|
|
function healRunId(lane) {
|
|
if (!lane.run_id) return lane;
|
|
const run = runs.getRun(lane.run_id);
|
|
if (run && run.status === "running") return lane;
|
|
lanesLib.updateLane(lane.id, { run_id: null, status: "idle" });
|
|
broadcastLane(lane.id);
|
|
return lanesLib.getLane(lane.id);
|
|
}
|
|
|
|
function payload(lane) {
|
|
const healed = healRunId(lane);
|
|
return lanesLib.lanePayload(healed, lastEventAge(healed));
|
|
}
|
|
|
|
/** 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) });
|
|
}
|
|
|
|
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() }));
|
|
|
|
/**
|
|
* How often each pipeline node has actually been passed, aggregated over every
|
|
* lane AND every archived feature — an archived feature is a finished run of a
|
|
* pipeline, so dropping it would throw away most of the history the moment a
|
|
* lane starts its next feature.
|
|
*
|
|
* A node is counted `entered` when a stage record resolves onto it; `failed`
|
|
* when that record says `result: "fail"`; `passed` otherwise; `withEvidence`
|
|
* for the subset of passes that left an artifact behind. Evidence is tracked
|
|
* separately rather than folded into the pass rate, because "claimed" and
|
|
* "proved" are not the same fact — the same distinction the pipeline map draws
|
|
* between `done` and `passed-no-evidence`. Also registered before "/:id".
|
|
*
|
|
* `?lane=<id>` narrows the same aggregation to one lane and its own archived
|
|
* features. The returned `lanes` list is never narrowed — it populates the
|
|
* picker, so it has to keep offering the way back to every other lane.
|
|
*/
|
|
router.get("/pipeline-stats", (req, res) => {
|
|
const laneFilter = req.query.lane;
|
|
let laneId = null;
|
|
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" } });
|
|
}
|
|
}
|
|
|
|
const events =
|
|
laneId === null
|
|
? db
|
|
.prepare(
|
|
"SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events ORDER BY id"
|
|
)
|
|
.all()
|
|
: db
|
|
.prepare(
|
|
"SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE lane_id = ? ORDER BY id"
|
|
)
|
|
.all(laneId);
|
|
|
|
const acc = new Map();
|
|
const entryFor = (pipelineId) => {
|
|
const pipeline = getPipeline(pipelineId);
|
|
let entry = acc.get(pipeline.id);
|
|
if (!entry) {
|
|
entry = { id: pipeline.id, runs: new Set(), nodes: new Map(), template: pipeline };
|
|
acc.set(pipeline.id, entry);
|
|
}
|
|
return entry;
|
|
};
|
|
|
|
// Seed the templates the lanes in scope are configured with, before folding
|
|
// any events in. A lane that has recorded nothing still has to render its map
|
|
// — the picker is how you find out a pipeline is going unused, so a pipeline
|
|
// with zero runs must appear, at zero, rather than vanish.
|
|
const scopedLanes =
|
|
laneId === null
|
|
? db.prepare("SELECT pipeline FROM lanes").all()
|
|
: db.prepare("SELECT pipeline FROM lanes WHERE id = ?").all(laneId);
|
|
for (const row of scopedLanes) entryFor(row.pipeline);
|
|
|
|
// Fold the event stream down to one verdict per (run, node): a run that
|
|
// entered a node twice is still one pass of it, and a declaration always
|
|
// beats a detection of the same node — the agent's own claim is the stronger
|
|
// statement, so a node it declared must never be reported as merely inferred.
|
|
for (const ev of events) {
|
|
const entry = entryFor(ev.pipeline);
|
|
const pipeline = entry.template;
|
|
entry.runs.add(`${ev.lane_id}#${ev.run_seq}`);
|
|
|
|
const idx = phaseIdx(pipeline, ev.node_id);
|
|
if (idx === -1) continue; // a stage nothing in this pipeline is named after
|
|
const key = `${ev.lane_id}#${ev.run_seq}#${idx}`;
|
|
const prev = entry.nodes.get(key);
|
|
const declared = ev.source !== "detected";
|
|
if (prev && prev.declared && !declared) continue;
|
|
entry.nodes.set(key, {
|
|
idx,
|
|
declared: declared || (prev ? prev.declared : false),
|
|
failed: declared ? ev.result === "fail" : prev ? prev.failed : false,
|
|
evidence: (declared && !!ev.evidence) || (prev ? prev.evidence : false),
|
|
});
|
|
}
|
|
|
|
const pipelines = [...acc.values()]
|
|
.map((entry) => {
|
|
const nodes = entry.template.nodes.map((n) => ({
|
|
id: n.id,
|
|
label: n.label,
|
|
icon: n.icon,
|
|
gate: n.gate,
|
|
command: n.command,
|
|
entered: 0,
|
|
passed: 0,
|
|
withEvidence: 0,
|
|
failed: 0,
|
|
detected: 0,
|
|
}));
|
|
for (const v of entry.nodes.values()) {
|
|
const node = nodes[v.idx];
|
|
if (!node) continue;
|
|
if (!v.declared) {
|
|
// Reached, but only inferred from the hook stream. Counted apart from
|
|
// the pass rate: it says the work happened, not that a gate was met.
|
|
node.detected += 1;
|
|
continue;
|
|
}
|
|
node.entered += 1;
|
|
if (v.failed) node.failed += 1;
|
|
else {
|
|
node.passed += 1;
|
|
if (v.evidence) node.withEvidence += 1;
|
|
}
|
|
}
|
|
const name = entry.template.name || entry.id;
|
|
return { id: entry.id, name, runs: entry.runs.size, nodes };
|
|
})
|
|
// Busiest pipeline first: the one with the most recorded runs is the one
|
|
// the reader most likely came to look at.
|
|
.sort((a, b) => b.runs - a.runs);
|
|
|
|
const lanes = db.prepare("SELECT id, title, cwd FROM lanes ORDER BY id").all();
|
|
res.json({ pipelines, lanes });
|
|
});
|
|
|
|
/**
|
|
* 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 || {};
|
|
// Default the cwd to the user's home so callers that don't care about a
|
|
// specific directory (e.g. Sagi's mascot companion) get a real, existing
|
|
// path and lane uniqueness stays meaningful. Callers that do care pass one
|
|
// through unchanged.
|
|
const cwd = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir();
|
|
|
|
if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) {
|
|
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
|
}
|
|
|
|
// A cwd can now back one lane per tool (idx_lanes_cwd_tool) — "does a lane
|
|
// already own this (cwd, tool) pair" is the real question, so a request for
|
|
// a different tool than an existing lane at this cwd falls through to
|
|
// creating that tool's own lane instead of adopting the wrong one.
|
|
const owner = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
|
|
if (owner) {
|
|
// First-write wins on the title: an ensure-with-title on an existing lane
|
|
// is how callers like Sagi adopt the lane, so don't clobber a real title
|
|
// with an empty one.
|
|
if (body.title && !owner.title) {
|
|
const updated = lanesLib.updateLane(owner.id, { title: body.title });
|
|
return res.json({ lane: payload(updated || owner), created: false });
|
|
}
|
|
return res.json({ lane: payload(owner), created: false });
|
|
}
|
|
try {
|
|
const lane = lanesLib.createLane({
|
|
cwd,
|
|
title: body.title || "",
|
|
...(body.pipeline ? { pipeline: body.pipeline } : {}),
|
|
...(body.tool ? { tool: body.tool } : {}),
|
|
});
|
|
broadcastLane(lane.id);
|
|
return res.status(201).json({ lane: payload(lane), created: true });
|
|
} catch (err) {
|
|
if (err.code === "EBADCWD" || err.code === "EBADPIPELINE" || err.code === "EBADTOOL") {
|
|
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(cwd, body.tool || "claude");
|
|
if (winner) return res.json({ lane: payload(winner), created: false });
|
|
}
|
|
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Machine-wide housekeeping — not scoped to one lane. Reaps orphaned
|
|
* Playwright MCP processes and caps oversized hook logs across every lane
|
|
* on this machine. See server/lib/lane-gc.js for what "orphaned" and
|
|
* "oversized" mean.
|
|
*/
|
|
router.post("/gc", sameOriginGuard, (req, res) => {
|
|
const dryRun = req.body?.dryRun === true;
|
|
try {
|
|
const reaped = reapOrphanMcp({ dryRun });
|
|
const capped = capOversizedLogs({ dryRun });
|
|
res.json({ reaped, capped });
|
|
} catch (err) {
|
|
res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Directory listing for the Add Lane modal's folder browser — browsers cannot
|
|
* expose absolute filesystem paths from a native picker, so path selection is
|
|
* done by browsing server-side instead. Read-only; registered ahead of
|
|
* "/:id" so the literal "browse" segment is never captured as a lane id.
|
|
*/
|
|
router.get("/browse", (req, res) => {
|
|
const raw =
|
|
typeof req.query.path === "string" && req.query.path.trim() ? req.query.path : os.homedir();
|
|
const resolved = path.resolve(raw);
|
|
|
|
let stat;
|
|
try {
|
|
stat = fs.statSync(resolved);
|
|
} catch {
|
|
return res.status(400).json({ error: { code: "ENOTFOUND", message: "path does not exist" } });
|
|
}
|
|
if (!stat.isDirectory()) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: { code: "ENOTADIR", message: "path is not a directory" } });
|
|
}
|
|
|
|
let entries = [];
|
|
try {
|
|
entries = fs
|
|
.readdirSync(resolved, { withFileTypes: true })
|
|
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
.map((e) => {
|
|
const full = path.join(resolved, e.name);
|
|
return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")) };
|
|
})
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
} catch {
|
|
// An unreadable entry mid-listing is skipped, not a request failure.
|
|
}
|
|
|
|
const parent = path.dirname(resolved) === resolved ? null : path.dirname(resolved);
|
|
res.json({ path: resolved, parent, entries });
|
|
});
|
|
|
|
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" ||
|
|
err.code === "EBADPIPELINE" ||
|
|
err.code === "EBADKIND" ||
|
|
err.code === "EBADFIELD"
|
|
) {
|
|
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`, `pipeline` or field value is invalid input, not a server
|
|
// fault — every sibling route answers 400 here, so this one must too
|
|
// instead of throwing into Express. The SQLITE_CONSTRAINT family lands
|
|
// here for the same reason: a NOT NULL column patched to null is the
|
|
// caller's mistake, and enumerating which columns those are in JS would
|
|
// just be the schema written twice.
|
|
if (err.code === "EBADKIND" || err.code === "EBADPIPELINE" || err.code === "EBADFIELD") {
|
|
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
|
}
|
|
if (String(err.code || "").startsWith("SQLITE_CONSTRAINT")) {
|
|
return res.status(400).json({ error: { code: "EBADFIELD", 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) => {
|
|
const current = lanesLib.getLane(req.params.id);
|
|
if (!current) {
|
|
return res.status(404).json({ error: { code: "ENOLANE", message: "lane 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
|
|
// lane (whose node is `gates`) left the CI row reading "never reached" with
|
|
// no hint anything was wrong. Aliases still resolve, so this rejects typos
|
|
// and wrong-pipeline names, not shorthand.
|
|
const requested = req.body && req.body.stage;
|
|
if (requested !== undefined && requested !== null && requested !== "") {
|
|
const pipeline = getPipeline(current.pipeline);
|
|
if (phaseIdx(pipeline, requested) === -1) {
|
|
return res.status(400).json({
|
|
error: {
|
|
code: "EBADSTAGE",
|
|
message:
|
|
`"${requested}" is not a stage of the "${pipeline.id}" pipeline. ` +
|
|
`Nodes: ${pipeline.nodes.map((n) => n.id).join(", ")}`,
|
|
stages: pipeline.nodes.map((n) => n.id),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
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 } });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Give this lane the same MCP servers as its source repo — relocates the
|
|
* source repo's already-configured mcpServers (from ~/.claude.json) into
|
|
* <lane>/.mcp.json, pins Playwright's proof output dir, seeds Chromium
|
|
* profiles. Never automatic, same as proof-link/agents-install: a session
|
|
* calls this explicitly. Never touches permissions/settings.local.json —
|
|
* see the F1 design spec for why.
|
|
*/
|
|
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" } });
|
|
try {
|
|
res.json(await syncMcp(lane));
|
|
} catch (err) {
|
|
if (err.code === "ENOMCPCONFIG") {
|
|
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
|
}
|
|
res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /:id/router/apply — point this lane at one AI Router profile (or back at
|
|
* `native`). Body: `{ profile: "<name>" }`.
|
|
*
|
|
* The profile name is the only thing taken from the request: what gets written,
|
|
* and where, is derived from the lane row (`server/lib/router-apply.js`). The
|
|
* lane's `router_profile` column is set in the same call so a later spawn knows
|
|
* which `-p` layer a codex pane needs.
|
|
*/
|
|
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" } });
|
|
const profile = (req.body || {}).profile;
|
|
if (typeof profile !== "string" || !profile) {
|
|
return res.status(400).json({ error: { code: "EBADREQ", message: "profile is required" } });
|
|
}
|
|
try {
|
|
const result = applyRouterToLane(lane, profile);
|
|
lanesLib.setRouterProfile(lane.id, profile);
|
|
res.json(result);
|
|
} catch (err) {
|
|
const status = { ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400 }[err.code] || 500;
|
|
res.status(status).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Detect a Node.js project at this lane's OWN directory and scaffold
|
|
* .agenthub/profile/ if one is found — the HTTP equivalent of
|
|
* `agenthub lanes profile init`, always targeting lane.cwd (never an arbitrary
|
|
* path; the CLI's <repo> argument has no meaning here, this lane's own
|
|
* directory is the only sensible target). "No Node.js project detected" is
|
|
* a normal 200 outcome, not an error — most lanes won't be auto-detectable
|
|
* and that's fine, same as every other optional profile declaration.
|
|
*/
|
|
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" } });
|
|
|
|
const facts = detectNode(lane.cwd);
|
|
if (!facts) {
|
|
return res.json({ scaffolded: false, reason: "no detectable Node.js project" });
|
|
}
|
|
try {
|
|
const result = scaffoldProfile(lane.cwd, facts, { force: req.body?.force === true });
|
|
res.json({ scaffolded: true, written: result.written, todos: result.todos });
|
|
} catch (err) {
|
|
if (err.code === "EPROFILEEXISTS") {
|
|
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
|
}
|
|
res.status(500).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Whether a named integration (tracker, dev_qc, ci_wait, ...) is turned on
|
|
* for this lane — reads .agenthub/profile/integrations.env. Read-only, no
|
|
* sameOriginGuard needed (same reasoning GET /:id/git already documents:
|
|
* that guard exists for destructive actions).
|
|
*/
|
|
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" } });
|
|
res.json({ enabled: isIntegrationEnabled(lane, req.params.name) });
|
|
});
|
|
|
|
/**
|
|
* 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 five
|
|
* 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 });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* A single GitHub Actions status snapshot for one ref (branch or commit sha)
|
|
* in the lane's repo — NOT a poll loop. ship-feature-lane's Stage 13 owns
|
|
* the wait/retry decision (docs/LANES.md "Orchestration: what AgentHub does NOT
|
|
* do"); this route only reports what `gh` currently knows. Read-only, no
|
|
* same-origin guard, same reasoning as GET /:id/git.
|
|
*/
|
|
router.get("/:id/ci/status", async (req, res) => {
|
|
const lane = laneOr404(req, res);
|
|
if (!lane) return;
|
|
const ref = typeof req.query.ref === "string" ? req.query.ref.trim() : "";
|
|
if (!ref) {
|
|
return res.status(400).json({ error: { code: "EBADREF", message: "ref is required" } });
|
|
}
|
|
try {
|
|
res.json(await ciStatus(lane.cwd, ref));
|
|
} catch (err) {
|
|
sendRuntimeError(res, err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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 || {};
|
|
|
|
if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) {
|
|
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
|
}
|
|
|
|
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",
|
|
},
|
|
});
|
|
}
|
|
|
|
let branch;
|
|
if (body.branch !== undefined) {
|
|
if (!(await isValidBranchName(resolvedSourceRepo, body.branch))) {
|
|
return res.status(400).json({
|
|
error: { code: "EBADBRANCH", message: "branch is not a valid git branch name" },
|
|
});
|
|
}
|
|
branch = body.branch;
|
|
} else {
|
|
const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/";
|
|
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,
|
|
...(body.pipeline ? { pipeline: body.pipeline } : {}),
|
|
...(body.tool ? { tool: body.tool } : {}),
|
|
});
|
|
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"]);
|
|
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 tmux session to exit 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.status !== "gone") {
|
|
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 } });
|
|
}
|
|
// ERUNLIVE: the lane's pane is busy with a live agent, so a resume/prompt
|
|
// could not be delivered — a conflict, not a bad request or a server fault.
|
|
if (["ESTALE", "EUNPUSHED", "ERUNLIVE"].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",
|
|
"ENOMCPCONFIG",
|
|
];
|
|
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
|
|
* lifecycle function. 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<slot>/ 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": {
|
|
// 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. 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 === "running") {
|
|
return { conflict: true };
|
|
}
|
|
const handle = runs.spawnRun({
|
|
laneId: current.id,
|
|
initialPrompt: body.prompt || "",
|
|
cwd: current.cwd,
|
|
// The tool is the LANE's fact, never the request's: a client that
|
|
// asked for the other CLI would otherwise get a pane that doesn't
|
|
// match the lane's badge, its history, or its binary probe.
|
|
tool: current.tool || "claude",
|
|
// Same reasoning as `tool`: the router is the lane's fact, written
|
|
// into its config when the lane was created.
|
|
routerProfile: current.router_profile || null,
|
|
model: body.model,
|
|
permissionMode: body.permissionMode,
|
|
effort: body.effort,
|
|
sandbox: body.sandbox,
|
|
approval: body.approval,
|
|
resumeLast: body.resumeLast === true,
|
|
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": {
|
|
return res.status(400).json({
|
|
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)",
|
|
},
|
|
});
|
|
}
|
|
case "clear":
|
|
lanesLib.clearLane(lane.id);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
// A host with no tmux cannot start a run, but nothing is broken on this
|
|
// side — 503 plus the install line, not a 500 the reader can only read as a
|
|
// dashboard bug.
|
|
if (err.code === "ETMUXMISSING") {
|
|
return res.status(503).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(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;
|