Files
AgentHub/server/openapi-extra/lanes-more.js
T
nntrivi2001 57f02f0d89 docs: OpenAPI and API.md cover every route; drop removed endpoints; guard config writes
- openapi-extra/run.js describes tmux pane runs only: the removed
  POST /api/run/{id}/message and stream-json run_stream/run_input_ack are gone.
- 108 operations added to OpenAPI (lanes, run, settings, reports, cc-config,
  pets, ai-proxies, tool-profiles, ai-tools, snapshots, locks, ...);
  openapi.yaml regenerated.
- docs/API.md: 74 routes added, 4 endpoints that no longer exist removed.
- Role is `developer`, not `member` (CLI.md, DATABASE.md, OVERVIEW.md);
  `agenthub update` -> `update-check`; help lists `server install-system|tools`
  and `users provision`; ARCHITECTURE.md's stale paths and names fixed.
- cc-config/codex-config writes carry sameOriginGuard (an MCP server or plugin
  is code Claude runs later), with a test; `command -v` takes its name as $1.
2026-10-22 12:17:18 +07:00

763 lines
29 KiB
JavaScript

/**
* @file OpenAPI fragment for the workspace (lane) routes the base spec and
* server/openapi-extra/lanes.js did not cover: the lane list and create, the
* folder browser, pipeline/feature/stage reads and declarations, proof
* screenshots, the MCP/profile/integration/CI/sync-base runtime primitives,
* removing a session's worktree, housekeeping (`/gc`), a workspace's own
* context (server/routes/lane-sources.js: instructions, extra folders,
* context files, memory), plus the cross-lane named locks (/api/locks) and
* the project tree (/api/projects). Exports `{ tags, schemas, paths }` for
* server/openapi-extra.js. Every /api/lanes path is also served under
* /api/workspaces by the same handlers.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tags = [
{
name: "Locks",
description:
"Cross-lane named locks (server/lib/named-lock.js). Single-shot — the server never waits for a lock; `agenthub lock acquire` owns the polling loop.",
},
{
name: "Projects",
description:
"Workspaces grouped by the git repository they belong to, with that repository's worktrees (server/lib/projects.js).",
},
];
const idParam = { name: "id", in: "path", required: true, schema: { type: "integer" } };
const json = (schema) => ({ content: { "application/json": { schema } } });
const obj = { type: "object", additionalProperties: true };
const notFound = { description: "ENOLANE — workspace not found." };
const crossOrigin = { description: "The browser request was not same-origin." };
const s3Refused = {
description:
"Same-origin refusal, or EMEMBERLANE (S3: not offered for a workspace in a member's folder — the runtime layer runs as the dashboard's own account).",
};
const SOURCES_VIEW = {
type: "object",
description: "A workspace's own context, as the Context panel shows it.",
properties: {
instructions: {
type: "string",
description: "The workspace's instructions (`''` when unset).",
},
supported: {
type: "boolean",
description: "Whether the workspace's tool takes instructions and sources (Claude, Codex).",
},
maxFileBytes: { type: "integer", example: 10485760 },
maxInstructions: { type: "integer", example: 16000 },
sources: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "integer" },
kind: { type: "string", enum: ["folder", "file"] },
path: { type: "string" },
name: { type: "string" },
size: { type: "integer", nullable: true },
added_at: { type: "string", format: "date-time" },
missing: { type: "boolean", description: "The path no longer exists on disk." },
},
},
},
},
};
const sourcesErrors = {
400: { description: "EBADFIELD, EBADPATH, ETOOMANY, EUNSUPPORTED or another validation code." },
403: {
description: "EBADORIGIN (not same-origin) or EFOLDER (outside the caller's own folder).",
},
404: { description: "ENOLANE, or ENOSOURCE for an unknown source id." },
};
const paths = {
"/api/lanes": {
get: {
tags: ["Lanes"],
summary: "List workspaces with live counts",
description:
"Every workspace the caller may see (server mode: a developer only their own), each as the full lane payload (stage, pipeline nodes, liveness, run state). One `tmux list-sessions` answers every lane's run. Working-copy facts are NOT here — see `GET /api/lanes/{id}/git`.",
operationId: "listLanes",
responses: {
200: {
description: "The lanes and their counts.",
...json({
type: "object",
properties: {
lanes: { type: "array", items: obj },
counts: {
type: "object",
properties: {
total: { type: "integer" },
running: { type: "integer" },
needs_you: { type: "integer" },
dead: { type: "integer" },
},
},
},
}),
},
},
},
post: {
tags: ["Lanes"],
summary: "Create a workspace for an existing folder",
description:
"Inserts a lane row (owner = the caller, never from the body) and broadcasts `lane_update`. A non-admin may only name a folder inside their own (EFOLDER), never one another user's workspace owns (EOWNED). Uniqueness is per (cwd, tool). Same-origin only.",
operationId: "createLane",
requestBody: {
required: true,
...json({
type: "object",
required: ["cwd"],
properties: {
cwd: { type: "string", description: "Absolute folder path." },
title: { type: "string" },
branch: { type: "string", nullable: true },
pipeline: { type: "string", default: "default" },
kind: { type: "string", enum: ["adopted", "managed"], default: "adopted" },
tool: { type: "string", default: "claude", description: "An AI Tools catalog id." },
},
}),
},
responses: {
201: { description: "`{lane}` — the new lane payload.", ...json(obj) },
400: {
description: "EBADCWD, EBADPIPELINE, EBADKIND, EBADFIELD, EBADTOOL or EDISABLED.",
},
403: { description: "EFOLDER, or not same-origin." },
409: {
description:
"EDUPCWD (a workspace already owns that cwd for this tool), EOWNED, EOWNERFOLDER or EMEMBERGONE.",
},
},
},
},
"/api/lanes/browse": {
get: {
tags: ["Lanes"],
summary: "List a server folder for the folder browser",
description:
"Read-only directory listing for the Add workspace dialogs (a browser cannot expose absolute paths from its own picker). Folders first; each entry carries `mtime` (ms) and, for a folder, `isGitRepo`. A developer browses only inside their own folder (by real path; EFOLDER), and never sees the dot entries at its top. An admin with their own Linux account (S3) starts in that home; `host=1` opens the host account's home instead.",
operationId: "browseLaneFolders",
parameters: [
{
name: "path",
in: "query",
schema: { type: "string" },
description: "Default: the caller's start home.",
},
{
name: "files",
in: "query",
schema: { type: "string", enum: ["1"] },
description: "List files too; `path` may then name a file (its folder opens).",
},
{
name: "hidden",
in: "query",
schema: { type: "string", enum: ["1"] },
description: "List dot entries too.",
},
{
name: "host",
in: "query",
schema: { type: "string", enum: ["1"] },
description: "S3 admin: open the host account's home.",
},
],
responses: {
200: {
description:
"`{path, parent, entries:[{name, path, isGitRepo, file?, mtime}], home, hostHome?}` — `parent` is null at the top.",
...json(obj),
},
400: { description: "ENOTFOUND (path does not exist) or ENOTADIR." },
403: { description: "EFOLDER — outside the caller's own folder." },
},
},
},
"/api/lanes/pipelines": {
get: {
tags: ["Lanes"],
summary: "List loaded pipeline templates",
description:
"Templates loaded from DASHBOARD_PIPELINES_DIR. AgentHub ships none, so this is empty unless a template was dropped in (pipelines are dormant).",
operationId: "listLanePipelines",
responses: { 200: { description: "`{pipelines:[…template]}`.", ...json(obj) } },
},
},
"/api/lanes/pipeline-stats": {
get: {
tags: ["Lanes"],
summary: "Per-node pass statistics across lanes and archived features",
description:
"Folds `lane_stage_events` into one verdict per (run, node): `entered`, `passed`, `failed`, `withEvidence` count declarations; `detected` counts nodes only inferred from hooks. Pipelines of in-scope lanes with no events appear at zero. The `lanes` list (picker) is never narrowed by `lane`. Server mode: a developer's aggregate covers their own workspaces.",
operationId: "lanePipelineStats",
parameters: [
{
name: "lane",
in: "query",
schema: { type: "string" },
description: "A lane id, or `all`/empty for every lane.",
},
],
responses: {
200: {
description:
"`{pipelines:[{id, name, runs, nodes:[{id, label, icon, gate, command, entered, passed, withEvidence, failed, detected}]}], lanes:[{id, title, cwd}]}`, busiest pipeline first.",
...json(obj),
},
400: { description: "EBADLANE — `lane` is not an id." },
},
},
},
"/api/lanes/gc": {
post: {
tags: ["Lanes"],
summary: "Machine-wide lane housekeeping",
description:
"Kills orphaned (parent pid 1) Playwright-MCP process trees under LANES_ROOT and caps every hook log over 10 MB under `LANES_ROOT/.state/lane<N>/logs/` to its last 2 MB, in place. `dryRun: true` only reports. Admin only in server mode (EADMIN); same-origin.",
operationId: "laneHousekeeping",
requestBody: json({ type: "object", properties: { dryRun: { type: "boolean" } } }),
responses: {
200: {
description: "`{reaped:[pid], capped:[{path, sizeBefore}]}`.",
...json(obj),
},
403: { description: "EADMIN, or not same-origin." },
500: { description: "The sweep failed." },
},
},
},
"/api/lanes/{id}/stage": {
post: {
tags: ["Lanes"],
summary: "Declare a lane's stage",
description:
"The driving session's own claim of progress (`agenthub stage`). Clears detection columns on a real transition; a declaration on a node BEFORE the current one drops the stage records of every node ahead. The dashboard UI never calls this. With no pipeline template installed the lane has no stages (ENOPIPELINE). Broadcasts `lane_update`. Same-origin.",
operationId: "declareLaneStage",
parameters: [idParam],
requestBody: {
required: true,
...json({
type: "object",
properties: {
stage: { type: "string", description: "A node id or alias of the lane's pipeline." },
status: { type: "string" },
evidence: { type: "string", nullable: true },
note: { type: "string" },
result: { type: "string", description: "`fail` marks the node failed." },
},
}),
},
responses: {
200: { description: "`{lane}`.", ...json(obj) },
400: {
description:
"ENOPIPELINE (no template installed) or EBADSTAGE (not a node of this pipeline; `error.stages` lists the valid ids).",
},
403: crossOrigin,
404: notFound,
},
},
},
"/api/lanes/{id}/features": {
get: {
tags: ["Lanes"],
summary: "List a lane's features",
description:
"Every feature the lane has activated, live or archived, each with its computed pipeline view (`pipeline_name`, `pipeline_nodes`, `progress`).",
operationId: "listLaneFeatures",
parameters: [idParam],
responses: { 200: { description: "`{features:[…]}`.", ...json(obj) }, 404: notFound },
},
},
"/api/lanes/{id}/features/{slug}": {
get: {
tags: ["Lanes"],
summary: "One feature of a lane",
description: "Including an archived feature's saved pipeline state.",
operationId: "getLaneFeature",
parameters: [
idParam,
{ name: "slug", in: "path", required: true, schema: { type: "string" } },
],
responses: {
200: { description: "`{feature}`.", ...json(obj) },
404: { description: "ENOLANE or ENOFEATURE." },
},
},
},
"/api/lanes/{id}/features/activate": {
post: {
tags: ["Lanes"],
summary: "Switch the lane to a feature",
description:
"`agenthub feature activate`: archives the outgoing feature's pipeline and clears detection (a detection belongs to the feature that produced it), then makes `slug` the live feature. Broadcasts `lane_update`. Same-origin.",
operationId: "activateLaneFeature",
parameters: [idParam],
requestBody: {
required: true,
...json({
type: "object",
required: ["slug"],
properties: { slug: { type: "string" }, title: { type: "string" } },
}),
},
responses: {
200: { description: "`{lane, feature}`.", ...json(obj) },
400: { description: "EBADSLUG." },
403: crossOrigin,
404: notFound,
},
},
},
"/api/lanes/{id}/proof": {
get: {
tags: ["Lanes"],
summary: "List a lane's proof screenshots",
description:
"Feature slugs under the lane's proof root (`.playwright-mcp/proof`), plus activated features with no proof yet; most recently touched first.",
operationId: "listLaneProof",
parameters: [idParam],
responses: {
200: {
description: "`{features:[{slug, groups:{<group>:[image]}, ticket_report, mtime}]}`.",
...json(obj),
},
404: notFound,
},
},
},
"/api/lanes/{id}/proof/{slug}": {
delete: {
tags: ["Lanes"],
summary: "Delete proof screenshots",
description:
"Body `{group, images}` unlinks those images (and an emptied group dir); `{group}` removes the whole group; no body removes every group except `ticket/`. Containment-checked to the proof root. Same-origin; S3 guard.",
operationId: "deleteLaneProof",
parameters: [
idParam,
{ name: "slug", in: "path", required: true, schema: { type: "string" } },
],
requestBody: json({
type: "object",
properties: {
group: { type: "string" },
images: { type: "array", items: { type: "string" } },
},
}),
responses: {
200: { description: "`{deleted}` — number of files removed.", ...json(obj) },
400: { description: "EBADPATH." },
403: s3Refused,
404: { description: "ENOLANE or ENOFEATURE." },
},
},
},
"/api/lanes/{id}/proof/{slug}/{group}/{file}": {
get: {
tags: ["Lanes"],
summary: "Serve one proof file",
description:
"A proof image or the ticket's REPORT.html, containment-checked (no traversal or symlink escape, image/HTML extensions only). Plain-text `not found` otherwise.",
operationId: "getLaneProofFile",
parameters: [
idParam,
{ name: "slug", in: "path", required: true, schema: { type: "string" } },
{ name: "group", in: "path", required: true, schema: { type: "string" } },
{ name: "file", in: "path", required: true, schema: { type: "string" } },
],
responses: {
200: { description: "The file (`text/html`, `image/png` or `image/jpeg`)." },
404: { description: "Lane or file not found." },
},
},
},
"/api/lanes/{id}/proof-link": {
post: {
tags: ["Lanes"],
summary: "Converge the lane's proof/ onto .playwright-mcp/proof",
description:
"Merges a real `proof/` dir into the canonical root and replaces it with a relative symlink; idempotent, never clobbers a stray file named `proof`. Never automatic (`agenthub lanes proof-link`). Same-origin; S3 guard.",
operationId: "linkLaneProof",
parameters: [idParam],
responses: {
200: { description: "`{linked}` — false when nothing changed.", ...json(obj) },
403: s3Refused,
404: notFound,
},
},
},
"/api/lanes/{id}/mcp/sync": {
post: {
tags: ["Lanes"],
summary: "Give the lane its source repo's MCP servers",
description:
"Relocates the source repo's configured `mcpServers` (from ~/.claude.json) into `<lane>/.mcp.json` (git-excluded), pins Playwright's output dir, seeds Chromium profiles. Never touches permissions or settings.local.json. Same-origin; S3 guard.",
operationId: "syncLaneMcp",
parameters: [idParam],
responses: {
200: { description: "`{servers:[name], profilesSeeded:[name]}`.", ...json(obj) },
400: { description: "ENOMCPCONFIG — the source repo has no MCP config." },
403: s3Refused,
404: notFound,
500: { description: "ERUNTIME." },
},
},
},
"/api/lanes/{id}/profile/init": {
post: {
tags: ["Lanes"],
summary: "Scaffold .agenthub/profile/ for a detected Node.js project",
description:
"Always targets the lane's own folder. No detectable Node.js project is a normal 200 (`scaffolded:false`). Same-origin; S3 guard.",
operationId: "initLaneProfile",
parameters: [idParam],
requestBody: json({
type: "object",
properties: { force: { type: "boolean", description: "Overwrite an existing profile." } },
}),
responses: {
200: {
description: "`{scaffolded:true, written, todos}` or `{scaffolded:false, reason}`.",
...json(obj),
},
400: { description: "EPROFILEEXISTS (without `force`)." },
403: s3Refused,
404: notFound,
},
},
},
"/api/lanes/{id}/integrations/{name}": {
get: {
tags: ["Lanes"],
summary: "Whether a profile integration is enabled",
description:
"Reads `<NAME>_ENABLED` from `.agenthub/profile/integrations.env` in the lane folder or its source repo. Read-only.",
operationId: "laneIntegrationEnabled",
parameters: [
idParam,
{
name: "name",
in: "path",
required: true,
schema: { type: "string" },
example: "tracker",
},
],
responses: { 200: { description: "`{enabled}`.", ...json(obj) }, 404: notFound },
},
},
"/api/lanes/{id}/ci/status": {
get: {
tags: ["Lanes"],
summary: "GitHub Actions status snapshot for a ref",
description:
"One `gh` read for a branch or sha in the lane's repo — not a poll loop; the caller owns any wait/retry. Read-only.",
operationId: "laneCiStatus",
parameters: [
idParam,
{ name: "ref", in: "query", required: true, schema: { type: "string" } },
],
responses: {
200: {
description: "`{state, ref, checks:[…]}` (`pending` with no checks yet).",
...json(obj),
},
400: { description: "EBADREF — ref is required." },
404: notFound,
500: { description: "ERUNTIME (gh failed)." },
},
},
},
"/api/lanes/{id}/sync-base": {
post: {
tags: ["Lanes"],
summary: "Merge the base branch into the feature branch (gated)",
description:
"The one sanctioned merge of the ship-feature pipeline: fetch, a migration-number collision preflight, then merge — under the lane lock, synchronously. Every documented outcome is 200 with `code`: 0 (ok; `check` adds `devDelta`/`overlap`), 4 (conflict left in place, `conflictedFiles`), 5 (migration collision, `collisions`). Requires a lane profile. Never writes stage/status/notes. Same-origin; S3 guard.",
operationId: "syncLaneBase",
parameters: [idParam],
requestBody: json({
type: "object",
properties: {
mode: { type: "string", enum: ["check", "merge", "continue"], default: "merge" },
branch: { type: "string" },
},
}),
responses: {
200: { description: "`{code, …}` as above.", ...json(obj) },
400: {
description:
"ENOPROFILE, EBADBRANCH, EUNRESOLVED, EMERGEUNCOMMITTED or another runtime input code.",
},
403: s3Refused,
404: notFound,
500: { description: "ERUNTIME." },
},
},
},
"/api/lanes/{id}/session-worktree/remove": {
post: {
tags: ["Lanes"],
summary: "Remove a session's `claude --worktree` tree",
description:
"The one dashboard path that deletes a worktree: resolved from the session row (`sessionWorktree`), never a path. `git worktree remove` without `--force`, then `git branch -d` (never `-D`) when git agrees the branch is merged — else the branch is kept and reported. Refused while a live pane holds the session. Same-origin.",
operationId: "removeSessionWorktree",
parameters: [idParam],
requestBody: {
required: true,
...json({
type: "object",
required: ["session", "confirm"],
properties: { session: { type: "string" }, confirm: { type: "boolean", enum: [true] } },
}),
},
responses: {
200: { description: "`{removed:true, branch, branchKept}`.", ...json(obj) },
400: { description: "ECONFIRM or ENOWORKTREE." },
403: crossOrigin,
404: notFound,
409: {
description:
"ELIVE (a pane holds the session), ELOCKED, or EGIT (git refused, e.g. a dirty tree).",
},
},
},
},
"/api/lanes/{id}/sources": {
get: {
tags: ["Lanes"],
summary: "A workspace's instructions, extra folders and context files",
operationId: "getLaneSources",
parameters: [idParam],
responses: {
200: { description: "The context view.", ...json(SOURCES_VIEW) },
404: notFound,
},
},
},
"/api/lanes/{id}/instructions": {
put: {
tags: ["Lanes"],
summary: "Set a workspace's instructions",
description:
"Stored on the lane row and given to every new Claude/Codex pane at spawn (never written into the repo). `null` clears. Capped at `maxInstructions`. Broadcasts `lane_update`. Same-origin.",
operationId: "setLaneInstructions",
parameters: [idParam],
requestBody: {
required: true,
...json({
type: "object",
required: ["instructions"],
properties: { instructions: { type: "string", nullable: true } },
}),
},
responses: {
200: { description: "The context view.", ...json(SOURCES_VIEW) },
...sourcesErrors,
},
},
},
"/api/lanes/{id}/sources/folder": {
post: {
tags: ["Lanes"],
summary: "Add an extra folder",
description:
"An absolute, existing directory that is not (and does not contain) the OS home and is not the workspace's own folder. A developer may name only folders in their own (checked before the path is probed, and again on the real path). The folder stays the user's — AgentHub never deletes it. Same-origin.",
operationId: "addLaneSourceFolder",
parameters: [idParam],
requestBody: {
required: true,
...json({ type: "object", required: ["path"], properties: { path: { type: "string" } } }),
},
responses: {
201: { description: "The context view.", ...json(SOURCES_VIEW) },
...sourcesErrors,
},
},
},
"/api/lanes/{id}/sources/file": {
post: {
tags: ["Lanes"],
summary: "Upload a context file",
description:
"Raw bytes (≤ 10 MB) with the name in the `X-File-Name` header (URI-encoded). Stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives. Same-origin.",
operationId: "addLaneSourceFile",
parameters: [
idParam,
{ name: "X-File-Name", in: "header", required: true, schema: { type: "string" } },
],
requestBody: {
required: true,
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
},
responses: {
201: { description: "The context view.", ...json(SOURCES_VIEW) },
...sourcesErrors,
413: { description: "ETOOBIG — over 10 MB." },
},
},
},
"/api/lanes/{id}/sources/clone": {
post: {
tags: ["Lanes"],
summary: "Clone a repo as an extra folder",
description:
"Clones `url` (the `publish` URL allowlist; run as the folder's account with that account's own git identity) BESIDE the workspace folder — no path from the request — then adds it as an extra folder. The clone is the user's; removing the source never deletes it. Same-origin.",
operationId: "cloneLaneSource",
parameters: [idParam],
requestBody: {
required: true,
...json({
type: "object",
required: ["url"],
properties: {
url: { type: "string" },
name: { type: "string", description: "Folder name; default derived from the URL." },
credentials: {
type: "object",
additionalProperties: true,
description: "A typed HTTPS login for this one clone.",
},
},
}),
},
responses: {
201: { description: "The context view plus `path` (the clone).", ...json(obj) },
400: {
description:
"EUNSUPPORTED (tool takes no extra folders), a URL/name refusal, or a source check (the clone stays, `path` names it).",
},
403: { description: "EFOLDER, or not same-origin." },
404: notFound,
409: {
description:
"EEXISTS (name taken), ECREDENTIALS / ESSHAUTH (the remote wants a login), ENOREPO, EOWNERFOLDER or EMEMBERGONE.",
},
},
},
},
"/api/lanes/{id}/sources/{sourceId}": {
delete: {
tags: ["Lanes"],
summary: "Remove an extra folder or context file",
description:
"Drops the row; for an uploaded file AgentHub also deletes its own stored copy. A folder on disk is never deleted. Same-origin.",
operationId: "removeLaneSource",
parameters: [
idParam,
{ name: "sourceId", in: "path", required: true, schema: { type: "integer" } },
],
responses: {
200: { description: "The context view.", ...json(SOURCES_VIEW) },
...sourcesErrors,
},
},
},
"/api/lanes/{id}/memory": {
get: {
tags: ["Lanes"],
summary: "The memory files the workspace's agent already reads",
description:
"Read-only: the folder's CLAUDE.md / .claude/CLAUDE.md / CLAUDE.local.md / AGENTS.md and, for Claude, up to 50 `.md` files of its auto-memory dir `<CLAUDE_HOME>/projects/<encoded cwd>/memory/`. Every path derives from the lane row.",
operationId: "getLaneMemory",
parameters: [idParam],
responses: { 200: { description: "`{files:[…]}`.", ...json(obj) }, 404: notFound },
},
},
"/api/locks": {
get: {
tags: ["Locks"],
summary: "List held named locks",
description:
"Server mode: a developer sees only their own namespace, with the prefix stripped.",
operationId: "listLocks",
responses: {
200: { description: "`{locks:[{name, holder, since, ageSec}]}`.", ...json(obj) },
},
},
},
"/api/locks/{name}/acquire": {
post: {
tags: ["Locks"],
summary: "Try to take a named lock (never waits)",
description:
"Atomic mkdir under the locks root. A lock held longer than the max hold time is stale and broken. `name` is one plain name (no path). Same-origin.",
operationId: "acquireLock",
parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
requestBody: {
required: true,
...json({
type: "object",
required: ["holder"],
properties: { holder: { type: "string" } },
}),
},
responses: {
200: { description: "`{acquired:true}`.", ...json(obj) },
400: { description: "EBADNAME or EBADHOLDER." },
403: crossOrigin,
409: {
description: "`{acquired:false, holder, since, ageSec}` — held by someone else.",
...json(obj),
},
},
},
},
"/api/locks/{name}/release": {
post: {
tags: ["Locks"],
summary: "Release a named lock",
description: "Only the recorded holder may release it. Same-origin.",
operationId: "releaseLock",
parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
requestBody: {
required: true,
...json({
type: "object",
required: ["holder"],
properties: { holder: { type: "string" } },
}),
},
responses: {
200: { description: "`{ok:true}`.", ...json(obj) },
400: { description: "EBADNAME or EBADHOLDER." },
403: crossOrigin,
404: { description: "ENOLOCK — not held." },
409: { description: "ENOTHOLDER — `error.currentHolder` names the owner." },
},
},
},
"/api/projects": {
get: {
tags: ["Projects"],
summary: "Workspaces grouped by git repository",
description:
"Each project: `{root, name, workspaces:[id], worktrees:[{path, branch, main, workspace_id, source?}]}` (`source` is `claude` for a `.claude/worktrees` tree, `agenthub` under LANES_ROOT); non-repo folders last as `Folders` (`root:null`). Roots and `git worktree list` are cached in memory (worktrees 30 s). Read on demand, never polled. Server mode: a developer's own workspaces, without worktrees another user's workspace sits on.",
operationId: "listProjects",
responses: { 200: { description: "`{projects:[…]}`.", ...json(obj) } },
},
},
"/api/projects/refresh": {
post: {
tags: ["Projects"],
summary: "Drop the project caches and list again",
operationId: "refreshProjects",
responses: {
200: { description: "`{projects:[…]}`.", ...json(obj) },
403: crossOrigin,
},
},
},
};
module.exports = { tags, schemas: {}, paths };