b441f32949
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # client/src/components/Sagi/PetSprite.tsx # client/src/components/Sagi/useSagiBrain.ts
2642 lines
104 KiB
JavaScript
2642 lines
104 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 express = require("express");
|
||
const { Router } = 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 { sessionWorktree, removeSessionWorktree } = require("../lib/session-worktree");
|
||
const { sourceFolder } = require("../lib/lane-sources");
|
||
const { suggestCommitMessage } = require("../lib/commit-message");
|
||
const aiTools = require("../lib/ai-tools");
|
||
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 tmux = require("../lib/tmux");
|
||
const { resumeProxy } = require("../lib/proxy-apply");
|
||
const { sameOriginGuard } = require("./run");
|
||
const { isLocalRequest } = require("../lib/same-origin");
|
||
const scope = require("../lib/scope");
|
||
const { writeLaneFile } = require("../lib/lane-file-write");
|
||
const { openDownload, saveUpload, UPLOAD_MAX_BYTES } = require("../lib/lane-file-transfer");
|
||
const { pickPath, startDir } = require("../lib/native-dialog");
|
||
const { preflight } = require("../lib/lane-preflight");
|
||
const {
|
||
LANES_ROOT,
|
||
addWorktree,
|
||
git,
|
||
gitFacts,
|
||
isGitRepo,
|
||
isValidBranchName,
|
||
listBranches,
|
||
listWorktrees,
|
||
resetWorktree,
|
||
resolveBase,
|
||
slugify,
|
||
} = require("../lib/worktree");
|
||
const {
|
||
collectChanges,
|
||
listCommits,
|
||
defaultBranch,
|
||
listPaths,
|
||
listTree,
|
||
listTreeAll,
|
||
readFileAt,
|
||
blameFile,
|
||
readImageAt,
|
||
} = require("../lib/git-diff");
|
||
const { runGitAction } = require("../lib/git-ops");
|
||
const { withLaneLock } = require("../lib/lane-lock");
|
||
const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync");
|
||
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,
|
||
} = 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 last showed life: its session's newest event or the
|
||
* start of its current run, whichever is newer; null if neither exists. The
|
||
* run counts because a new run keeps the PREVIOUS session_id until the new
|
||
* session's first hook rebinds it — judged by that old session alone, a run
|
||
* started a second ago read "dead" and, with no lane field changing after
|
||
* (a resume, or a hook ingested by another server on the same DB), stayed so
|
||
* until a refresh. Only with a session bound: a lane no hook ever gives one
|
||
* (a catalog tool, Codex without its hooks) has no signal, so stays null.
|
||
*/
|
||
const lastEventStmt = db.prepare("SELECT MAX(created_at) AS last FROM events WHERE session_id = ?");
|
||
const runStartStmt = db.prepare("SELECT started_at FROM dashboard_runs WHERE id = ?");
|
||
function lastEventAge(lane) {
|
||
if (!lane.session_id) return null;
|
||
const times = [];
|
||
const row = lastEventStmt.get(lane.session_id);
|
||
if (row?.last) times.push(Date.parse(row.last));
|
||
if (lane.run_id) {
|
||
const run = runStartStmt.get(lane.run_id);
|
||
if (run?.started_at) times.push(Date.parse(run.started_at));
|
||
}
|
||
const t = Math.max(...times.filter((x) => !Number.isNaN(x)));
|
||
return Number.isFinite(t) ? Math.max(0, Math.round((Date.now() - t) / 1000)) : null;
|
||
}
|
||
|
||
/**
|
||
* 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, live = null) {
|
||
if (!lane.run_id) return lane;
|
||
// A member's pane on a server without S3 (a dev dashboard on the same DB):
|
||
// its tmux is not ours to ask, so "not listed" proves nothing.
|
||
if (require("../lib/linux-accounts").unseenMemberFolder(lane.cwd)) return lane;
|
||
if (live) {
|
||
if (live.has(lane.run_id)) return lane;
|
||
// Started a moment ago: a cached list may not show it yet (dashboard-runs.js).
|
||
const dashboardRuns = require("../lib/dashboard-runs");
|
||
const row = dashboardRuns.getRun(lane.run_id);
|
||
if (row && row.status === "running" && dashboardRuns.isYoungRun(row.started_at)) return lane;
|
||
} else {
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Heal every lane at once. A claude pane that exits fires a hook, which
|
||
* broadcasts the lane and so re-reads it; a codex or antigravity pane fires
|
||
* nothing, so without this sweep its lane read "running" until something
|
||
* else happened to fetch it. `live` (a `tmux.liveSessions()` set) answers
|
||
* every lane from one tmux call instead of a has-session per lane.
|
||
*/
|
||
function healAllRunIds(live = null) {
|
||
for (const lane of lanesLib.listLanes()) if (lane.run_id) healRunId(lane, live);
|
||
}
|
||
|
||
/**
|
||
* Re-broadcast the lanes ANOTHER process changed. Two dashboards may share one
|
||
* database, and hooks reach only one of them (the lowest port), so the other's
|
||
* browsers never heard about a lane the hooks moved until they refreshed.
|
||
* `PRAGMA data_version` moves only on another connection's commit, so the
|
||
* common pass is one pragma; after one, each lane whose `updated_at` differs
|
||
* from what this pass saw last is broadcast (a lane this server wrote itself
|
||
* may go out twice — the client drops an identical copy), and a vanished
|
||
* lane goes out as `removed`.
|
||
* ponytail: two writes in the same millisecond share an updated_at; a counter
|
||
* column would close that.
|
||
*/
|
||
let seenDataVersion = null;
|
||
const seenLanes = new Map();
|
||
const laneStampsStmt = db.prepare("SELECT id, updated_at FROM lanes");
|
||
function broadcastForeignLaneChanges() {
|
||
const out = { changed: [], removed: [] };
|
||
const version = db.pragma("data_version", { simple: true });
|
||
if (version === seenDataVersion) return out;
|
||
const primed = seenDataVersion !== null;
|
||
seenDataVersion = version;
|
||
const live = new Set();
|
||
for (const { id, updated_at } of laneStampsStmt.all()) {
|
||
live.add(id);
|
||
if (primed && seenLanes.get(id) !== updated_at) out.changed.push(id);
|
||
seenLanes.set(id, updated_at);
|
||
}
|
||
for (const id of [...seenLanes.keys()]) {
|
||
if (live.has(id)) continue;
|
||
seenLanes.delete(id);
|
||
if (primed) out.removed.push(id);
|
||
}
|
||
for (const id of out.changed) broadcastLane(id);
|
||
for (const id of out.removed) broadcast("lane_update", { removed: id });
|
||
return out;
|
||
}
|
||
|
||
function payload(lane, live = null) {
|
||
const healed = healRunId(lane, live);
|
||
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) return;
|
||
// The cached list-sessions (as GET / uses), not a synchronous has-session —
|
||
// through sudo for a member's pane — on every hook that moves a lane.
|
||
const live = lane.run_id ? tmux.liveSessions() : null;
|
||
broadcast("lane_update", { lane: payload(lane, live) });
|
||
}
|
||
|
||
router.get("/", (req, res) => {
|
||
// Server mode: a member lists (and counts) only their own workspaces.
|
||
const visible = scope.visibleLaneIds(req);
|
||
const rows = lanesLib.listLanes().filter((l) => visible === null || visible.includes(l.id));
|
||
// ONE list-sessions answers every lane's run, instead of a synchronous
|
||
// has-session per lane (null when tmux could not be asked: per-lane then).
|
||
const live = rows.some((l) => l.run_id) ? tmux.liveSessions() : null;
|
||
const lanes = rows.map((l) => payload(l, live));
|
||
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: "workspace must be an id" } });
|
||
}
|
||
}
|
||
|
||
// Server mode: a member's aggregate, lanes and picker cover only their own
|
||
// workspaces ("1" for everyone else). A `?lane=` of another user's never
|
||
// gets here — scopeGuard answers 404 first.
|
||
const own = scope.laneWhere(req, "lane_id");
|
||
const ownLanes = scope.laneWhere(req, "id");
|
||
const events =
|
||
laneId === null
|
||
? db
|
||
.prepare(
|
||
`SELECT lane_id, pipeline, run_seq, node_id, source, result, evidence FROM lane_stage_events WHERE ${own.where} ORDER BY id`
|
||
)
|
||
.all(...own.params)
|
||
: 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 WHERE ${ownLanes.where}`).all(...ownLanes.params)
|
||
: 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 WHERE ${ownLanes.where} ORDER BY id`)
|
||
.all(...ownLanes.params);
|
||
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" },
|
||
});
|
||
}
|
||
// A developer names only a repo in their own folder: git would otherwise run
|
||
// AS whichever member owns the path (S3) and hand back their branches.
|
||
if (!scope.folderAllowed(req, lanesLib.canonicalCwd(sourceRepo))) return refuseFolder(res);
|
||
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);
|
||
// The repo's worktrees ride along because every question the Add-lane
|
||
// picker asks about a branch is really a question about them: which
|
||
// branches can still be checked out (git allows one worktree per branch,
|
||
// and `addWorktree` throws EBRANCHBUSY for the rest), and what this repo
|
||
// already has provisioned. One extra subprocess on a form the user is
|
||
// still typing into, versus a second round trip for facts that expire
|
||
// together. A repo whose worktree list cannot be read still answers with
|
||
// its branches — the picker just loses the busy markers.
|
||
const worktrees = await listWorktrees(sourceRepo).catch(() => []);
|
||
res.json({ branches, current, worktrees });
|
||
} catch (err) {
|
||
res
|
||
.status(500)
|
||
.json({ error: { code: gitErrorCode(err), message: err.message, git: err.git } });
|
||
}
|
||
});
|
||
|
||
/** The lane fields a server-mode member may PATCH (the UI sends title and pipeline). */
|
||
const MEMBER_PATCHABLE = new Set(["title", "pipeline", "notes", "links", "branch"]);
|
||
|
||
/** Server mode: the folder is, holds or lies in another user's workspace. */
|
||
function refuseOwned(res) {
|
||
return res.status(409).json({
|
||
error: { code: "EOWNED", message: "this folder belongs to another user's workspace" },
|
||
});
|
||
}
|
||
|
||
/** Server mode with AGENTHUB_USERS_ROOT: a member's folder lies outside their own. */
|
||
function refuseFolder(res) {
|
||
return res.status(403).json({
|
||
error: { code: "EFOLDER", message: "a workspace must be inside your own folder" },
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
// Canonical before the lookup, so the lookup and createLane's insert agree.
|
||
const cwd = lanesLib.canonicalCwd(
|
||
typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir()
|
||
);
|
||
|
||
if (body.tool !== undefined && !aiTools.has(body.tool)) {
|
||
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||
}
|
||
if (!scope.folderAllowed(req, cwd)) return refuseFolder(res);
|
||
if (scope.folderTakenByOther(req, cwd)) return refuseOwned(res);
|
||
|
||
// 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.
|
||
//
|
||
// `exact: true` is the Add-lane modal's question — "is THIS directory a
|
||
// lane" — so a lane on a PARENT directory does not answer it (#42: the
|
||
// modal closed on the parent lane and no new card ever appeared). Without
|
||
// it, a nested cwd still resolves to its owner, which is what the CLI and
|
||
// other callers adopting "the lane I am inside" rely on.
|
||
const found = lanesLib.resolveLaneByCwd(cwd, body.tool || "claude");
|
||
const strip = (p) => p.replace(/[\\/]+$/, "");
|
||
const owner = found && (body.exact !== true || strip(found.cwd) === strip(cwd)) ? found : null;
|
||
// Server mode: another user's workspace is not this one's to adopt.
|
||
if (owner && !scope.canSeeLane(req, owner.id)) {
|
||
return res.status(409).json({
|
||
error: { code: "EOWNED", message: "this folder belongs to another user's workspace" },
|
||
});
|
||
}
|
||
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,
|
||
owner_id: scope.ownerFor(req),
|
||
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" ||
|
||
err.code === "EDISABLED"
|
||
) {
|
||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
if (err.code === "EOWNERFOLDER" || err.code === "EMEMBERGONE") {
|
||
return res.status(409).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.
|
||
*/
|
||
/**
|
||
* S3 (docs/plans/server-mode/S3-PLAN.md): the managed-worktree mode and the
|
||
* runtime layer run as the dashboard's own account and write into the
|
||
* workspace or LANES_ROOT, so they are not offered for a folder that belongs
|
||
* to a Linux account — such a workspace uses Repo mode and the prompt box's
|
||
* New worktree. True once it has answered 403 EMEMBERLANE.
|
||
*/
|
||
function refuseS3Folder(res, folder) {
|
||
// ownerForRead: a folder of a gone member is still a member's (accountForPath throws).
|
||
if (!require("../lib/run-as").ownerForRead(folder)) return false;
|
||
res.status(403).json({
|
||
error: {
|
||
code: "EMEMBERLANE",
|
||
message:
|
||
"not available for a workspace in a member's folder — use Repo mode and the prompt box's New worktree",
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
/** Route guard: `refuseS3Folder` for the workspace `:id`. */
|
||
function s3Guard(req, res, next) {
|
||
const lane = lanesLib.getLane(Number(req.params.id));
|
||
if (lane && refuseS3Folder(res, lane.cwd)) return;
|
||
next();
|
||
}
|
||
|
||
router.post("/gc", sameOriginGuard, (req, res) => {
|
||
// Machine-wide, so it cannot be narrowed to one member's workspaces.
|
||
if (scope.memberId(req) != null) {
|
||
return res
|
||
.status(403)
|
||
.json({ error: { code: "EADMIN", message: "only an admin may run housekeeping" } });
|
||
}
|
||
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.
|
||
* `files=1` (a file field's fallback) lists files too, and opens `path` the
|
||
* way the native picker would (`startDir`: `~`, a file's folder, else home).
|
||
* `hidden=1` lists dot entries too — never those at the top of a non-admin's
|
||
* own folder, where a member's logins live. Each entry carries its `mtime`
|
||
* (ms), and `home` is the folder the browser's Home place opens.
|
||
* An admin with a Linux account of their own (S3) starts in THEIR home, like
|
||
* any member, and the answer carries `hostHome` (the host account's home);
|
||
* `host=1` opens that one instead — a workspace there runs as the shared
|
||
* host account, so it is a deliberate tick in the dialog, never the default.
|
||
*/
|
||
router.get("/browse", (req, res) => {
|
||
// A developer browses only their own folder (by real path: a link in it
|
||
// cannot point the listing elsewhere) — the dashboard lists with its own
|
||
// rights, CAP_DAC_READ_SEARCH under S3, not theirs.
|
||
const own = scope.ownFolder(req);
|
||
const files = req.query.files === "1";
|
||
const hidden = req.query.hidden === "1";
|
||
// An admin is not held to a folder, but starts in their own home (S3).
|
||
let adminHome = null;
|
||
if (!own && req.user?.role === "admin") {
|
||
try {
|
||
adminHome = require("../lib/run-as").accountForUser(req.user.username)?.home || null;
|
||
} catch {
|
||
// No Linux account for this admin (EMEMBERGONE): the host's home, as before.
|
||
}
|
||
}
|
||
const startHome = adminHome && req.query.host !== "1" ? adminHome : own || os.homedir();
|
||
const raw =
|
||
typeof req.query.path === "string" && req.query.path.trim()
|
||
? files
|
||
? startDir(req.query.path)
|
||
: req.query.path
|
||
: startHome;
|
||
const resolved = own ? lanesLib.canonicalCwd(path.resolve(raw)) : path.resolve(raw);
|
||
if (own && !scope.folderAllowed(req, resolved, { allowHome: true })) return refuseFolder(res);
|
||
|
||
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" } });
|
||
}
|
||
|
||
const atOwnTop = Boolean(own) && resolved === lanesLib.canonicalCwd(own);
|
||
const mtimeOf = (full) => {
|
||
try {
|
||
return fs.statSync(full).mtimeMs;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
let entries = [];
|
||
try {
|
||
entries = fs
|
||
.readdirSync(resolved, { withFileTypes: true })
|
||
.filter(
|
||
(e) =>
|
||
(e.isDirectory() || (files && e.isFile())) &&
|
||
(!e.name.startsWith(".") || (hidden && !atOwnTop))
|
||
)
|
||
.map((e) => {
|
||
const full = path.join(resolved, e.name);
|
||
const mtime = mtimeOf(full);
|
||
if (e.isFile()) return { name: e.name, path: full, isGitRepo: false, file: true, mtime };
|
||
return {
|
||
name: e.name,
|
||
path: full,
|
||
isGitRepo: fs.existsSync(path.join(full, ".git")),
|
||
mtime,
|
||
};
|
||
})
|
||
// Folders first, as every file picker lists them.
|
||
.sort((a, b) => !!a.file - !!b.file || a.name.localeCompare(b.name));
|
||
} catch {
|
||
// An unreadable entry mid-listing is skipped, not a request failure.
|
||
}
|
||
|
||
const top = path.dirname(resolved) === resolved || atOwnTop;
|
||
const parent = top ? null : path.dirname(resolved);
|
||
res.json({
|
||
path: resolved,
|
||
parent,
|
||
entries,
|
||
home: startHome,
|
||
...(adminHome ? { hostHome: os.homedir() } : {}),
|
||
});
|
||
});
|
||
|
||
/**
|
||
* The folder browser's New folder: makes ONE folder `name` (a plain name —
|
||
* no separator, not `.`/`..`) in the existing folder `parent`, never with
|
||
* `-p` and never over an existing entry (`EEXISTS`). A non-admin makes one
|
||
* only inside their own folder, judged by real path like `GET /browse`, and
|
||
* in server mode a non-admin with no folder of their own makes none
|
||
* (`EFOLDER`) — a read of the host's disk is not a write to it. A folder in a
|
||
* member's home (S3) is made AS the member, never by the host.
|
||
*/
|
||
router.post("/browse/mkdir", sameOriginGuard, (req, res) => {
|
||
const { parent, name } = req.body || {};
|
||
if (
|
||
typeof name !== "string" ||
|
||
!name ||
|
||
name === "." ||
|
||
name === ".." ||
|
||
/[/\\\0]/.test(name) ||
|
||
Buffer.byteLength(name) > 255
|
||
) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: { code: "EBADNAME", message: "a folder name without '/' or '\\'" } });
|
||
}
|
||
if (typeof parent !== "string" || !path.isAbsolute(parent)) {
|
||
return res.status(400).json({ error: { code: "EBADPATH", message: "parent: absolute path" } });
|
||
}
|
||
const own = scope.ownFolder(req);
|
||
const limited = Boolean(req.user) && req.user.role !== "admin";
|
||
if (limited && !own) return refuseFolder(res);
|
||
const dir = own ? lanesLib.canonicalCwd(path.resolve(parent)) : path.resolve(parent);
|
||
if (own && !scope.folderAllowed(req, dir, { allowHome: true })) return refuseFolder(res);
|
||
try {
|
||
if (!fs.statSync(dir).isDirectory()) throw new Error("not a directory");
|
||
} catch {
|
||
return res
|
||
.status(400)
|
||
.json({ error: { code: "ENOTFOUND", message: "parent folder does not exist" } });
|
||
}
|
||
const target = path.join(dir, name);
|
||
if (fs.existsSync(target) || isSymlink(target)) {
|
||
return res.status(409).json({ error: { code: "EEXISTS", message: "that name is taken" } });
|
||
}
|
||
try {
|
||
const runAs = require("../lib/run-as");
|
||
const acct = runAs.accountForPath(target);
|
||
if (acct) runAs.mkdirAs(acct, target);
|
||
else fs.mkdirSync(target);
|
||
} catch (err) {
|
||
const status = err.code === "EMEMBERGONE" ? 409 : err.code === "EEXIST" ? 409 : 500;
|
||
return res.status(status).json({
|
||
error: {
|
||
code: err.code === "EEXIST" ? "EEXISTS" : err.code || "EMKDIR",
|
||
message: err.message,
|
||
},
|
||
});
|
||
}
|
||
res.status(201).json({ path: target });
|
||
});
|
||
|
||
/**
|
||
* Add workspace → Clone: `git clone <url>` into `<parent>/<name>` and make
|
||
* that folder a workspace (like `POST /ensure`). The URL passes the same
|
||
* allowlist as `publish` (`validateRemoteUrl`: https with no userinfo, ssh://,
|
||
* scp-like), `name` is one plain folder name that must not exist yet, and
|
||
* `parent` is held to the user's own folder exactly like New folder above.
|
||
* The clone runs AS the folder's account (S3: the member) with THAT
|
||
* account's own identity (`server/lib/git-identity.js`) — the user's SSH key
|
||
* or saved HTTPS login, or `credentials` typed for this one https host.
|
||
* 409 `ECREDENTIALS` (with `auth: {protocol, host}`) asks the browser for a
|
||
* login, 409 `ESSHAUTH` means the key was refused — 409 like the git bar's
|
||
* `EGITAUTH`, never 401, which the client reads as "signed out".
|
||
*/
|
||
router.post("/clone", sameOriginGuard, async (req, res) => {
|
||
const body = req.body || {};
|
||
const identity = require("../lib/git-identity");
|
||
let url;
|
||
try {
|
||
url = require("../lib/git-ops").validateRemoteUrl(
|
||
typeof body.url === "string" ? body.url.trim() : body.url
|
||
);
|
||
} catch (err) {
|
||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
const name =
|
||
typeof body.name === "string" && body.name.trim()
|
||
? body.name.trim()
|
||
: identity.defaultName(url);
|
||
if (
|
||
!name ||
|
||
name === "." ||
|
||
name === ".." ||
|
||
/[/\\\0]/.test(name) ||
|
||
Buffer.byteLength(name) > 255
|
||
) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: { code: "EBADNAME", message: "a folder name without '/' or '\\'" } });
|
||
}
|
||
if (typeof body.parent !== "string" || !path.isAbsolute(body.parent)) {
|
||
return res.status(400).json({ error: { code: "EBADPATH", message: "parent: absolute path" } });
|
||
}
|
||
if (body.tool !== undefined && !aiTools.has(body.tool)) {
|
||
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||
}
|
||
const once = body.credentials;
|
||
if (once != null && (typeof once !== "object" || Array.isArray(once))) {
|
||
return res.status(400).json({ error: { code: "EBADCRED", message: "credentials: object" } });
|
||
}
|
||
const own = scope.ownFolder(req);
|
||
const limited = Boolean(req.user) && req.user.role !== "admin";
|
||
if (limited && !own) return refuseFolder(res);
|
||
const dir = own ? lanesLib.canonicalCwd(path.resolve(body.parent)) : path.resolve(body.parent);
|
||
if (own && !scope.folderAllowed(req, dir, { allowHome: true })) return refuseFolder(res);
|
||
try {
|
||
if (!fs.statSync(dir).isDirectory()) throw new Error("not a directory");
|
||
} catch {
|
||
return res
|
||
.status(400)
|
||
.json({ error: { code: "ENOTFOUND", message: "parent folder does not exist" } });
|
||
}
|
||
const target = path.join(dir, name);
|
||
if (fs.existsSync(target) || isSymlink(target)) {
|
||
return res.status(409).json({ error: { code: "EEXISTS", message: "that name is taken" } });
|
||
}
|
||
if (!scope.folderAllowed(req, target)) return refuseFolder(res);
|
||
if (scope.folderTakenByOther(req, target)) return refuseOwned(res);
|
||
const ownerId = scope.ownerFor(req);
|
||
try {
|
||
// Before the clone: a workspace this user may not own there, or a tool
|
||
// that is disabled, is refused up front — not after minutes of cloning,
|
||
// leaving a clone with no workspace on it.
|
||
lanesLib.validateTool(body.tool || "claude");
|
||
require("../lib/run-as").assertOwnerFits(target, ownerId);
|
||
await identity.cloneRepo({
|
||
url,
|
||
parent: dir,
|
||
name,
|
||
once: once
|
||
? { username: once.username, password: once.password, remember: once.remember === true }
|
||
: null,
|
||
});
|
||
} catch (err) {
|
||
const status = ["EBADCRED", "EBADHOST", "EBADTOOL", "EDISABLED"].includes(err.code)
|
||
? 400
|
||
: ["ECREDENTIALS", "ESSHAUTH", "ENOREPO", "EOWNERFOLDER", "EMEMBERGONE"].includes(err.code)
|
||
? 409
|
||
: 500;
|
||
return res.status(status).json({
|
||
error: {
|
||
code: err.code || "ECLONE",
|
||
message: err.message,
|
||
...(err.code === "ECREDENTIALS"
|
||
? { auth: { protocol: err.protocol, host: err.host } }
|
||
: {}),
|
||
},
|
||
});
|
||
}
|
||
try {
|
||
const lane = lanesLib.createLane({
|
||
cwd: lanesLib.canonicalCwd(target),
|
||
owner_id: ownerId,
|
||
title: typeof body.title === "string" && body.title.trim() ? body.title.trim() : name,
|
||
...(body.tool ? { tool: body.tool } : {}),
|
||
});
|
||
broadcastLane(lane.id);
|
||
return res.status(201).json({ lane: payload(lane), created: true, path: target });
|
||
} catch (err) {
|
||
// The clone stays: it is the user's now, and Repo mode can adopt it.
|
||
const status = ["EBADCWD", "EBADTOOL", "EDISABLED"].includes(err.code) ? 400 : 500;
|
||
return res
|
||
.status(status)
|
||
.json({ error: { code: err.code || "ELANE", message: err.message }, path: target });
|
||
}
|
||
});
|
||
|
||
function isSymlink(p) {
|
||
try {
|
||
return fs.lstatSync(p).isSymbolicLink();
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The OS's own folder/file picker, shown on the SERVER's desktop
|
||
* (`server/lib/native-dialog.js`), answering `{path}` or `{cancelled: true}`.
|
||
* Only a client on this machine may ask — anywhere else the dialog would pop
|
||
* up on a screen nobody is looking at (`EREMOTE`); that client, and a server
|
||
* with no desktop (`ENODIALOG`), falls back to `GET /browse` above. A request
|
||
* a reverse proxy forwarded arrives from loopback too, so a forwarding header
|
||
* counts as remote. An SSH tunnel carries no such header and cannot be told
|
||
* apart — the dialog is closed when the browser's request goes away.
|
||
*/
|
||
router.post("/pick-path", sameOriginGuard, async (req, res) => {
|
||
if (!isLocalRequest(req)) {
|
||
return res.status(403).json({
|
||
error: { code: "EREMOTE", message: "the native picker opens only for a local browser" },
|
||
});
|
||
}
|
||
const kind = req.body?.kind === "file" ? "file" : "dir";
|
||
const ac = new AbortController();
|
||
res.on("close", () => {
|
||
if (!res.writableFinished) ac.abort();
|
||
});
|
||
try {
|
||
const picked = await pickPath(kind, req.body?.start, ac.signal);
|
||
if (ac.signal.aborted) return;
|
||
res.json(picked ? { path: picked } : { cancelled: true });
|
||
} catch (err) {
|
||
if (ac.signal.aborted) return;
|
||
const status = err.code === "EBUSY" ? 409 : err.code === "ENODIALOG" ? 501 : 500;
|
||
res.status(status).json({ error: { code: err.code || "EDIALOG", 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: "workspace not found" } });
|
||
res.json({ lane: payload(lane) });
|
||
});
|
||
|
||
router.post("/", sameOriginGuard, (req, res) => {
|
||
try {
|
||
const body = req.body || {};
|
||
const cwd = typeof body.cwd === "string" ? lanesLib.canonicalCwd(body.cwd) : body.cwd;
|
||
if (typeof cwd === "string" && !scope.folderAllowed(req, cwd)) return refuseFolder(res);
|
||
if (typeof cwd === "string" && scope.folderTakenByOther(req, cwd)) return refuseOwned(res);
|
||
const lane = lanesLib.createLane({ ...body, owner_id: scope.ownerFor(req) });
|
||
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" ||
|
||
err.code === "EBADTOOL" ||
|
||
err.code === "EDISABLED"
|
||
) {
|
||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
if (err.code === "EOWNERFOLDER" || err.code === "EMEMBERGONE") {
|
||
return res.status(409).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 workspace 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: "workspace not found" } });
|
||
}
|
||
// Server mode: a member edits only what a person types — never the columns
|
||
// that tie a workspace to a pane or a session (`run_id` pointed at another
|
||
// user's pane would let stop/reset kill it).
|
||
const body = req.body && typeof req.body === "object" ? req.body : {};
|
||
if (scope.memberId(req) != null) {
|
||
const off = Object.keys(body).filter((k) => !MEMBER_PATCHABLE.has(k));
|
||
if (off.length) {
|
||
return res.status(403).json({
|
||
error: { code: "EFIELD", message: `a member may not change ${off.join(", ")}` },
|
||
});
|
||
}
|
||
}
|
||
let lane;
|
||
try {
|
||
lane = lanesLib.updateLane(req.params.id, 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: "workspace not found" } });
|
||
}
|
||
// A stage naming no node of this lane's pipeline used to be stored and then
|
||
// silently ignored by every reader — `agenthub stage test` on a ship-feature
|
||
// 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);
|
||
// No template installed: the pipeline feature is dormant, so there is no
|
||
// stage to declare — say so instead of listing zero nodes.
|
||
if (!pipeline.nodes.length) {
|
||
return res.status(400).json({
|
||
error: {
|
||
code: "ENOPIPELINE",
|
||
message:
|
||
"no pipeline template is installed, so this workspace has no stages to declare " +
|
||
"(drop a template into DASHBOARD_PIPELINES_DIR to turn stages on)",
|
||
stages: [],
|
||
},
|
||
});
|
||
}
|
||
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: "workspace 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: "workspace not found" } });
|
||
res.json({ features: laneFeatures.listFeatures(lane.id).map(featurePayload) });
|
||
});
|
||
|
||
router.get("/:id/features/:slug", (req, res) => {
|
||
const lane = lanesLib.getLane(req.params.id);
|
||
if (!lane)
|
||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||
const feature = laneFeatures.getFeature(lane.id, req.params.slug);
|
||
if (!feature) {
|
||
return res.status(404).json({ error: { code: "ENOFEATURE", message: "no such feature" } });
|
||
}
|
||
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: "workspace not found" } });
|
||
const slug = typeof req.body?.slug === "string" ? req.body.slug : "";
|
||
if (!slug) {
|
||
return res.status(400).json({ error: { code: "EBADSLUG", message: "slug is required" } });
|
||
}
|
||
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: "workspace 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, s3Guard, (req, res) => {
|
||
const lane = lanesLib.getLane(req.params.id);
|
||
if (!lane)
|
||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||
try {
|
||
const result = proofLib.deleteProof(lane.id, {
|
||
slug: req.params.slug,
|
||
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, s3Guard, (req, res) => {
|
||
const lane = lanesLib.getLane(req.params.id);
|
||
if (!lane)
|
||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||
res.json(proofLib.ensureProofLink(lane));
|
||
});
|
||
|
||
/**
|
||
* 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, s3Guard, async (req, res) => {
|
||
const lane = lanesLib.getLane(req.params.id);
|
||
if (!lane)
|
||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace 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 } });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 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, s3Guard, (req, res) => {
|
||
const lane = lanesLib.getLane(req.params.id);
|
||
if (!lane)
|
||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace 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: "workspace 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 t = await gitTargetOr404(req, res);
|
||
if (!t) return;
|
||
try {
|
||
res.json({
|
||
available: true,
|
||
...(await gitFacts(t.cwd)),
|
||
// What the Changes panel's "vs base" compares against: the lane's own
|
||
// base, else the repo's default branch (an extra folder is another
|
||
// repo: always its own default branch).
|
||
base: (!t.source && t.lane.base_branch) || (await defaultBranch(t.cwd)),
|
||
...(t.worktree ? { worktree: t.worktree } : {}),
|
||
});
|
||
} catch {
|
||
res.json({ available: false });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* The lane's code changes, in the three shapes the Workspace's Changes panel
|
||
* asks for — one route, because they are the same read at three depths:
|
||
*
|
||
* ?range=work|branch|commit — overview: every changed file + its counts
|
||
* &full=1 — review: the same list WITH every hunk
|
||
* &path=<file> — one file's hunks (opened from the overview)
|
||
* &context=<n> — lines of context per hunk (expand)
|
||
*
|
||
* `work` (default) is HEAD vs the working tree, untracked files included;
|
||
* `branch` is `<base>...HEAD` — the lane's base, else the repo's default
|
||
* branch — and answers 404-free with ENOBASE when neither exists; `commit` needs `&sha=` and shows that
|
||
* one commit. Read-only, so no same-origin guard — same reasoning as
|
||
* GET /:id/git. See server/lib/git-diff.js for the caps.
|
||
*/
|
||
router.get("/:id/git/changes", async (req, res) => {
|
||
const t = await gitTargetOr404(req, res);
|
||
if (!t) return;
|
||
const { lane, cwd } = t;
|
||
const range = ["branch", "commit"].includes(req.query.range) ? req.query.range : "work";
|
||
const file = typeof req.query.path === "string" && req.query.path ? req.query.path : null;
|
||
const full = req.query.full === "1" || req.query.full === "true";
|
||
try {
|
||
const changes = await collectChanges(cwd, {
|
||
range,
|
||
base: lane.base_branch,
|
||
sha: typeof req.query.sha === "string" ? req.query.sha : null,
|
||
file,
|
||
full,
|
||
context: req.query.context,
|
||
});
|
||
res.json({ available: true, ...changes });
|
||
} catch (err) {
|
||
if (["ENOBASE", "ENOFILECHANGE", "EBADRANGE", "EBADREV"].includes(err.code)) {
|
||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
// Not a git repo (an adopted lane pointed at a plain directory), or a repo
|
||
// with no commits yet. Same contract as GET /:id/git: a state, not a fault.
|
||
res.json({ available: false, range, files: [] });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* The lane's recent commits, for the history list. `?file=` narrows it to the
|
||
* commits touching one path — the "history of this file" the single-file view
|
||
* offers. Reading a commit's own diff is `GET /:id/git/changes?range=commit`.
|
||
*/
|
||
router.get("/:id/git/log", async (req, res) => {
|
||
const t = await gitTargetOr404(req, res);
|
||
if (!t) return;
|
||
const { lane, cwd } = t;
|
||
try {
|
||
const commits = await listCommits(cwd, {
|
||
limit: req.query.limit,
|
||
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
|
||
ref: typeof req.query.ref === "string" && req.query.ref ? req.query.ref : null,
|
||
});
|
||
res.json({ available: true, commits });
|
||
} catch (err) {
|
||
if (err.code === "EBADPATH" || err.code === "ENOBRANCH") {
|
||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
res.json({ available: false, commits: [] });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* One file's whole content at a revision (`?rev=work|HEAD|<sha>`), for the
|
||
* viewer's "show the entire file" mode. Path validation lives in
|
||
* git-diff.js's `readFileAt` — the `work` revision is the one branch that
|
||
* touches the filesystem, and it is realpath-confined to the lane (symlinks
|
||
* included). `.claude/settings.local.json` is 403 ESECRET at every rev.
|
||
*/
|
||
router.get("/:id/git/file", async (req, res) => {
|
||
const t = await gitTargetOr404(req, res);
|
||
if (!t) return;
|
||
const { lane, cwd } = t;
|
||
try {
|
||
const file = await readFileAt(
|
||
cwd,
|
||
typeof req.query.path === "string" ? req.query.path : "",
|
||
typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work"
|
||
);
|
||
res.json({ available: true, ...file });
|
||
} catch (err) {
|
||
// A secret file (where a proxy's API key once lived) is refused at every rev.
|
||
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
|
||
return res.status(403).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
if (["EBADPATH", "EBADREV", "ENOFILE"].includes(err.code)) {
|
||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
res.json({ available: false });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Annotate (`?path=`): who last changed each line of the working-tree file —
|
||
* git-diff.js's `blameFile`, with the whole-file read's guards (lexical lane
|
||
* guard, 403 ESECRET, a size cap). A read like `/git/file`, so unguarded too.
|
||
*/
|
||
router.get("/:id/git/blame", async (req, res) => {
|
||
const t = await gitTargetOr404(req, res);
|
||
if (!t) return;
|
||
try {
|
||
const blame = await blameFile(t.cwd, typeof req.query.path === "string" ? req.query.path : "");
|
||
res.json({ available: true, ...blame });
|
||
} catch (err) {
|
||
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
|
||
return res.status(403).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
if (["EBADPATH", "ENOFILE", "ETOOBIG"].includes(err.code)) {
|
||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
// Not tracked (git blame has no history for it) or not a repo.
|
||
res.json({ available: false });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* One image's bytes (`?path=&rev=work|HEAD|<sha>`) for the viewer's `<img>`:
|
||
* `readImageAt`'s guards (the file read's, plus an image-extension allowlist
|
||
* that picks the Content-Type). Served under a `sandbox` CSP with `nosniff`,
|
||
* so an SVG opened straight from this URL runs no script on the dashboard's
|
||
* origin.
|
||
*/
|
||
router.get("/:id/git/raw", async (req, res) => {
|
||
const t = await gitTargetOr404(req, res);
|
||
if (!t) return;
|
||
try {
|
||
const { type, buf } = await readImageAt(
|
||
t.cwd,
|
||
typeof req.query.path === "string" ? req.query.path : "",
|
||
typeof req.query.rev === "string" && req.query.rev ? req.query.rev : "work"
|
||
);
|
||
res.set({
|
||
"Content-Type": type,
|
||
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
||
"X-Content-Type-Options": "nosniff",
|
||
"Cache-Control": "no-store",
|
||
});
|
||
res.send(buf);
|
||
} catch (err) {
|
||
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
|
||
return res.status(403).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
if (["EBADPATH", "EBADREV", "ENOFILE", "ENOTIMAGE", "ETOOBIG"].includes(err.code)) {
|
||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
res.status(500).json({ error: { code: "EREAD", message: "could not read the image" } });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Save one text file in the lane from the viewer's editor. Guards live in
|
||
* lane-file-write.js (the read path's own, plus existing-textual-file-only and
|
||
* a `baseSha` stale check). Answers the file re-read, fresh `sha` included.
|
||
*/
|
||
router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
|
||
const t = await gitTargetOr404(req, res);
|
||
if (!t) return;
|
||
const { cwd } = t;
|
||
const body = req.body || {};
|
||
try {
|
||
await writeLaneFile(cwd, body.path, body.content, body.baseSha);
|
||
res.json({ available: true, ...(await readFileAt(cwd, body.path, "work")) });
|
||
} catch (err) {
|
||
const status =
|
||
{ ESECRET: 403, EFOREIGN: 403, ESTALE: 409 }[err.code] ??
|
||
(["EBADPATH", "ENOFILE", "EBADCONTENT", "ETOOBIG", "EBINARY"].includes(err.code) ? 400 : 500);
|
||
res.status(status).json({ error: { code: err.code || "EWRITE", message: err.message } });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Download one file of the workspace (`?path=`), any type — lane-file-transfer.js's
|
||
* guards (the file read's own, `openSafe`, a size cap). Always an attachment
|
||
* under a `sandbox` CSP with `nosniff`, so nothing in it renders or runs on the
|
||
* dashboard's origin. A read, so unguarded like `/git/file`.
|
||
*/
|
||
router.get("/:id/git/download", async (req, res) => {
|
||
const t = await gitTargetOr404(req, res);
|
||
if (!t) return;
|
||
let file;
|
||
try {
|
||
file = await openDownload(t.cwd, typeof req.query.path === "string" ? req.query.path : "");
|
||
} catch (err) {
|
||
if (err.code === "ESECRET" || err.code === "EFOREIGN") {
|
||
return res.status(403).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
if (["EBADPATH", "ENOFILE", "ETOOBIG"].includes(err.code)) {
|
||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
return res.status(500).json({ error: { code: "EREAD", message: "could not read the file" } });
|
||
}
|
||
// RFC 6266: an ASCII fallback plus the exact UTF-8 name.
|
||
const ascii = file.name.replace(/[^\x20-\x7e]|["\\]/g, "_");
|
||
res.set({
|
||
"Content-Type": "application/octet-stream",
|
||
"Content-Length": String(file.size),
|
||
"Content-Disposition": `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||
"X-Content-Type-Options": "nosniff",
|
||
"Cache-Control": "no-store",
|
||
});
|
||
file.stream.on("error", () => res.destroy());
|
||
file.stream.pipe(res);
|
||
});
|
||
|
||
/**
|
||
* Upload one file into a folder of the workspace (`?dir=` — "" is its root —
|
||
* and the `X-File-Name` header, URI-encoded; the body is the raw bytes). It
|
||
* only CREATES (409 EEXISTS for a taken name), writes into an existing folder
|
||
* only, never under `.git`, as the folder's member under S3 —
|
||
* lane-file-transfer.js. Same-origin, like every lane write.
|
||
*/
|
||
const uploadBody = (req, res, next) =>
|
||
express.raw({ type: () => true, limit: UPLOAD_MAX_BYTES })(req, res, (err) => {
|
||
if (!err) return next();
|
||
const big = err.type === "entity.too.large";
|
||
res.status(big ? 413 : 400).json({
|
||
error: big
|
||
? { code: "ETOOBIG", message: "a file may be at most 25 MB" }
|
||
: { code: "EBADBODY", message: "could not read the upload" },
|
||
});
|
||
});
|
||
router.post("/:id/git/upload", sameOriginGuard, uploadBody, async (req, res) => {
|
||
const t = await gitTargetOr404(req, res);
|
||
if (!t) return;
|
||
let name = String(req.headers["x-file-name"] || "");
|
||
try {
|
||
name = decodeURIComponent(name);
|
||
} catch {
|
||
/* a header that is not URI-encoded is used as sent */
|
||
}
|
||
try {
|
||
const saved = await saveUpload(
|
||
t.cwd,
|
||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||
name,
|
||
Buffer.isBuffer(req.body) ? req.body : null
|
||
);
|
||
res.status(201).json(saved);
|
||
} catch (err) {
|
||
const status =
|
||
{ ESECRET: 403, EFOREIGN: 403, EEXISTS: 409, ETOOBIG: 413, EMEMBERGONE: 403 }[err.code] ??
|
||
(["EBADPATH", "EBADNAME", "ENOFILE", "EBADCONTENT"].includes(err.code) ? 400 : 500);
|
||
res.status(status).json({ error: { code: err.code || "EWRITE", message: err.message } });
|
||
}
|
||
});
|
||
|
||
/** Tracked paths matching `?q=`, for quick-open. */
|
||
router.get("/:id/git/ls", async (req, res) => {
|
||
const t = await gitTargetOr404(req, res);
|
||
if (!t) return;
|
||
const { cwd } = t;
|
||
try {
|
||
res.json({ available: true, ...(await listPaths(cwd, { q: req.query.q })) });
|
||
} catch {
|
||
res.json({ available: false, paths: [], truncated: false, total: 0 });
|
||
}
|
||
});
|
||
|
||
/** Every level of the tracked tree in one read, for "expand all folders". */
|
||
router.get("/:id/git/tree-all", async (req, res) => {
|
||
const t = await gitTargetOr404(req, res);
|
||
if (!t) return;
|
||
const { cwd } = t;
|
||
try {
|
||
res.json({ available: true, ...(await listTreeAll(cwd)) });
|
||
} catch {
|
||
res.json({ available: false, levels: {}, truncated: false });
|
||
}
|
||
});
|
||
|
||
/** One directory level of the explorer tree (tracked + untracked-not-ignored), status-decorated. */
|
||
router.get("/:id/git/tree", async (req, res) => {
|
||
const t = await gitTargetOr404(req, res);
|
||
if (!t) return;
|
||
const { lane, cwd } = t;
|
||
try {
|
||
const subdir = typeof req.query.path === "string" ? req.query.path : "";
|
||
res.json({ available: true, ...(await listTree(cwd, { subdir })) });
|
||
} catch (err) {
|
||
if (err.code === "EBADPATH") {
|
||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
res.json({ available: false, path: "", entries: [], truncated: false });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* The lane's own local branches, for the branch switcher. Separate from
|
||
* `GET /branches` (which takes a candidate repo PATH from the query, for a
|
||
* lane that does not exist yet); this one resolves the repo from the lane row,
|
||
* so no path ever comes from the request.
|
||
*/
|
||
router.get("/:id/git/branches", async (req, res) => {
|
||
const t = await gitTargetOr404(req, res);
|
||
if (!t) return;
|
||
const { lane, cwd } = t;
|
||
try {
|
||
const { branches, current } = await listBranches(cwd);
|
||
const worktrees = await listWorktrees(cwd).catch(() => []);
|
||
res.json({ available: true, branches, current, worktrees });
|
||
} catch {
|
||
res.json({ available: false, branches: [], current: null, worktrees: [] });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* The git WRITES the dashboard offers — the list is `ACTIONS` in
|
||
* server/lib/git-ops.js (sync, stage/discard, commit/amend/undo-commit,
|
||
* branches, merge, cherry-pick, revert).
|
||
* Guarded (they mutate), serialized per git index (`withLaneLock` keyed on
|
||
* the worktree's git dir, so a claude and a codex lane on one directory share
|
||
* it) so a push and a commit fired from two tabs or two lanes cannot
|
||
* interleave on one index, and thin — every
|
||
* rule about what each action may do lives in server/lib/git-ops.js, not here.
|
||
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
|
||
*/
|
||
/**
|
||
* A commit message suggested by the user's `claude` CLI for what is STAGED
|
||
* (server/lib/commit-message.js). Not a git write — it only reads the index —
|
||
* but same-origin, because each call spends the user's Claude quota.
|
||
* Registered before `/:id/git/:action`, which would answer EBADACTION.
|
||
*/
|
||
router.post("/:id/git/commit-message", sameOriginGuard, async (req, res) => {
|
||
const t = await gitTargetOr404(req, res);
|
||
if (!t) return;
|
||
try {
|
||
const paths = Array.isArray(req.body?.paths) ? req.body.paths : undefined;
|
||
res.json({ message: await suggestCommitMessage(t.cwd, { paths }) });
|
||
} catch (err) {
|
||
const status = ["ENOSTAGED", "ENOTOOL", "EBADPATH"].includes(err.code)
|
||
? 400
|
||
: err.code === "EAI"
|
||
? 502
|
||
: 500;
|
||
res.status(status).json({ error: { code: gitErrorCode(err), message: err.message } });
|
||
}
|
||
});
|
||
|
||
router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||
const t = await gitTargetOr404(req, res);
|
||
if (!t) return;
|
||
const { lane, cwd } = t;
|
||
try {
|
||
// Keyed on the INDEX, not the lane id: a claude and a codex lane on one
|
||
// cwd — or a root lane and a subdirectory lane of one repo — share one
|
||
// index, and their writes would otherwise race on index.lock. The git dir
|
||
// is per worktree, so sibling worktree lanes still run in parallel; a
|
||
// cwd that is not a repo falls back to its real path.
|
||
const gitDir = await git(cwd, ["rev-parse", "--absolute-git-dir"])
|
||
.then((r) => r.stdout.trim())
|
||
.catch(() => fs.promises.realpath(cwd).catch(() => cwd));
|
||
const result = await withLaneLock(`git:${gitDir}`, () =>
|
||
runGitAction(cwd, req.params.action, req.body || {})
|
||
);
|
||
res.json(result);
|
||
} catch (err) {
|
||
const badRequest = [
|
||
"EBADACTION",
|
||
"EBADMESSAGE",
|
||
"EBADBRANCH",
|
||
"ENOBRANCH",
|
||
"EBRANCHEXISTS",
|
||
"EBRANCHBUSY",
|
||
"EDIRTY",
|
||
"ENOCHANGES",
|
||
"EBADPATH",
|
||
"ECONFIRM",
|
||
"EPROTECTED",
|
||
"ESTAGEDOUTSIDE",
|
||
"ENOREMOTE",
|
||
"EUNTRACKED",
|
||
"EBADHOST",
|
||
"EBADCRED",
|
||
"EMEMBERCRED",
|
||
"ESUBDIR",
|
||
"EBADSIDE",
|
||
"EMARKERS",
|
||
"EBADURL",
|
||
"EREMOTEEXISTS",
|
||
"ENOCOMMITS",
|
||
"EBADSHA",
|
||
"EMERGECOMMIT",
|
||
"ENOPARENT",
|
||
"ETRACKED",
|
||
];
|
||
// A numeric code is git's own exit status (execFile reports one that way):
|
||
// git ran and refused — a rejected push, a failed credential, a diverged
|
||
// pull. That is the repository's state answering a valid request, not a
|
||
// server fault, so it is a 409 under the string code the client expects.
|
||
const exited = typeof err.code === "number";
|
||
// EHUNKGONE is the same kind of answer: the tree moved under the click.
|
||
const status = badRequest.includes(err.code)
|
||
? 400
|
||
: exited ||
|
||
[
|
||
"EHUNKGONE",
|
||
"EGITAUTH",
|
||
"EMERGING",
|
||
"ENOMERGE",
|
||
"ECONFLICTS",
|
||
"EPUSHED",
|
||
"EEMPTY",
|
||
"ESTALE",
|
||
"EMEMBERGONE",
|
||
].includes(err.code)
|
||
? 409
|
||
: 500;
|
||
// git's own stderr is the useful half of a failed push or pull (a
|
||
// rejection, a missing credential, a diverged branch), so it joins the
|
||
// message rather than riding a side field the client would have to know
|
||
// about — `message` is the one thing every caller already reads.
|
||
const detail = err.git ? (err.git.stderr || "").trim() : "";
|
||
res.status(status).json({
|
||
error: {
|
||
code: gitErrorCode(err),
|
||
message: detail ? `${err.message}\n${detail}` : err.message,
|
||
// EGITAUTH: which host wants a login, for the git bar's form. Never
|
||
// a secret — just protocol, host, a username hint and `rejected`.
|
||
...(err.auth ? { auth: err.auth } : {}),
|
||
},
|
||
});
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Claude Desktop's "archive a worktree session": remove the git worktree a
|
||
* session of this workspace ran in (`claude --worktree`), then its branch when
|
||
* git agrees it is merged. The session is named, never a path; the tree comes
|
||
* from `sessionWorktree`. Refused while a live pane holds the session
|
||
* (`ELIVE`), and every refusal git itself makes — a dirty tree, an unmerged
|
||
* branch (kept and reported) — stands: nothing here forces.
|
||
*/
|
||
router.post("/:id/session-worktree/remove", sameOriginGuard, async (req, res) => {
|
||
const lane = laneOr404(req, res);
|
||
if (!lane) return;
|
||
const sessionId = typeof req.body?.session === "string" ? req.body.session : "";
|
||
const held = runs
|
||
.listRuns()
|
||
.some((r) => r.laneId === lane.id && r.status === "running" && r.sessionId === sessionId);
|
||
if (held) {
|
||
return res.status(409).json({
|
||
error: { code: "ELIVE", message: "a pane is still running that session — exit it first" },
|
||
});
|
||
}
|
||
try {
|
||
res.json(await removeSessionWorktree(lane, sessionId, { confirm: req.body?.confirm }));
|
||
} catch (err) {
|
||
// git ran and refused (a dirty tree): the repository answering, a 409.
|
||
const refused = !!err.git;
|
||
const status = ["ECONFIRM", "ENOWORKTREE"].includes(err.code)
|
||
? 400
|
||
: err.code === "ELOCKED" || refused
|
||
? 409
|
||
: 500;
|
||
const detail = refused ? (err.git.stderr || "").trim() : "";
|
||
res.status(status).json({
|
||
error: {
|
||
code: refused ? "EGIT" : err.code || "EGIT",
|
||
message: detail ? `${err.message}\n${detail}` : err.message,
|
||
},
|
||
});
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 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 || {};
|
||
|
||
// Checked before any git work: a worktree for a tool that cannot get a lane
|
||
// would be left behind with no row pointing at it.
|
||
try {
|
||
lanesLib.validateTool(body.tool === undefined ? "claude" : body.tool);
|
||
} catch (err) {
|
||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
|
||
const sourceRepo = body.sourceRepo;
|
||
// Before any git work (which would already run as the member).
|
||
if (
|
||
typeof sourceRepo === "string" &&
|
||
path.isAbsolute(sourceRepo) &&
|
||
refuseS3Folder(res, lanesLib.canonicalCwd(sourceRepo))
|
||
) {
|
||
return;
|
||
}
|
||
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",
|
||
},
|
||
});
|
||
}
|
||
if (!scope.folderAllowed(req, lanesLib.canonicalCwd(sourceRepo))) return refuseFolder(res);
|
||
if (scope.folderTakenByOther(req, lanesLib.canonicalCwd(sourceRepo))) return refuseOwned(res);
|
||
|
||
// A repo with no commit has nothing a worktree could branch from: git would
|
||
// fail later, in the background job, and leave a `failed` row behind. Refuse
|
||
// it here instead — that repo is a Repo lane until its first commit.
|
||
if (!(await listBranches(sourceRepo)).branches.length) {
|
||
const unborn = await git(sourceRepo, ["rev-parse", "--verify", "HEAD"]).then(
|
||
() => false,
|
||
() => true
|
||
);
|
||
if (unborn) {
|
||
return res.status(400).json({
|
||
error: {
|
||
code: "EEMPTYREPO",
|
||
message: "sourceRepo has no commits yet — add it as a Repo workspace and commit first",
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
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",
|
||
owner_id: scope.ownerFor(req),
|
||
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 workspace 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", "workspace state changed since preflight");
|
||
err.expected = expected;
|
||
err.current = current;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
function wait(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
/** Every tmux session a lane holds: its recorded `run_id` plus the extra
|
||
* split-pane sessions (`agenthub-lane-<id>-<slot>`), which never touch it. */
|
||
function laneRunIds(lane) {
|
||
const ids = new Set(
|
||
runs
|
||
.listRuns()
|
||
.filter((r) => r.laneId === lane.id)
|
||
.map((r) => r.id)
|
||
);
|
||
// Only this lane's own panes: a run_id naming another lane's is ignored.
|
||
if (lane.run_id && new RegExp(`^agenthub-lane-${lane.id}(-\\d+)?$`).test(lane.run_id)) {
|
||
ids.add(lane.run_id);
|
||
}
|
||
return [...ids];
|
||
}
|
||
|
||
/** Kill a lane's runs and wait for their tmux sessions to exit before touching its cwd. */
|
||
async function stopLaneRun(lane) {
|
||
const ids = laneRunIds(lane);
|
||
if (ids.length === 0) return;
|
||
for (const id of ids) {
|
||
try {
|
||
runs.killRun(id);
|
||
} catch {
|
||
/* a concurrently completed run is already safe */
|
||
}
|
||
}
|
||
|
||
const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS;
|
||
for (const id of ids) {
|
||
let run = runs.getRun(id);
|
||
while (run && run.status !== "gone") {
|
||
if (Date.now() >= deadline) {
|
||
throw lifecycleError(
|
||
"ERUNTIMEOUT",
|
||
`workspace run ${id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
|
||
);
|
||
}
|
||
await wait(RUN_EXIT_POLL_MS);
|
||
run = runs.getRun(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: gitErrorCode(err), 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 },
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The string `error.code` for an error that may have come out of `git()`.
|
||
* When git exits non-zero, execFile sets `err.code` to the exit STATUS (a
|
||
* number, e.g. 128); the client branches on string codes, so that becomes
|
||
* `EGIT`. A missing code (a timeout kill) is `EGIT` too; string codes pass.
|
||
*/
|
||
function gitErrorCode(err) {
|
||
return typeof err.code === "string" && err.code ? err.code : "EGIT";
|
||
}
|
||
|
||
/** Resolve `:id` or answer 404. Returns null once the response has been sent. */
|
||
/**
|
||
* Which tree a git route works in: the workspace's own folder, or — with
|
||
* `?session=<id>` — that session's worktree (`claude --worktree`), resolved by
|
||
* `sessionWorktree` from the session ROW (owned by this workspace, registered
|
||
* by `git worktree list`), or — with `?source=<id>` — one of the workspace's
|
||
* extra folders, resolved by `sourceFolder` from its `lane_sources` ROW; never
|
||
* from a path in the request. An unresolvable session (400) or source (404,
|
||
* like another workspace's row) is never a silent fall back to the main
|
||
* folder: a commit or a discard must never land in the wrong tree.
|
||
*/
|
||
async function gitTargetOr404(req, res) {
|
||
const lane = laneOr404(req, res);
|
||
if (!lane) return null;
|
||
if (req.query.source !== undefined) {
|
||
try {
|
||
const cwd = sourceFolder(lane.id, req.query.source);
|
||
return { lane, cwd, worktree: null, source: true };
|
||
} catch (err) {
|
||
res.status(404).json({ error: { code: err.code, message: err.message } });
|
||
return null;
|
||
}
|
||
}
|
||
const session = req.query.session;
|
||
if (session === undefined) return { lane, cwd: lane.cwd, worktree: null };
|
||
try {
|
||
const wt = await sessionWorktree(lane, String(session));
|
||
return { lane, cwd: wt.path, worktree: { path: wt.path, branch: wt.branch } };
|
||
} catch (err) {
|
||
res.status(400).json({ error: { code: err.code, message: err.message } });
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function laneOr404(req, res) {
|
||
const lane = lanesLib.getLane(req.params.id);
|
||
if (!lane) {
|
||
res.status(404).json({ error: { code: "ENOLANE", message: "workspace 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, s3Guard, (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, s3Guard, 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, s3Guard, (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: "workspace 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, s3Guard, 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: "workspace 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", "workspace not found");
|
||
|
||
await stopLaneRun(lockedLane);
|
||
// A running stack holds files open in the very directory `reset` is about
|
||
// to rewrite, and either way its processes would outlive the lane still
|
||
// bound to its ports. Stop it before touching git or dropping the row.
|
||
// 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);
|
||
// Only `reset` can lose committed work now — `remove` forgets the row and
|
||
// touches neither the worktree nor the branch, so unpushed commits are
|
||
// nothing it can discard.
|
||
if (action === "reset" && 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") {
|
||
// `remove` forgets the lane, it does not destroy it. The worktree
|
||
// directory, its branch and the lane's own database(s) are the USER's
|
||
// data and outlive the dashboard's record of them — one mis-click here
|
||
// used to cost a whole branch's unpushed code, and no dashboard row is
|
||
// worth that. Deleting any of it is the user's own call, by hand
|
||
// (`git worktree remove` / `git branch -d` / dropping the db).
|
||
//
|
||
// Runtime bookkeeping is the exception, because it is AgentHub's and
|
||
// not the user's: 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);
|
||
}
|
||
}
|
||
|
||
// The pane a `start` targets: 0 is the lane's own session (`run_id`), 1–3
|
||
// are the Workspace split panes' extra sessions on the same lane.
|
||
let startedRunId = null;
|
||
try {
|
||
switch (action) {
|
||
case "start": {
|
||
// A folder that is gone (or never was) would start the pane in the
|
||
// account's home instead, under this workspace's name. EACCES is not
|
||
// "gone": a member's folder may be unreadable to this process.
|
||
try {
|
||
if (!fs.statSync(lane.cwd).isDirectory())
|
||
throw Object.assign(new Error(), { code: "ENOTDIR" });
|
||
} catch (err) {
|
||
// A member's new project folder is made for them, as them.
|
||
const made = err.code === "ENOENT" && require("../lib/run-as").makeMemberFolder(lane.cwd);
|
||
if (!made && (err.code === "ENOENT" || err.code === "ENOTDIR")) {
|
||
return res.status(409).json({
|
||
error: {
|
||
code: "ENOCWD",
|
||
message: `the workspace folder does not exist: ${lane.cwd}`,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
// `"auto"`: the first free tab, picked under the lane lock below — a
|
||
// client's own pick comes from a list that may already be stale.
|
||
const auto = body.slot === "auto";
|
||
let slot = body.slot === undefined || auto ? 0 : body.slot;
|
||
if (!Number.isInteger(slot) || slot < 0 || slot > runs.MAX_PANE_SLOT) {
|
||
return res.status(400).json({
|
||
error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` },
|
||
});
|
||
}
|
||
const isLive = (laneId, s) =>
|
||
runs.getRun(runs.runIdForLane(laneId, s))?.status === "running";
|
||
// Under the lock: the free slot, or null when every tab is busy.
|
||
const pickSlot = (laneId) => {
|
||
if (!auto) return slot;
|
||
for (let s = 0; s <= runs.MAX_PANE_SLOT; s++) if (!isLive(laneId, s)) return s;
|
||
return null;
|
||
};
|
||
const allBusy = () =>
|
||
res.status(409).json({
|
||
error: { code: "ERUNLIVE", message: "every tab of this workspace is busy" },
|
||
});
|
||
// A Terminal tab: a plain shell in the slot. No agent is at work in
|
||
// it, so the lane's run_id/status stay as they are.
|
||
if (body.shell === true) {
|
||
const shellOutcome = await withLaneLock(lane.id, async () => {
|
||
const current = lanesLib.getLane(lane.id);
|
||
if (!current) return { missing: true };
|
||
const picked = pickSlot(current.id);
|
||
if (picked === null) return { allBusy: true };
|
||
slot = picked;
|
||
if (isLive(current.id, slot)) return { conflict: true };
|
||
startedRunId = runs.spawnShell({ laneId: current.id, slot, cwd: current.cwd }).id;
|
||
return {};
|
||
});
|
||
if (shellOutcome.missing) {
|
||
return res
|
||
.status(404)
|
||
.json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||
}
|
||
if (shellOutcome.allBusy) return allBusy();
|
||
if (shellOutcome.conflict) {
|
||
return res.status(409).json({
|
||
error: { code: "ERUNLIVE", message: "that tab already has a live session" },
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
// A session that ran in a worktree of its own resumes (or forks) THERE:
|
||
// Claude finds a session by the folder it ran in. Resolved from the
|
||
// session row, before the lock; anything else starts as it always did.
|
||
let resumeWorktree = null;
|
||
if (typeof body.resumeSessionId === "string" && body.resumeSessionId) {
|
||
resumeWorktree = await sessionWorktree(lane, body.resumeSessionId).catch(() => null);
|
||
const row = resumeWorktree
|
||
? null
|
||
: db
|
||
.prepare("SELECT cwd, lane_id FROM sessions WHERE id = ?")
|
||
.get(body.resumeSessionId);
|
||
// One of this workspace's worktree sessions whose worktree is gone
|
||
// (Claude removed it on exit): there is no folder to resume it in.
|
||
if (
|
||
row &&
|
||
row.lane_id === lane.id &&
|
||
/\/\.claude\/worktrees\/[^/]+\/?$/.test(row.cwd || "")
|
||
) {
|
||
return res.status(400).json({
|
||
error: { code: "ENOWORKTREE", message: "that session's worktree no longer exists" },
|
||
});
|
||
}
|
||
}
|
||
// The SESSION's AI tool: a resume keeps the CLI its session ran on
|
||
// (`sessions.tool`); a new session takes the catalog id the request
|
||
// names — known and enabled, only an id, never a command — and the
|
||
// workspace's own tool otherwise.
|
||
const resumeId = body.resumeSessionId || (body.resume ? lane.session_id : null);
|
||
let sessionTool = null;
|
||
if (resumeId) {
|
||
sessionTool =
|
||
db.prepare("SELECT tool FROM sessions WHERE id = ?").get(resumeId)?.tool || null;
|
||
} else if (body.tool !== undefined) {
|
||
try {
|
||
if (typeof body.tool !== "string") {
|
||
throw Object.assign(new Error("tool must be a string"), { code: "EBADTOOL" });
|
||
}
|
||
lanesLib.validateTool(body.tool); // EBADTOOL, EDISABLED
|
||
} catch (err) {
|
||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
sessionTool = body.tool;
|
||
}
|
||
// 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 };
|
||
// A session holds ONE pane: a second `--resume` of it would run two
|
||
// agents writing one transcript. Its pane is named instead. A fork
|
||
// makes a new session, so it is no duplicate.
|
||
const resumeTarget = body.resumeSessionId || (body.resume ? current.session_id : null);
|
||
if (resumeTarget && body.forkSession !== true) {
|
||
const holder = runs
|
||
.listRuns()
|
||
.find((r) => r.status === "running" && r.sessionId === resumeTarget);
|
||
if (holder) return { sessionLive: holder.id };
|
||
}
|
||
const picked = pickSlot(current.id);
|
||
if (picked === null) return { allBusy: true };
|
||
slot = picked;
|
||
// By the pane's own name, slot 0 too: a terminal tab in pane 0 is no
|
||
// run, so `run_id` does not name it, and a start there would take it over.
|
||
const live = runs.getRun(runs.runIdForLane(current.id, slot || 0));
|
||
if (live && live.status === "running") {
|
||
return { conflict: true };
|
||
}
|
||
const handle = runs.spawnRun({
|
||
laneId: current.id,
|
||
slot,
|
||
initialPrompt: body.prompt || "",
|
||
cwd: resumeWorktree ? resumeWorktree.path : current.cwd,
|
||
inWorktree: !!resumeWorktree,
|
||
// A NEW session in a worktree of its own (`claude --worktree`).
|
||
worktree: body.worktree === true && !body.resumeSessionId,
|
||
tool: sessionTool || current.tool || "claude",
|
||
// The SESSION's AI Proxy, by name; a resume that names none goes
|
||
// back to the one its session last ran on. `router` is the
|
||
// pre-rename field, still taken from an older client.
|
||
proxy:
|
||
(body.proxy ?? body.router) !== undefined
|
||
? (body.proxy ?? body.router)
|
||
: resumeProxy(
|
||
body.resumeSessionId || (body.resume ? current.session_id : null),
|
||
current.owner_id ?? null
|
||
),
|
||
model: body.model,
|
||
permissionMode: body.permissionMode,
|
||
effort: body.effort,
|
||
sandbox: body.sandbox,
|
||
approval: body.approval,
|
||
resumeLast: body.resumeLast === true,
|
||
bypass: body.bypass === true,
|
||
resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined),
|
||
forkSession: body.forkSession === true,
|
||
});
|
||
startedRunId = handle.id;
|
||
// An extra pane's session is not the lane's run: `run_id` stays the
|
||
// pane-0 session every other path (reset, stop) names.
|
||
if (!slot) lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
|
||
return {};
|
||
});
|
||
if (outcome.missing) {
|
||
return res
|
||
.status(404)
|
||
.json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||
}
|
||
if (outcome.sessionLive) {
|
||
return res.status(409).json({
|
||
error: {
|
||
code: "ESESSIONLIVE",
|
||
message: "that session is already open in a tab",
|
||
run_id: outcome.sessionLive,
|
||
},
|
||
});
|
||
}
|
||
if (outcome.allBusy) return allBusy();
|
||
if (outcome.conflict) {
|
||
return res
|
||
.status(409)
|
||
.json({ error: { code: "ERUNLIVE", message: "that tab already has a live session" } });
|
||
}
|
||
break;
|
||
}
|
||
case "stop": {
|
||
// A lane with no live run is already stopped — say so, don't 500.
|
||
for (const id of laneRunIds(lane)) {
|
||
try {
|
||
runs.killRun(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 workspace's run is no longer supported via REST — open the workspace's terminal in Workspace and type directly (attaches over WebSocket to the same tmux session)",
|
||
},
|
||
});
|
||
}
|
||
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 } });
|
||
}
|
||
// A refused field (EBADMODEL, EBADEFFORT, …) or a thing the lane's tool
|
||
// cannot do (EUNSUPPORTED: a fork on a non-Claude lane) is the request's fault.
|
||
// ENOTSHARED (S3): the tool is not under /opt/agenthub/tools yet — a setup state.
|
||
const status = /^(EBAD|EUNSUPPORTED$)/.test(err.code || "")
|
||
? 400
|
||
: ["ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
|
||
? 409
|
||
: 500;
|
||
return res.status(status).json({ error: { code: err.code, message: err.message } });
|
||
}
|
||
|
||
broadcastLane(lane.id);
|
||
res.json({
|
||
lane: payload(lanesLib.getLane(lane.id)),
|
||
...(startedRunId ? { run_id: startedRunId } : {}),
|
||
});
|
||
});
|
||
|
||
router.delete("/:id", sameOriginGuard, (req, res) => {
|
||
const ok = lanesLib.deleteLane(req.params.id);
|
||
if (!ok)
|
||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||
broadcast("lane_update", { removed: Number(req.params.id) });
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
module.exports = router;
|
||
module.exports.broadcastLane = broadcastLane;
|
||
module.exports.healAllRunIds = healAllRunIds;
|
||
module.exports.broadcastForeignLaneChanges = broadcastForeignLaneChanges;
|