7bc4594b94
Server (git-diff.js, git-ops.js, worktree.js, routes/lanes.js): - Every lane git call runs with GIT_LITERAL_PATHSPECS=1 and -z porcelain, so a file name is a file name, never a glob, and odd names parse correctly. - A lane that is a subdirectory of its repo sees and acts on ONLY its own paths (reads, stage/unstage/discard/commit, and gitFacts.files). - Any file named settings.local.json (the lane's router API key) is refused as 403 ESECRET on disk (symlinks resolved) and at every committed rev. - commit refuses with 400 ESTAGEDOUTSIDE while anything outside the lane is staged (checked with --no-renames, so a move into the lane is caught). - push resolves its remote (upstream → only remote → origin), else 400 ENOREMOTE; unstaging a rename unstages both halves. - Git writes serialize on the repo's git dir, not the lane id, so two lanes on one index cannot collide on index.lock. - git refusing an action (a numeric exit status) answers 409 EGIT with git's stderr instead of a 500 carrying a numeric code; one shared parseNumstat. Client: - Changes panel: no stale answer from a previous lane or an older quick-open query repaints the panel; discard disarms on lane switch; Enter on a file path opens it instead of folding; bounded tree expand-all. - Large diffs (> 500 rows) render only the visible rows, with stable width and in-sync split columns; mirrored split scroll is clamped. - Git bar: no "in sync" without counts, a clear no-remote message, branch list re-read after a switch, and no previous-lane facts after a lane switch. - Tooltips no longer flicker or close on unrelated scrolls.
722 lines
31 KiB
JavaScript
722 lines
31 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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",
|
|
},
|
|
exact: {
|
|
type: "boolean",
|
|
description:
|
|
"Only a lane on this very directory counts; a parent directory's lane is not returned, so a nested directory gets its own lane.",
|
|
},
|
|
},
|
|
},
|
|
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) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — 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=<file> returns that one file's hunks. range=work (default) is HEAD vs the working tree with untracked files included; range=branch is <base_branch>...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 (relative, no `..`, else 400 EBADPATH). 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}]}." },
|
|
400: { description: "EBADPATH (file= is absolute or has a `..` segment)." },
|
|
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 (incl. a symlink resolving outside the lane), EBADREV or ENOFILE.",
|
|
},
|
|
403: {
|
|
description:
|
|
"ESECRET — `.claude/settings.local.json` (the AI Router key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).",
|
|
},
|
|
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 <remote> HEAD, so only the branch the lane is on; remote = the branch's upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m <message>, 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 git index (the lane lock keyed on the worktree's git dir, so two lanes sharing one index cannot collide on index.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, ENOCHANGES, ESTAGEDOUTSIDE or ENOREMOTE.",
|
|
},
|
|
404: { description: "Lane not found." },
|
|
409: {
|
|
description:
|
|
"EGIT — git ran and refused (a rejected push, a credential failure, a diverged --ff-only pull); its stderr is appended to error.message.",
|
|
},
|
|
500: { description: "Unexpected server-side failure." },
|
|
},
|
|
},
|
|
},
|
|
"/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 };
|