cb19d7e5d2
Gathered from the parallel sessions' uncommitted work on main, at the
owner's request (the session that wrote #78/#79/#80 was stopped mid-task):
- #78/#79/#80: Files tab upload/download (GET /:id/git/download, POST
/:id/git/upload, server/lib/lane-file-transfer.js) and a non-git tree
(git-diff.js walkFolder); pinned sessions (sessions.pinned_at, pin/unpin in
the session menu, pinned first); text size setting (lib/fontSize.ts,
Appearance).
- Git identity and clone over HTTPS/SSH (server/lib/git-identity.js,
routes/git-identity.js, GitIdentitySettings, AddLaneModal clone).
- pets.js FEATURED list and view().
Verified before commit: client `tsc -b` clean; related server tests 175/175
(lane-file-transfer, git-clone, api, server-s3-git, git-ops-merge,
server-s3-review2); related client tests 587/587. Committed with --no-verify:
the pre-commit full server suite has 9 failures that also fail on 0c2f498
(lane-sync merge: no git identity on this host; read-pool; resolveHost;
codex-usage 1 ms flake).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1022 lines
49 KiB
JavaScript
1022 lines
49 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, and the Files tab's reads (tree,
|
|
* quick-open, download — also in a folder that is not a git repo) and upload.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const tags = [
|
|
{
|
|
name: "Lanes",
|
|
description:
|
|
"Durable parallel-work workspaces (called lanes in the API) and dashboard-managed git worktrees. Every /api/lanes route is also mounted at /api/workspaces — the same handlers, the user-facing name.",
|
|
},
|
|
];
|
|
|
|
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/pick-path": {
|
|
post: {
|
|
tags: ["Lanes"],
|
|
summary: "Open the OS's own folder/file picker on the server's desktop",
|
|
description:
|
|
"Shows zenity/kdialog (Linux), osascript (macOS) or a WinForms dialog (Windows) on the SERVER's desktop and waits for the user (up to 10 minutes; closed when the request goes away). Only for a browser on the same machine: a non-loopback client, or a request carrying X-Forwarded-For/Forwarded, gets EREMOTE and should fall back to GET /api/lanes/browse. One dialog at a time. Nothing from the request reaches argv but the starting folder, resolved to an existing absolute directory first.",
|
|
operationId: "pickLanePath",
|
|
requestBody: {
|
|
content: {
|
|
"application/json": {
|
|
schema: {
|
|
type: "object",
|
|
properties: {
|
|
kind: { type: "string", enum: ["dir", "file"], default: "dir" },
|
|
start: { type: "string", description: "Folder (or file) to open the dialog at." },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
200: {
|
|
description: "`{path}` — the chosen absolute path — or `{cancelled: true}`.",
|
|
content: {
|
|
"application/json": {
|
|
schema: {
|
|
type: "object",
|
|
properties: { path: { type: "string" }, cancelled: { type: "boolean" } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
403: { description: "EBADORIGIN (not same-origin) or EREMOTE (not a local browser)." },
|
|
409: { description: "EBUSY — a picker is already open." },
|
|
500: { description: "EDIALOG — the dialog program failed." },
|
|
501: { description: "ENODIALOG — no desktop session or no dialog program." },
|
|
},
|
|
},
|
|
},
|
|
"/api/lanes/browse/mkdir": {
|
|
post: {
|
|
tags: ["Lanes"],
|
|
summary: "New folder, from the in-app folder browser",
|
|
description:
|
|
"Makes ONE folder `name` (no `/`, `\\`, NUL, `.` or `..`; ≤ 255 bytes) inside the existing folder `parent` — never `-p`, never over an existing entry. A non-admin makes one only inside their own folder, by real path (`EFOLDER`), and in server mode a non-admin with no folder of their own makes none. Under S3 a folder in a member's home is made as that member.",
|
|
operationId: "makeBrowseFolder",
|
|
requestBody: {
|
|
content: {
|
|
"application/json": {
|
|
schema: {
|
|
type: "object",
|
|
required: ["parent", "name"],
|
|
properties: {
|
|
parent: { type: "string", description: "Absolute path of an existing folder." },
|
|
name: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
201: {
|
|
description: "`{path}` — the folder made.",
|
|
content: {
|
|
"application/json": {
|
|
schema: { type: "object", properties: { path: { type: "string" } } },
|
|
},
|
|
},
|
|
},
|
|
400: { description: "EBADNAME, EBADPATH, or ENOTFOUND (no such parent folder)." },
|
|
403: { description: "EBADORIGIN, or EFOLDER (outside the caller's own folder)." },
|
|
409: { description: "EEXISTS (the name is taken) or EMEMBERGONE." },
|
|
},
|
|
},
|
|
},
|
|
"/api/lanes/ensure": {
|
|
post: {
|
|
tags: ["Lanes"],
|
|
summary: "Find or adopt the lane owning a working directory",
|
|
description:
|
|
"Idempotent: canonicalizes `cwd` to its real path when it exists (the same path `createLane` stores), then 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:
|
|
"EBADSOURCEREPO: sourceRepo is relative, missing, or not a git repository. EEMPTYREPO: it has no commits, so there is nothing to branch a worktree from (no lane row is created).",
|
|
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, unborn, subject, dirty, untracked, files, hasRemote, upstream, ahead, behind}, or available:false alone. A repo with no commit yet is available with unborn:true, head:null and subject:"". 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." },
|
|
403: {
|
|
description:
|
|
"EFOLDER — server mode with AGENTHUB_USERS_ROOT: a developer named a repo outside their own folder.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"/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 with neither a lane base branch nor a repo default branch to compare against), 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); ref= reads another existing local or remote-tracking branch's history instead of HEAD's (else 400 ENOBRANCH). 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" } },
|
|
{ name: "ref", 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), ENOBRANCH (ref= is not a branch).",
|
|
},
|
|
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` (where an older build kept the AI Proxy key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`); under S3 also a dot folder at the top of a member's home. EFOREIGN — under S3 the workspace folder now resolves into another account's files.",
|
|
},
|
|
404: { description: "Lane not found." },
|
|
},
|
|
},
|
|
},
|
|
"/api/lanes/{id}/git/blame": {
|
|
get: {
|
|
tags: ["Lanes"],
|
|
summary: "Annotate: each line's last commit",
|
|
description:
|
|
"`git blame --porcelain` of the working-tree file, for the Changes panel's Annotate gutter. Same lexical lane guard and secret refusal as /git/file, capped at 1 MB. A line nobody committed yet is null. An untracked file (no history) answers {available:false}. Read-only, no same-origin guard.",
|
|
operationId: "blameLaneFile",
|
|
parameters: [
|
|
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
|
{ name: "path", in: "query", required: true, schema: { type: "string" } },
|
|
],
|
|
responses: {
|
|
200: {
|
|
description:
|
|
"{available, path, commits: {<sha>: {short, author, date, summary}}, lines: [<sha>|null]}.",
|
|
},
|
|
400: { description: "EBADPATH, ENOFILE or ETOOBIG." },
|
|
403: {
|
|
description:
|
|
"ESECRET, or EFOREIGN (S3: the workspace folder resolves into another account's files).",
|
|
},
|
|
404: { description: "Lane not found." },
|
|
},
|
|
},
|
|
},
|
|
"/api/lanes/{id}/git/raw": {
|
|
get: {
|
|
tags: ["Lanes"],
|
|
summary: "One image file's bytes at a revision",
|
|
description:
|
|
"For the viewer's <img>. Same path, rev and secret guards as /git/file, plus an extension allowlist (png, jpg, jpeg, gif, webp, avif, bmp, ico, svg) that sets the Content-Type, and a 20 MB cap. Served with `Content-Security-Policy: …; sandbox` and `X-Content-Type-Options: nosniff`, so an SVG opened from this URL runs no script. Read-only, no same-origin guard.",
|
|
operationId: "readLaneImage",
|
|
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: "The image bytes, typed by extension." },
|
|
400: { description: "EBADPATH, EBADREV, ENOFILE, ENOTIMAGE or ETOOBIG." },
|
|
403: {
|
|
description:
|
|
"ESECRET, or EFOREIGN (S3: the workspace folder resolves into another account's files).",
|
|
},
|
|
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. In a folder that is not a git repo it walks the disk instead (realpath-confined, no symlink followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). 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. In a folder that is not a git repo the level is read off the disk instead (no status; `.git` hidden, `node_modules` listed as ignored and never entered). 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/tree-all": {
|
|
get: {
|
|
tags: ["Lanes"],
|
|
summary: "Every level of the tracked tree in one read",
|
|
description:
|
|
"The file explorer's expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory (\"\" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree's entries; a folder that is not a git repo is walked off the disk instead (at most 20 000 entries, then truncated). Read-only, no same-origin guard.",
|
|
operationId: "listLaneTreeAll",
|
|
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
|
responses: {
|
|
200: { description: "{available, levels:{<dir>:[{name, path, dir}]}, truncated}." },
|
|
404: { description: "Lane not found." },
|
|
},
|
|
},
|
|
},
|
|
"/api/lanes/{id}/git/download": {
|
|
get: {
|
|
tags: ["Lanes"],
|
|
summary: "Download one working file of the workspace",
|
|
description:
|
|
"The Files tab's Download: one working-copy file of any type, also in a folder that is not a git repo (server/lib/lane-file-transfer.js). Same guards as the /git/file read (lexical lane path, realpath confinement, secret refusal; under S3 a member's file is opened through openSafe), at most 512 MB. Served as an `application/octet-stream` attachment (an ASCII filename plus `filename*=UTF-8''…`) with `Content-Security-Policy: default-src 'none'; sandbox`, `X-Content-Type-Options: nosniff` and `Cache-Control: no-store`, so nothing in it renders or runs on the dashboard's origin. Honours `?session=`/`?source=` like the other /git reads. Read-only, no same-origin guard.",
|
|
operationId: "downloadLaneFile",
|
|
parameters: [
|
|
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
|
{ name: "path", in: "query", required: true, schema: { type: "string" } },
|
|
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
|
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
|
],
|
|
responses: {
|
|
200: { description: "The file's bytes, as an attachment." },
|
|
400: { description: "EBADPATH, ENOFILE or ETOOBIG." },
|
|
403: {
|
|
description:
|
|
"ESECRET, or EFOREIGN (S3: the workspace folder resolves into another account's files).",
|
|
},
|
|
404: { description: "Lane not found." },
|
|
},
|
|
},
|
|
},
|
|
"/api/lanes/{id}/git/upload": {
|
|
post: {
|
|
tags: ["Lanes"],
|
|
summary: "Upload one new file into a folder of the workspace",
|
|
description:
|
|
"The Files tab's Upload. The body is the file's raw bytes, its name the URI-encoded `X-File-Name` header, `dir` the workspace-relative folder (\"\" = the root). It only CREATES: a taken name (a symlink at it included) is 409 EEXISTS, never an overwrite. Writes into an existing folder only, never into or under `.git`, never a secret name (`settings.local.json`, a member's home dot folder), at most 25 MB. Under S3 the file is written as the folder's member through `write-as`; elsewhere with an exclusive create. Works in a folder that is not a git repo. Same-origin guarded.",
|
|
operationId: "uploadLaneFile",
|
|
parameters: [
|
|
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
|
{ name: "dir", in: "query", schema: { type: "string", default: "" } },
|
|
{
|
|
name: "X-File-Name",
|
|
in: "header",
|
|
required: true,
|
|
schema: { type: "string" },
|
|
description: "The new file's name, URI-encoded: one plain segment of at most 255 bytes.",
|
|
},
|
|
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
|
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
|
],
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
"application/octet-stream": { schema: { type: "string", format: "binary" } },
|
|
},
|
|
},
|
|
responses: {
|
|
201: { description: "{path} — the workspace-relative path written." },
|
|
400: { description: "EBADPATH (.git), EBADNAME, ENOFILE (no such folder) or EBADCONTENT." },
|
|
403: { description: "ESECRET, EFOREIGN, or EBADORIGIN (cross-origin)." },
|
|
404: { description: "Lane not found." },
|
|
409: { description: "EEXISTS — a file of that name is already there." },
|
|
413: { description: "ETOOBIG — over 25 MB." },
|
|
},
|
|
},
|
|
},
|
|
"/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/commit-message": {
|
|
post: {
|
|
tags: ["Lanes"],
|
|
summary: "Suggest a commit message for the staged changes",
|
|
description:
|
|
"Runs the user's `claude` CLI (`-p --safe-mode --no-session-persistence --tools \"\" --model haiku`) on the STAGED diff of the lane's cwd (or `?session=` worktree, or `?source=` extra folder), sent on stdin with the last ten commit subjects; a `settings.local.json` is never included. Reads only — nothing is staged or committed, no tool runs and no session is saved. Same-origin, since each call spends the user's Claude quota. 400 ENOSTAGED when nothing is staged, 400 ENOTOOL when `claude` is not on PATH, 502 EAI when the CLI fails, answers empty or runs past 120 s.",
|
|
operationId: "suggestLaneCommitMessage",
|
|
parameters: [
|
|
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
|
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
|
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
|
],
|
|
responses: {
|
|
200: {
|
|
description: "The suggested message",
|
|
content: {
|
|
"application/json": {
|
|
schema: {
|
|
type: "object",
|
|
properties: { message: { type: "string" } },
|
|
required: ["message"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
400: { description: "ENOSTAGED / ENOTOOL / ENOWORKTREE" },
|
|
403: { description: "Cross-origin request refused" },
|
|
404: { description: "No such workspace, or ENOSOURCE" },
|
|
502: { description: "EAI — the claude CLI failed" },
|
|
},
|
|
},
|
|
},
|
|
"/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; staging a conflicted file that still has conflict markers is 400 EMARKERS), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file's `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git's own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), 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), 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), publish (the ONE write whose destination comes from the request: `git remote add origin -- <url>` then `git push -u origin HEAD`, only in a repo with a commit and NO remote — it never re-points one; the url must be https:// without userinfo, ssh:// or scp-like user@host:path, and origin is removed again if the push fails), and merge / resolve / merge-commit / merge-abort (issue #45: merge a local or remote-tracking branch that exists — clean tree and a repo-root lane only, 400 ESUBDIR otherwise — answering conflicts:[paths] when it stops on conflicts; resolve {path, side: ours|theirs|done} a conflicted path, done refusing leftover markers with 400 EMARKERS; merge-commit is 409 ECONFLICTS while anything is unmerged; merge-abort REQUIRES confirm:true; pull/switch/merge/hunk actions are 409 EMERGING mid-merge). fetch/pull/push/publish needing a login answer 409 EGITAUTH with auth:{protocol,host,username,rejected}; resend with credentials:{protocol,host,username,password,remember} (a repo remote's host only, else 400 EBADHOST). 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 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",
|
|
"stage-hunk",
|
|
"discard-hunk",
|
|
"commit",
|
|
"switch",
|
|
"branch-create",
|
|
"branch-delete",
|
|
"publish",
|
|
"cherry-pick",
|
|
"revert",
|
|
"undo-commit",
|
|
"amend",
|
|
"ignore",
|
|
],
|
|
},
|
|
},
|
|
],
|
|
requestBody: {
|
|
required: false,
|
|
content: {
|
|
"application/json": {
|
|
schema: {
|
|
type: "object",
|
|
properties: {
|
|
url: {
|
|
type: "string",
|
|
description:
|
|
"publish only; https:// (no userinfo), ssh:// or user@host:path, max 1024 chars.",
|
|
},
|
|
message: {
|
|
type: "string",
|
|
description:
|
|
"commit (required) and amend (optional — none keeps the old message); max 4000 chars.",
|
|
},
|
|
sha: {
|
|
type: "string",
|
|
description:
|
|
"cherry-pick, revert; a hex commit id (4-40) of a non-merge commit, else 400 EBADSHA / EMERGECOMMIT. Clean tree and repo-root lane required (EDIRTY/ESUBDIR); a conflict answers 200 with conflicts[], finished by merge-commit or dropped by merge-abort; a no-op pick/revert is aborted and answers 409 EEMPTY. undo-commit (reset --soft HEAD~1, confirm:true) and amend REQUIRE sha = the HEAD the user confirmed (409 ESTALE once it moved) and refuse a HEAD any remote-tracking branch holds (409 EPUSHED) or a subdirectory lane (400 ESUBDIR).",
|
|
},
|
|
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, ignore (untracked only, else 400 ETRACKED; appends an anchored line to the workspace's .gitignore); each must be a path git reports changed. commit and amend: commit exactly these files' working-tree content (`git commit --only`), an untracked one included, leaving every other change — staged or not — uncommitted; a conflicted one is 409 ECONFLICTS. amend with an empty list rewords only (a message is then required, else 400 ENOCHANGES).",
|
|
},
|
|
path: {
|
|
type: "string",
|
|
description:
|
|
"stage-hunk, discard-hunk; a path git reports changed (not untracked).",
|
|
},
|
|
hunks: {
|
|
type: "object",
|
|
additionalProperties: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
hunk: { type: "string" },
|
|
blocks: { type: "array", items: { type: "integer" } },
|
|
},
|
|
},
|
|
},
|
|
description:
|
|
"commit, amend (optional): the hunk-level commit — per tracked, non-renamed path, only these change blocks of these hunks (`@@` lines as the diff at `context` returned them). Built in a temporary index from git's own fresh diff and committed from it; the real index entries of the committed paths are then reset to the new HEAD, the rest of a partial file left as an unstaged change. A hunk no longer in the diff is 409 EHUNKGONE, an untracked path 400 EUNTRACKED, a bad shape 400 EBADPATH.",
|
|
},
|
|
hunk: {
|
|
type: "string",
|
|
description:
|
|
"stage-hunk, discard-hunk; the hunk's `@@` line as the diff returned it. The server re-reads the diff and applies git's own text of that hunk; a hunk no longer present is 409 EHUNKGONE.",
|
|
},
|
|
block: {
|
|
type: "integer",
|
|
description:
|
|
"stage-hunk, discard-hunk (optional); act on only the block-th run of consecutive +/- lines inside that hunk — the other runs are folded into context. Out of range is 409 EHUNKGONE.",
|
|
},
|
|
context: {
|
|
type: "integer",
|
|
description: "stage-hunk, discard-hunk; the context the hunk was read at.",
|
|
},
|
|
confirm: {
|
|
type: "boolean",
|
|
description: "discard and discard-hunk; the call is refused without it.",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
200: {
|
|
description:
|
|
'{action, ok:true, output} — git\'s own stdout+stderr; publish adds remote:"origin".',
|
|
},
|
|
400: {
|
|
description:
|
|
"EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE, ENOREMOTE, EUNTRACKED, EBADURL, EREMOTEEXISTS, ENOCOMMITS, EBADHOST, EBADCRED, ESUBDIR, EBADSIDE or EMARKERS.",
|
|
},
|
|
404: { description: "Lane not found." },
|
|
409: {
|
|
description:
|
|
"EGIT — git ran and refused (a rejected push, a diverged --ff-only pull); its stderr is appended to error.message. EGITAUTH — the remote wants a login (carries auth). EHUNKGONE, EMERGING, ENOMERGE, ECONFLICTS.",
|
|
},
|
|
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 };
|