/** * @file OpenAPI fragment for dashboard-managed git worktree lane provisioning * and the confirmed, preflight-guarded reset, remove, and purge lifecycle API. * It documents the asynchronous provisioning and destructive action contracts * for the built-in Swagger and ReDoc surfaces, plus the read-only * `LaneStageDetectionFields` schema for the stage-detection fields every lane * response carries, and the idempotent `POST /api/lanes/ensure` lookup the * Workspace page opens a directory with. * @author Nguyễn Ngọc Trí Vĩ */ const tags = [ { name: "Lanes", description: "Durable parallel-work lanes and dashboard-managed git worktrees", }, ]; const schemas = { LaneWorktreeCreateRequest: { type: "object", required: ["sourceRepo"], properties: { sourceRepo: { type: "string", description: "Existing absolute path to the source git repository.", example: "/Users/me/src/project", }, title: { type: "string", description: "Human-readable lane title.", example: "Criteria form", }, base: { type: "string", description: "Preferred base branch. Defaults to the LANE_BASE_BRANCH env var, or `main` when that is also unset.", example: "main", }, slug: { type: "string", description: "Optional branch/directory slug override.", example: "criteria-form", }, }, }, LaneEnsureRequest: { type: "object", required: ["cwd"], properties: { cwd: { type: "string", description: "Absolute working directory to find or adopt a lane for. A lane whose own cwd is this path, or the closest path-boundary parent of it, is returned as-is.", example: "/Users/me/src/project/packages/app", }, title: { type: "string", description: "Title for the lane if one has to be created; ignored when one already exists.", example: "App package", }, }, }, LaneDestructiveActionRequest: { type: "object", required: ["confirm", "expect"], properties: { confirm: { type: "boolean", enum: [true] }, force: { type: "boolean", description: "Required by reset/remove when unpushed commits exist.", }, expect: { type: "object", description: "Required complete facts returned by the preceding preflight: head, dirty, untracked, unpushed for reset/remove; sessions, events, tokenRows for purge. Differences return 409 ESTALE without destructive work.", additionalProperties: true, }, }, }, LaneStageDetectionFields: { type: "object", description: "Fields the server's stage-detection heuristic (server/lib/stage-detect.js) adds to every lane returned by GET /api/lanes and GET /api/lanes/:id. An inferred stage is never evidence and never renders as done — see docs/LANES.md#stage-detection.", properties: { detected_stage: { type: "string", nullable: true, description: "Stage id inferred from ingested tool events, or null if no signal has been seen. Independent of the agent's own declared `stage`.", }, detected_signal: { type: "string", nullable: true, description: "The tool-event signal that produced detected_stage, capped at 120 characters; null when detected_stage is null.", }, detected: { type: "boolean", description: "Present on each entry of pipeline_nodes. True for the inferred node and any node before it that carries no declared record; decorates that node's state without ever upgrading it to done.", }, }, }, }; const paths = { "/api/lanes/ensure": { post: { tags: ["Lanes"], summary: "Find or adopt the lane owning a working directory", description: "Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200), otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.", operationId: "ensureLane", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/LaneEnsureRequest" } }, }, }, responses: { 200: { description: "An existing lane already owns that cwd.", content: { "application/json": { schema: { type: "object", required: ["lane", "created"], properties: { lane: { type: "object", additionalProperties: true }, created: { type: "boolean", enum: [false] }, }, }, }, }, }, 201: { description: "No lane owned that cwd, so an adopted one was created.", content: { "application/json": { schema: { type: "object", required: ["lane", "created"], properties: { lane: { type: "object", additionalProperties: true }, created: { type: "boolean", enum: [true] }, }, }, }, }, }, 400: { description: "cwd is missing or not an absolute path (EBADCWD).", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, }, }, 403: { description: "The browser request was not same-origin/loopback.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, }, }, }, }, }, "/api/lanes/worktree": { post: { tags: ["Lanes"], summary: "Provision a managed git worktree lane", description: "Validates the absolute source repository, creates a managed lane in `provisioning` state, and returns immediately. Worktree creation continues under the lane lock; the existing `lane_update` broadcast reports either `idle` or `failed` with git stderr in `notes`.", operationId: "createLaneWorktree", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/LaneWorktreeCreateRequest" }, }, }, }, responses: { 202: { description: "Managed lane accepted for background provisioning.", content: { "application/json": { schema: { type: "object", required: ["lane"], properties: { lane: { type: "object", additionalProperties: true } }, }, }, }, }, 400: { description: "sourceRepo is relative, missing, or not a git repository.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, }, }, 403: { description: "The browser request was not same-origin/loopback.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, }, }, 409: { description: "The computed worktree directory already belongs to a lane, or no unique directory was available after 50 attempts.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } }, }, }, }, }, }, "/api/lanes/{id}": { patch: { tags: ["Lanes"], summary: "Partially update a lane", description: "Updates lane fields, including run_id; browser requests must pass the loopback same-origin guard. The provisioning-time facts kind, source_repo, slug and base_branch are NOT patchable — kind is check 1 of the destroy guard — and are silently ignored here.", operationId: "updateLane", parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], requestBody: { required: true, content: { "application/json": { schema: { type: "object", additionalProperties: true }, }, }, }, responses: { 200: { description: "Updated lane." }, 400: { description: "kind was not one of adopted|managed (EBADKIND)." }, 403: { description: "The browser request was not same-origin/loopback." }, 404: { description: "Lane not found." }, }, }, }, "/api/lanes/{id}/preflight": { get: { tags: ["Lanes"], summary: "Count facts before a destructive lane action", operationId: "preflightLaneAction", parameters: [ { name: "id", in: "path", required: true, schema: { type: "integer" } }, { name: "action", in: "query", required: true, schema: { type: "string", enum: ["reset", "remove", "purge"] }, }, ], responses: { 200: { description: "Current counted facts for the selected action. reset/remove additionally return blocked[] (hard blockers: adopted, missing, unreadable, unpushed-commits — the only one force overrides) and warnings[] (informational only, e.g. no-remote).", }, 400: { description: "Unknown action." }, 404: { description: "Lane not found." }, }, }, }, "/api/lanes/{id}/git": { get: { tags: ["Lanes"], summary: "A lane's working-copy facts", description: "Branch, short HEAD, that commit's subject, and the uncommitted counts for the lane's cwd. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it shells out to git five times and that payload is polled and re-broadcast on every lane_update. A cwd that is missing, is not a git repository, or makes git fail returns available:false with HTTP 200 — a lane pointing at a plain directory is a normal state, not a fault.", operationId: "getLaneGitFacts", parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], responses: { 200: { description: "available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).", }, 404: { description: "Lane not found." }, }, }, }, "/api/lanes/{id}/runtime": { get: { tags: ["Lanes"], summary: "A lane's own application stack", description: "Slot, ports, per-service liveness, log paths and the last boot error. Recomputed on every call from pid files and port probes rather than cached, because a process can die to OOM or a stray kill without telling anyone. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it opens a socket per declared port and stats every pid file. A lane whose repository declares no .agenthub/profile returns available:false with HTTP 200 — most lanes never run a stack, which is a normal state, not a fault.", operationId: "getLaneRuntime", parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], responses: { 200: { description: "available:false (no profile); available:true with provisioned:false (no slot yet); or the full facts with slot, ports, services, up, healthy, steppedAside, logs and lastError.", }, 404: { description: "Lane not found." }, }, }, }, "/api/lanes/{id}/up": { post: { tags: ["Lanes"], summary: "Boot a lane's stack", description: "Runs the profile's boot then health hooks. Returns 202 and finishes in the background because a build can take minutes; progress streams as lane_hook_output and the attempt ends with a lane_runtime message. Does NOT run bootstrap — installing dependencies on every boot would make a routine restart minutes long. A failing health check leaves the processes running, because their logs are what identify the service that never came up. Writes only slot and ports on the lane row: never stage, status or notes.", operationId: "upLane", parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], requestBody: { required: false, content: { "application/json": { schema: { type: "object", properties: { build: { type: "boolean", default: true, description: "false passes --no-build to the boot hook, reusing an existing build.", }, }, }, }, }, }, responses: { 202: { description: "Accepted; the boot runs in the background." }, 400: { description: "ENOPROFILE — no .agenthub/profile, with the paths searched." }, 403: { description: "The browser request was not same-origin/loopback." }, 404: { description: "Lane not found." }, 409: { description: "ESLOTS (every slot taken) or EPORTBUSY (with the occupying pids)." }, }, }, }, "/api/lanes/{id}/down": { post: { tags: ["Lanes"], summary: "Stop a lane's stack", description: "Kills each recorded pid tree bottom-up (killing a parent first reparents its children to init, where nothing knows to look for them), then sweeps listeners on the lane's ports ONLY when a pid file existed — a lane whose stack is already down still owns its port numbers, and an unconditional sweep would kill a server the user started there. Idempotent, and a no-op for a lane that was never up.", operationId: "downLane", parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], responses: { 200: { description: "{ok, killed: number[], runtime}." }, 403: { description: "The browser request was not same-origin/loopback." }, 404: { description: "Lane not found." }, }, }, }, "/api/lanes/{id}/hook/{name}": { post: { tags: ["Lanes"], summary: "Run one of the lane profile's hooks", description: "The surface a driving session uses for ci-gate, e2e, migrate and friends. Returns 202; output streams as lane_hook_output and completion arrives as lane_hook_result with the exit code. The name is checked against a fixed allowlist BEFORE anything is spawned, and args travels as an array of strings straight into argv — neither is ever joined into a command string.", operationId: "runLaneHook", parameters: [ { name: "id", in: "path", required: true, schema: { type: "integer" } }, { name: "name", in: "path", required: true, schema: { type: "string", enum: [ "bootstrap", "boot", "health", "migrate", "seed", "ci-gate", "e2e", "regen", "db-create", "db-drop", ], }, }, ], requestBody: { required: false, content: { "application/json": { schema: { type: "object", properties: { args: { type: "array", items: { type: "string" } } }, }, }, }, }, responses: { 202: { description: "Accepted; the hook runs in the background." }, 400: { description: "ENOHOOK (name outside the allowlist) or ENOPROFILE." }, 403: { description: "The browser request was not same-origin/loopback." }, 404: { description: "Lane not found." }, 409: { description: "ENOSLOT — the lane has no runtime yet; bring it up first." }, }, }, }, "/api/lanes/{id}/logs/{svc}": { get: { tags: ["Lanes"], summary: "Tail a lane's hook or service log", description: "The resolved path is confined to the lane's log directory after realpath, so a name from the request can never escape it. Read-only, so no same-origin guard.", operationId: "getLaneLog", parameters: [ { name: "id", in: "path", required: true, schema: { type: "integer" } }, { name: "svc", in: "path", required: true, schema: { type: "string" } }, { name: "tail", in: "query", required: false, schema: { type: "integer", default: 65536, maximum: 1048576 }, description: "Trailing bytes to return; capped at 1 MiB.", }, ], responses: { 200: { description: "{available, svc, size, truncated, text}, or {available:false} for a lane with no slot.", }, 404: { description: "Lane not found, or ENOLOG — no such log for this lane." }, }, }, }, "/api/lanes/branches": { get: { tags: ["Lanes"], summary: "A candidate source repo's local branches", description: "Feeds the Add-lane picker: given a repo path, returns its local branches (not origin/* refs — those aren't checkout-able into a new worktree without a fetch first) plus which one is currently checked out. Same repo validation as POST /api/lanes/worktree; a repo neither endpoint can resolve can't be provisioned from either. Read-only, no same-origin guard.", operationId: "listLaneBranches", parameters: [ { name: "repo", in: "query", required: true, schema: { type: "string" }, description: "Absolute path to an existing git repository.", }, ], responses: { 200: { description: "{branches: string[], current: string | null, worktrees: {path, branch?, locked?}[]}. The worktrees ride along so the picker can grey out branches git would refuse to check out twice.", }, 400: { description: "repo is missing, relative, does not exist, or is not a git repo." }, }, }, }, "/api/lanes/{id}/git/changes": { get: { tags: ["Lanes"], summary: "A lane's code changes, at one of three depths", description: "Backs the Workspace Changes panel. No extra params = a summary of every changed file; full=1 adds every file's hunks (review mode); path= returns that one file's hunks. range=work (default) is HEAD vs the working tree with untracked files included; range=branch is ...HEAD; range=commit needs sha= and shows that one commit. context= widens each hunk (default 3, clamped to 5000) — the viewer's expand button. Lines arrive with both gutters resolved. Capped at 300 files, 200 KB of patch per file and 1 MB per full read; a cap that bit sets `truncated`. Read-only, no same-origin guard.", operationId: "getLaneChanges", parameters: [ { name: "id", in: "path", required: true, schema: { type: "integer" } }, { name: "range", in: "query", schema: { type: "string", enum: ["work", "branch", "commit"], default: "work" }, }, { name: "sha", in: "query", schema: { type: "string" }, description: "range=commit only: the commit to show.", }, { name: "context", in: "query", schema: { type: "integer", default: 3, maximum: 5000 }, description: "Unchanged lines around each hunk.", }, { name: "path", in: "query", schema: { type: "string" }, description: "Return only this file, with its hunks.", }, { name: "full", in: "query", schema: { type: "string", enum: ["1", "true"] }, description: "Include every file's hunks (review mode).", }, ], responses: { 200: { description: "{available, range, base, baseRef, sha, context, files, truncated}, or {available:false} for a cwd git cannot read.", }, 400: { description: "ENOBASE (range=branch on a lane with no base branch), ENOFILECHANGE (path has no changes in this range), EBADRANGE or EBADREV.", }, 404: { description: "Lane not found." }, }, }, }, "/api/lanes/{id}/git/log": { get: { tags: ["Lanes"], summary: "The lane's recent commits", description: "Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path. Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.", operationId: "listLaneCommits", parameters: [ { name: "id", in: "path", required: true, schema: { type: "integer" } }, { name: "limit", in: "query", schema: { type: "integer", default: 30, maximum: 200 } }, { name: "file", in: "query", schema: { type: "string" } }, ], responses: { 200: { description: "{available, commits:[{sha, short, subject, author, date, refs}]}." }, 404: { description: "Lane not found." }, }, }, }, "/api/lanes/{id}/git/file": { get: { tags: ["Lanes"], summary: "One file's whole content at a revision", description: "rev=work (the file on disk, untracked included), HEAD, or a commit-ish. The `work` revision is the only one that reads the filesystem and it is realpath-confined to the lane directory. Binary content is reported, never returned. Capped at 1 MB. Read-only, no same-origin guard.", operationId: "readLaneFile", parameters: [ { name: "id", in: "path", required: true, schema: { type: "integer" } }, { name: "path", in: "query", required: true, schema: { type: "string" } }, { name: "rev", in: "query", schema: { type: "string", default: "work" } }, ], responses: { 200: { description: "{available, path, rev, binary, truncated, lines}." }, 400: { description: "EBADPATH, EBADREV or ENOFILE." }, 404: { description: "Lane not found." }, }, }, }, "/api/lanes/{id}/git/ls": { get: { tags: ["Lanes"], summary: "Tracked paths matching a substring (quick-open)", description: "Reads `git ls-files`, so ignored files never appear and no skip-list is maintained. Basename hits rank first. Capped at 500 paths. Read-only, no same-origin guard.", operationId: "listLanePaths", parameters: [ { name: "id", in: "path", required: true, schema: { type: "integer" } }, { name: "q", in: "query", schema: { type: "string" } }, ], responses: { 200: { description: "{available, paths, truncated, total}." }, 404: { description: "Lane not found." }, }, }, }, "/api/lanes/{id}/git/tree": { get: { tags: ["Lanes"], summary: "One directory level of tracked files", description: "The file explorer's expand-a-folder read: tracked entries under path=, folded into directories at that level. Read-only, no same-origin guard.", operationId: "listLaneTree", parameters: [ { name: "id", in: "path", required: true, schema: { type: "integer" } }, { name: "path", in: "query", schema: { type: "string", default: "" } }, ], responses: { 200: { description: "{available, path, entries:[{name, path, dir}], truncated}." }, 400: { description: "EBADPATH (the path escapes the lane)." }, 404: { description: "Lane not found." }, }, }, }, "/api/lanes/{id}/git/branches": { get: { tags: ["Lanes"], summary: "The lane repo's own local branches", description: "Feeds the lane's branch switcher. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.", operationId: "listLaneOwnBranches", parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], responses: { 200: { description: "{available, branches, current, worktrees}." }, 404: { description: "Lane not found." }, }, }, }, "/api/lanes/{id}/git/{action}": { post: { tags: ["Lanes"], summary: "One git write on the lane's working copy", description: 'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u origin HEAD, so only the branch the lane is on), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m , or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent\'s half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per lane under the lane lock. Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.', operationId: "runLaneGitAction", parameters: [ { name: "id", in: "path", required: true, schema: { type: "integer" } }, { name: "action", in: "path", required: true, schema: { type: "string", enum: [ "fetch", "pull", "push", "stage", "unstage", "discard", "commit", "switch", "branch-create", "branch-delete", ], }, }, ], requestBody: { required: false, content: { "application/json": { schema: { type: "object", properties: { message: { type: "string", description: "commit only; required, max 4000 chars." }, only: { type: "string", enum: ["staged"], description: "commit only; commit the index as it stands, with no add -A.", }, branch: { type: "string", description: "switch, branch-create and branch-delete.", }, base: { type: "string", description: "branch-create only; defaults to HEAD." }, create: { type: "boolean", description: "switch only; create the branch." }, paths: { type: "array", items: { type: "string" }, description: "stage, unstage, discard; each must be a path git reports changed.", }, confirm: { type: "boolean", description: "discard only; the call is refused without it.", }, }, }, }, }, }, responses: { 200: { description: "{action, ok:true, output} — git's own stdout+stderr." }, 400: { description: "EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY or ENOCHANGES.", }, 404: { description: "Lane not found." }, 500: { description: "git failed; its stderr is appended to error.message." }, }, }, }, "/api/lanes/{id}/{action}": { post: { tags: ["Lanes"], summary: "Confirm a reset, managed-worktree removal, or session purge", description: "Actions run under the lane lock after waiting for the lane child's actual exit. A failed spawn is already exited because no child started. Reset requires a live managed worktree; remove tears down a managed worktree, prunes git's stale record when the directory was deleted by hand, or only forgets an adopted-lane row without touching its directory. Reset/remove require force when managed work has unpushed commits.", operationId: "runLaneDestructiveAction", parameters: [ { name: "id", in: "path", required: true, schema: { type: "integer" } }, { name: "action", in: "path", required: true, schema: { type: "string", enum: ["reset", "remove", "purge"] }, }, ], requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/LaneDestructiveActionRequest" }, }, }, }, responses: { 200: { description: "Action completed; purge includes its deleted-row counts." }, 400: { description: "Confirmation or complete expect facts missing, or the managed-worktree guard refused the target.", }, 403: { description: "The browser request was not same-origin/loopback." }, 409: { description: "Preflight facts changed (ESTALE) or force is required (EUNPUSHED)." }, 500: { description: "Git, run-exit timeout, or internal failure; git failures include error.stderr.", }, }, }, }, }; module.exports = { tags, schemas, paths };