feat(ui): call lanes "workspaces" in the UI, CLI output and docs
Copy-only rename (workspace redesign, phase 0). The word a user reads changes — "Lane" → "Workspace", "Add lane" → "Add workspace" — in both locales, in agenthub CLI prose and help, in the server's error messages and in the docs. Every identifier stays: /api/lanes, `agenthub lanes`, --lane, AGENTHUB_LANE_ID, error codes, tmux names, DB tables, file, component and i18n key names. docs/LANES.md keeps its name, retitled "Workspaces (lanes)".
This commit is contained in:
+39
-39
@@ -508,30 +508,30 @@ Request body shape:
|
||||
| `GET` | `/api/workflows` | Aggregate workflow intelligence (`?status=active\|completed\|...`) |
|
||||
| `GET` | `/api/workflows/session/:id` | Per-session drill-in (tree, timeline, swim lanes, events) |
|
||||
|
||||
### Lanes
|
||||
### Workspaces
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| `POST` | `/api/lanes/worktree` | Create a dashboard-managed git worktree lane; returns `202` while background provisioning finishes. |
|
||||
| `POST` | `/api/lanes/worktree` | Create a dashboard-managed git worktree workspace; returns `202` while background provisioning finishes. |
|
||||
| `GET` | `/api/lanes/:id/preflight?action=reset\|remove\|purge` | Return the exact counts and blockers a destructive confirmation must echo. |
|
||||
| `POST` | `/api/lanes/:id/reset` | Confirmed managed-worktree reset; requires `force` for unpushed commits. |
|
||||
| `POST` | `/api/lanes/:id/remove` | Confirmed managed-worktree teardown and branch deletion, prune of a hand-deleted worktree, or adopted-lane metadata removal; requires `force` for unpushed managed work. |
|
||||
| `POST` | `/api/lanes/:id/purge` | Confirmed deletion of eligible lane sessions, events, and token usage rows. |
|
||||
| `PATCH` | `/api/lanes/:id` | Same-origin guarded partial lane update. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are provisioning facts and are not patchable; an invalid `kind` returns `400 EBADKIND`. |
|
||||
| `DELETE` | `/api/lanes/:id` | Same-origin guarded non-destructive lane-row removal. |
|
||||
| `GET` | `/api/lanes/:id/runtime` | The lane's own application stack: slot, ports, per-service liveness, last boot error. Recomputed per call from pid files and port probes, never cached. A lane with no `.agenthub/profile` returns `{available:false}` with `200`. |
|
||||
| `POST` | `/api/lanes/:id/remove` | Confirmed managed-worktree teardown and branch deletion, prune of a hand-deleted worktree, or adopted-workspace metadata removal; requires `force` for unpushed managed work. |
|
||||
| `POST` | `/api/lanes/:id/purge` | Confirmed deletion of eligible workspace sessions, events, and token usage rows. |
|
||||
| `PATCH` | `/api/lanes/:id` | Same-origin guarded partial workspace update. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are provisioning facts and are not patchable; an invalid `kind` returns `400 EBADKIND`. |
|
||||
| `DELETE` | `/api/lanes/:id` | Same-origin guarded non-destructive workspace-row removal. |
|
||||
| `GET` | `/api/lanes/:id/runtime` | The workspace's own application stack: slot, ports, per-service liveness, last boot error. Recomputed per call from pid files and port probes, never cached. A workspace with no `.agenthub/profile` returns `{available:false}` with `200`. |
|
||||
| `POST` | `/api/lanes/:id/up` | Same-origin guarded stack boot through the profile's `boot` + `health` hooks; returns `202` and finishes in the background. |
|
||||
| `POST` | `/api/lanes/:id/down` | Same-origin guarded stack teardown: recorded pid trees, then a port backstop only when a pid file existed. |
|
||||
| `POST` | `/api/lanes/:id/hook/:name` | Same-origin guarded profile-hook run; `:name` must be in the fixed hook allowlist and `args` is passed as argv. Returns `202`. |
|
||||
| `GET` | `/api/lanes/:id/logs/:svc` | Tail a hook or service log, `realpath`-confined to the lane's log directory. |
|
||||
| `GET` | `/api/lanes/:id/logs/:svc` | Tail a hook or service log, `realpath`-confined to the workspace's log directory. |
|
||||
| `GET` | `/api/lanes/:id/git` | Working-copy facts: branch, head, subject, uncommitted counts and files, plus `hasRemote`/`upstream`/`ahead`/`behind` (null counts when the branch has no upstream), and `unborn` (`true` with `head: null` in a repo with no commit yet). `{available:false}` with `200` for a cwd git cannot read. |
|
||||
| `GET` | `/api/lanes/:id/git/changes?range=work\|branch\|commit[&sha=][&path=][&full=1][&context=]` | The lane's code changes for the Changes panel: a file summary, one file's hunks (`path`), or every file's hunks (`full=1`); `range=commit&sha=` is one commit, `context=` (default 3, max 5000) widens each hunk. Capped at 300 files / 200 KB per file / 1 MB per full read. `400 ENOBASE`, `ENOFILECHANGE`, `EBADRANGE` or `EBADREV`. |
|
||||
| `GET` | `/api/lanes/:id/git/log?limit=&file=` | The lane's recent commits (newest first, limit max 200), optionally only those touching one path. |
|
||||
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the lane), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
|
||||
| `GET` | `/api/lanes/:id/git/changes?range=work\|branch\|commit[&sha=][&path=][&full=1][&context=]` | The workspace's code changes for the Changes panel: a file summary, one file's hunks (`path`), or every file's hunks (`full=1`); `range=commit&sha=` is one commit, `context=` (default 3, max 5000) widens each hunk. Capped at 300 files / 200 KB per file / 1 MB per full read. `400 ENOBASE`, `ENOFILECHANGE`, `EBADRANGE` or `EBADREV`. |
|
||||
| `GET` | `/api/lanes/:id/git/log?limit=&file=` | The workspace's recent commits (newest first, limit max 200), optionally only those touching one path. |
|
||||
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the workspace), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
|
||||
| `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. |
|
||||
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
|
||||
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. `400 EBADPATH` for a path escaping the lane. |
|
||||
| `GET` | `/api/lanes/:id/git/branches` | The lane repo's own local branches + worktrees, for the branch switcher. The repo comes from the lane row, never the query. |
|
||||
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. `400 EBADPATH` for a path escaping the workspace. |
|
||||
| `GET` | `/api/lanes/:id/git/branches` | The workspace repo's own local branches + worktrees, for the branch switcher. The repo comes from the workspace row, never the query. |
|
||||
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
|
||||
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
|
||||
| `DELETE` | `/api/git-credentials/:protocol/:host` | Same-origin guarded delete. `404 ENOTFOUND`. |
|
||||
@@ -549,17 +549,17 @@ Request body shape:
|
||||
| `GET` | `/api/ai-tools/job` | The current or last job (in memory), or `null`. |
|
||||
| `GET` | `/api/ai-tools/versions` | npm-managed check and newest npm version per npm-installed tool, cached 24 h. The AI Tools modal no longer calls it: Update shows for every updater. |
|
||||
| `GET` | `/api/ai-tools/:id/icon` | The server-cached favicon (`server/lib/ai-tool-icons.js`), raster only; `204` when there is none (a normal state — no console error per letter avatar), `404` for an unknown id. |
|
||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root lane, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `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`; saved only after git succeeds and `remember` is set). |
|
||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `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`; saved only after git succeeds and `remember` is set). |
|
||||
|
||||
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
|
||||
an existing absolute git repository. The response lane starts as
|
||||
an existing absolute git repository. The response workspace starts as
|
||||
`status: "provisioning"`; it becomes `idle` on success or remains a removable
|
||||
managed lane with `status: "failed"` and git stderr in `notes` on failure. Use
|
||||
managed workspace with `status: "failed"` and git stderr in `notes` on failure. Use
|
||||
the existing `DELETE /api/lanes/:id` route to forget a failed row that has no
|
||||
worktree.
|
||||
|
||||
Every destructive action requires `{ confirm: true }` and a complete `expect`
|
||||
object, runs under the per-lane lock, and waits for its recorded child's actual
|
||||
object, runs under the per-workspace lock, and waits for its recorded child's actual
|
||||
`exit` event (beyond the five-second SIGKILL escalation) before it clears
|
||||
`run_id` or runs git. A spawn failure is already exited because no child process
|
||||
started and can touch the worktree; a run that never exits returns `500 ERUNTIMEOUT`
|
||||
@@ -570,50 +570,50 @@ purge; missing/incomplete facts return `400 EEXPECT`, and changed facts return
|
||||
`409 ESTALE` with `expected` and `current` diagnostics. A missing force returns
|
||||
`409 EUNPUSHED`.
|
||||
|
||||
`reset` cleans untracked but not ignored files and clears the lane state.
|
||||
`remove` uses guarded git worktree removal and deletes the lane feature branch
|
||||
`reset` cleans untracked but not ignored files and clears the workspace state.
|
||||
`remove` uses guarded git worktree removal and deletes the workspace feature branch
|
||||
before deleting a managed row; when that directory was already deleted by hand it
|
||||
prunes git's stale record instead of failing; for an adopted lane it deletes only
|
||||
prunes git's stale record instead of failing; for an adopted workspace it deletes only
|
||||
the row and never touches the directory. `purge` returns `{ ok: true, purged: {
|
||||
sessions, events, tokenRows } }`. Reset and managed remove invoke the worktree
|
||||
three-check guard; `EOUTSIDEROOT` and `ENOTWORKTREE` remain `400`, while git
|
||||
failures return `500` with `error.stderr`.
|
||||
|
||||
`start` returns `409 ERUNLIVE` when the lane's recorded run is still `spawning`
|
||||
`start` returns `409 ERUNLIVE` when the workspace's recorded run is still `spawning`
|
||||
or `running`: overwriting `run_id` would orphan that child, and a later `reset`
|
||||
would then `git clean -fd` a directory the orphan is still writing into. It
|
||||
returns the same code when the lane's tmux pane is running an agent and the
|
||||
returns the same code when the workspace's tmux pane is running an agent and the
|
||||
request carried a `prompt` or `resumeSessionId` — `pty-run.js` will not type
|
||||
over a live agent, and adopting the pane silently would answer 200 for a resume
|
||||
that never spawned. `POST /api/run` maps that code to `409` too; a start with
|
||||
neither field still adopts the pane.
|
||||
|
||||
Besides the stage a skill declares explicitly, every lane also carries a
|
||||
Besides the stage a skill declares explicitly, every workspace also carries a
|
||||
server-inferred `detected_stage`: `server/lib/stage-detect.js` matches each
|
||||
hook event's tool name/input against the pipeline template's per-node
|
||||
`detect` rules, and `server/routes/hooks.js`'s `touchLaneFromHook` calls it on
|
||||
every hook before any other lane bookkeeping. It is forward-only, yields to a
|
||||
every hook before any other workspace bookkeeping. It is forward-only, yields to a
|
||||
declared stage that is already ahead — unless that declaration is the
|
||||
pipeline's last node, where the pipeline is finished and a later detection is
|
||||
new work — and, the one rule that matters, never counts as evidence and never
|
||||
renders a node as `done` (see `docs/LANES.md#stage-detection`).
|
||||
|
||||
A `codex` lane carries no pipeline, so nothing detects or advances its stage.
|
||||
A `codex` workspace carries no pipeline, so nothing detects or advances its stage.
|
||||
Codex's hooks are opt-in (`server/lib/codex-hooks.js`, installed from Settings →
|
||||
Codex → Overview and trusted by the user inside Codex); a hook posted with
|
||||
`tool: "codex"` reaches only the codex lane at its cwd and creates a
|
||||
`tool: "codex"` reaches only the codex workspace at its cwd and creates a
|
||||
`tool = 'codex'` session (see ARCHITECTURE.md, **Codex hooks**). Otherwise
|
||||
`server/lib/codex-ingest.js` only mirrors its `status` — skipping a lane whose
|
||||
`server/lib/codex-ingest.js` only mirrors its `status` — skipping a workspace whose
|
||||
hooks are current (an event for its session in the last two minutes) —
|
||||
between `idle` and `running` from the mtime of Codex's own rollout journals
|
||||
under `$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
|
||||
`DASHBOARD_CODEX_POLL_MS` ms (default 15000, `0` disables), under the guard hook
|
||||
ingestion uses (no `run_id`, only from `idle`/`running`); it writes nothing else.
|
||||
`server/lib/codex-usage.js` reads the same journals for every Codex session
|
||||
(lane or not) into `sessions` (`tool = 'codex'`) and `token_usage`, tracking
|
||||
(workspace or not) into `sessions` (`tool = 'codex'`) and `token_usage`, tracking
|
||||
each file's progress in `codex_rollouts` (see ARCHITECTURE.md).
|
||||
|
||||
An `antigravity` lane carries no pipeline either.
|
||||
An `antigravity` workspace carries no pipeline either.
|
||||
- **Sessions and tokens:** `server/lib/antigravity-usage.js` reads agy's
|
||||
per-conversation SQLite files (`~/.gemini/antigravity-cli/conversations/<id>.db`)
|
||||
every `DASHBOARD_ANTIGRAVITY_POLL_MS` ms (default 15000, `0` disables). They
|
||||
@@ -621,13 +621,13 @@ An `antigravity` lane carries no pipeline either.
|
||||
`antigravity_conversations` holds the mtime read for each file.
|
||||
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
|
||||
minutes (with hook events) or two (without) is settled to `completed`, its
|
||||
agents too, and sets its lane idle. That lane must be one the hooks left
|
||||
agents too, and sets its workspace idle. That workspace must be one the hooks left
|
||||
running with no dashboard pane.
|
||||
- **Hooks:** opt-in. `server/lib/antigravity-hooks.js` installs them from
|
||||
Settings → Antigravity: the named hook `agenthub` in
|
||||
`~/.gemini/config/hooks.json`, with PreInvocation, PostToolUse and Stop,
|
||||
never PreToolUse. A hook posted with `tool: "antigravity"` reaches only the
|
||||
antigravity lane at its workspace (see ARCHITECTURE.md, **Antigravity
|
||||
antigravity workspace at its workspace (see ARCHITECTURE.md, **Antigravity
|
||||
Sessions and Hooks**).
|
||||
|
||||
### Remote Data Sources
|
||||
@@ -682,9 +682,9 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
|
||||
| `POST` | `/api/settings/reinstall-hooks`| Reinstall Claude Code hooks |
|
||||
| `POST` | `/api/settings/reset-pricing` | Reset pricing table to defaults, then run the automatic pricing sync at once (waiting up to 3 s). Body `{scope: "claude"}` (the Claude Code tab's **Reset Defaults**) deletes and reseeds only `claude-…` rows; no body resets every row |
|
||||
| `GET` | `/api/settings/claude-home` | `{claude_home, env_owned}` — `env_owned` when the real environment or the checkout's `.env` set `CLAUDE_HOME` (a saved value is overridden at the next start) |
|
||||
| `PUT` | `/api/settings/claude-home` | `{path}` → validate, apply, persist to AgentHub's settings file. 400 `EBADHOME` (is or contains the OS home) / `INVALID_PATH`, 500 `ESAVE`, 403 `EBADORIGIN` (`sameOriginGuard`). Answers `routers: {missing: [...]}` — lanes naming an AI Router profile the new `<CLAUDE_HOME>/agenthub-routers/` store lacks (they start on native) |
|
||||
| `PUT` | `/api/settings/claude-home` | `{path}` → validate, apply, persist to AgentHub's settings file. 400 `EBADHOME` (is or contains the OS home) / `INVALID_PATH`, 500 `ESAVE`, 403 `EBADORIGIN` (`sameOriginGuard`). Answers `routers: {missing: [...]}` — workspaces naming an AI Router profile the new `<CLAUDE_HOME>/agenthub-routers/` store lacks (they start on native) |
|
||||
| `GET` | `/api/settings/codex-home` | `{codex_home, env_owned}` |
|
||||
| `PUT` | `/api/settings/codex-home` | Same checks as `claude-home`; answers `routers: {updated, failed}` after re-writing every codex lane's AI Router layer into the new home (`reapplyCodexLayers`) |
|
||||
| `PUT` | `/api/settings/codex-home` | Same checks as `claude-home`; answers `routers: {updated, failed}` after re-writing every codex workspace's AI Router layer into the new home (`reapplyCodexLayers`) |
|
||||
| `GET` | `/api/settings/codex-hooks` | Status of AgentHub's hooks in `$CODEX_HOME/hooks.json` (installed, per-event, Codex's trust as read from `config.toml`) |
|
||||
| `POST` | `/api/settings/codex-hooks` | Install or reinstall them (`sameOriginGuard`). 400 `EBADJSON` / `EUNSUPPORTED` / `EFOREIGN` (the forwarder path holds a file that is not AgentHub's) |
|
||||
| `DELETE` | `/api/settings/codex-hooks` | Remove AgentHub's hooks only (`sameOriginGuard`); a foreign forwarder is left alone |
|
||||
@@ -726,24 +726,24 @@ Reads — and carefully gated mutations for low-risk text-file artifacts — for
|
||||
| `POST` | `/api/cc-config/profiles/activate` | Make a saved profile the live account — swaps `claudeAiOauth` + `oauthAccount`/`userID` and nothing else. Body: `{ name }`. Re-snapshots the outgoing account first (`savedOutgoing`); `EUNSAVED` if it was never saved |
|
||||
| `DELETE` | `/api/cc-config/profiles` | Forget a saved profile. Body: `{ name }`. `EACTIVE` only for the **last** saved copy of the live account — a redundant copy stays deletable |
|
||||
|
||||
### Run a lane's CLI (`/api/run`)
|
||||
### Run a workspace's CLI (`/api/run`)
|
||||
|
||||
HTTP surface for the tmux-backed terminal run: one session per lane, named `agenthub-lane-<laneId>`, running the CLI that lane declares — any AI Tools catalog id: `claude`/`codex` through their own argv builders, every other tool by its detected absolute path with the args its catalog file gives (an `after-start` prompt is pasted into the pane through a tmux buffer once the CLI is up; `bypass: true` adds the file's bypass args for that run only). There is no in-memory handle map — every read is computed from `tmux list-sessions`, so a session killed out-of-band self-corrects on the next call. Every route enforces a same-origin / loopback-Origin guard against browser CSRF.
|
||||
HTTP surface for the tmux-backed terminal run: one session per workspace, named `agenthub-lane-<laneId>`, running the CLI that workspace declares — any AI Tools catalog id: `claude`/`codex` through their own argv builders, every other tool by its detected absolute path with the args its catalog file gives (an `after-start` prompt is pasted into the pane through a tmux buffer once the CLI is up; `bypass: true` adds the file's bypass args for that run only). There is no in-memory handle map — every read is computed from `tmux list-sessions`, so a session killed out-of-band self-corrects on the next call. Every route enforces a same-origin / loopback-Origin guard against browser CSRF.
|
||||
|
||||
| Method | Path | Description |
|
||||
| -------- | ----------------------------- | ----------- |
|
||||
| `GET` | `/api/run` | `{ items }` — every live run handle, recomputed from tmux |
|
||||
| `GET` | `/api/run/history?limit=&laneId=` | Persisted run history from `dashboard_runs` (default 50, max 500, newest first); each row carries `isLive` and the `tool` it was spawned with. `laneId` narrows to one lane |
|
||||
| `GET` | `/api/run/history?limit=&laneId=` | Persisted run history from `dashboard_runs` (default 50, max 500, newest first); each row carries `isLive` and the `tool` it was spawned with. `laneId` narrows to one workspace |
|
||||
| `GET` | `/api/run/binary[?tool=]` | Probe whether the CLI is on `PATH`. `?tool=codex` probes Codex; omitted or `?tool=claude` probes Claude; anything else is `400 EBADTOOL`. Returns `{ found, path }` |
|
||||
| `GET` | `/api/run/tmux` | `{ available }` — whether the `tmux` binary exists |
|
||||
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
|
||||
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<tmpdir>/agenthub-drops-<uid>/` with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
|
||||
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
|
||||
| `POST` | `/api/run` | Create-or-adopt the lane's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the lane row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
|
||||
| `POST` | `/api/run` | Create-or-adopt the workspace's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the workspace row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
|
||||
| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
|
||||
| `DELETE` | `/api/run/:id` | Kill the tmux session |
|
||||
|
||||
Codex-only spawn options (`sandbox`, `approval`, `resumeLast`) are accepted by the lane action `POST /api/lanes/:id/start`, not by `POST /api/run` — see [`docs/API.md`](../docs/API.md).
|
||||
Codex-only spawn options (`sandbox`, `approval`, `resumeLast`) are accepted by the workspace action `POST /api/lanes/:id/start`, not by `POST /api/run` — see [`docs/API.md`](../docs/API.md).
|
||||
|
||||
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`) and its Codex counterpart `codex_config_changed` (broadcast by `routes/codex-config.js` after every successful file mutation and CLI-backed mcp/plugin/marketplace action — always `source: "dashboard"`, there is no `$CODEX_HOME` filesystem watcher).
|
||||
|
||||
@@ -1513,7 +1513,7 @@ test("POST /api/hooks/event ingests hook payload", async () => {
|
||||
|
||||
## Terminal Access (`agenthub` CLI)
|
||||
|
||||
Everything this server exposes over REST is also reachable from a terminal via the repo's dependency-free `agenthub` CLI (`bin/agenthub.js`, linked by `npm run setup`): monitoring (`health`/`stats`/`kanban`/`tail`), data browsing, analytics/workflows/cost, lane adoption and managed-worktree provisioning (`lanes add --cwd` / `lanes add --repo`), fact-confirmed lane reset/removal/purge (`lanes reset|remove|purge <id> --yes`), alerts + webhook tests, pricing CRUD, imports, and administration (`doctor`/`export`/`cleanup`/`reinstall-hooks`/`update-check`/`clear-data --yes`). It resolves the live server through the same `~/.claude/.agenthub.json` registry the hook handler uses. See [docs/CLI.md](../docs/CLI.md).
|
||||
Everything this server exposes over REST is also reachable from a terminal via the repo's dependency-free `agenthub` CLI (`bin/agenthub.js`, linked by `npm run setup`): monitoring (`health`/`stats`/`kanban`/`tail`), data browsing, analytics/workflows/cost, workspace adoption and managed-worktree provisioning (`lanes add --cwd` / `lanes add --repo`), fact-confirmed workspace reset/removal/purge (`lanes reset|remove|purge <id> --yes`), alerts + webhook tests, pricing CRUD, imports, and administration (`doctor`/`export`/`cleanup`/`reinstall-hooks`/`update-check`/`clear-data --yes`). It resolves the live server through the same `~/.claude/.agenthub.json` registry the hook handler uses. See [docs/CLI.md](../docs/CLI.md).
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ describe("agenthub stage", () => {
|
||||
it("exits non-zero when no lane owns the cwd", async () => {
|
||||
const r = await cli(["stage", "review"], os.tmpdir());
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(`${r.stdout}${r.stderr}`, /no lane/i);
|
||||
assert.match(`${r.stdout}${r.stderr}`, /no workspace/i);
|
||||
});
|
||||
|
||||
it("refuses a stage name matching no pipeline node", async () => {
|
||||
@@ -296,7 +296,7 @@ describe("agenthub stage in a directory shared by two lanes", () => {
|
||||
await post("/api/lanes", { cwd: solo, title: "solo claude" });
|
||||
const r = await cli(["stage", "plan", "--tool", "codex"], solo, NO_HARNESS);
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(`${r.stdout}${r.stderr}`, /no codex lane/i);
|
||||
assert.match(`${r.stdout}${r.stderr}`, /no codex workspace/i);
|
||||
});
|
||||
|
||||
it("--lane flag with explicit ID beats cwd resolution and tool ambiguity", async () => {
|
||||
@@ -312,7 +312,7 @@ describe("agenthub stage in a directory shared by two lanes", () => {
|
||||
// Other commands like `agenthub stage` have their own positional argument and don't support it.
|
||||
const r = await cli(["lanes", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, new RegExp(`lane #${codexLane.id}`));
|
||||
assert.match(r.stdout, new RegExp(`workspace #${codexLane.id}`));
|
||||
});
|
||||
|
||||
it("non-numeric AGENTHUB_LANE_ID is ignored", async () => {
|
||||
@@ -363,7 +363,7 @@ describe("agenthub lanes add", () => {
|
||||
try {
|
||||
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "CLI added"], addDir);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /Created lane/i);
|
||||
assert.match(r.stdout, /Created workspace/i);
|
||||
|
||||
const list = await cli(["lanes"], addDir);
|
||||
assert.equal(list.status, 0, list.stderr);
|
||||
@@ -378,7 +378,7 @@ describe("agenthub lanes add", () => {
|
||||
try {
|
||||
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /Created lane/i);
|
||||
assert.match(r.stdout, /Created workspace/i);
|
||||
|
||||
const details = await get(`/api/lanes`);
|
||||
const lane = details.body.lanes.find((l) => l.title === "Default pipeline");
|
||||
@@ -406,7 +406,7 @@ describe("agenthub lanes add", () => {
|
||||
addDir
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /Created lane/i);
|
||||
assert.match(r.stdout, /Created workspace/i);
|
||||
|
||||
const details = await get(`/api/lanes`);
|
||||
const lane = details.body.lanes.find((l) => l.title === "Custom pipeline");
|
||||
@@ -452,7 +452,7 @@ describe("agenthub lanes add", () => {
|
||||
SOURCE_REPO
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /Worktree lane #\d+ ready: CLI worktree/);
|
||||
assert.match(r.stdout, /Worktree workspace #\d+ ready: CLI worktree/);
|
||||
|
||||
const list = await get("/api/lanes");
|
||||
const lane = list.body.lanes.find((l) => l.title === "CLI worktree");
|
||||
@@ -479,7 +479,7 @@ describe("agenthub lanes add", () => {
|
||||
emptyRepo
|
||||
);
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(`${r.stdout}${r.stderr}`, /Worktree lane #\d+ failed:/);
|
||||
assert.match(`${r.stdout}${r.stderr}`, /Worktree workspace #\d+ failed:/);
|
||||
assert.match(`${r.stdout}${r.stderr}`, /fatal:|ambiguous argument|unknown revision/i);
|
||||
assert.doesNotMatch(r.stdout, /ready/i);
|
||||
} finally {
|
||||
@@ -515,7 +515,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
|
||||
const r = await cli(["lanes", "reset", String(lane.id)], SOURCE_REPO);
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(r.stdout, /Preflight for reset lane #\d+:/);
|
||||
assert.match(r.stdout, /Preflight for reset workspace #\d+:/);
|
||||
assert.match(r.stdout, /dirty\s+0/);
|
||||
assert.match(r.stdout, /untracked\s+1/);
|
||||
assert.match(`${r.stdout}${r.stderr}`, /without --yes/);
|
||||
@@ -528,7 +528,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
|
||||
const r = await cli(["lanes", "reset", String(lane.id), "--yes"], SOURCE_REPO);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, new RegExp(`Reset lane #${lane.id}`));
|
||||
assert.match(r.stdout, new RegExp(`Reset workspace #${lane.id}`));
|
||||
assert.equal(fs.existsSync(path.join(lane.cwd, "dirty.txt")), false);
|
||||
|
||||
const preflight = await get(`/api/lanes/${lane.id}/preflight?action=reset`);
|
||||
@@ -554,7 +554,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
const [winner, loser] = a.status === 0 ? [a, b] : [b, a];
|
||||
assert.equal(winner.status, 0, winner.stderr);
|
||||
assert.notEqual(loser.status, 0);
|
||||
assert.match(`${loser.stdout}${loser.stderr}`, /lane state changed since preflight/);
|
||||
assert.match(`${loser.stdout}${loser.stderr}`, /workspace state changed since preflight/);
|
||||
assert.match(loser.stdout, /Expected preflight:/);
|
||||
assert.match(loser.stdout, /Current preflight:/);
|
||||
});
|
||||
@@ -564,7 +564,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
assert.equal(fs.existsSync(lane.cwd), true);
|
||||
const r = await cli(["lanes", "remove", String(lane.id), "--yes"], SOURCE_REPO);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, new RegExp(`Removed lane #${lane.id}`));
|
||||
assert.match(r.stdout, new RegExp(`Removed workspace #${lane.id}`));
|
||||
assert.match(r.stdout, /left for you to delete yourself/);
|
||||
assert.equal(fs.existsSync(lane.cwd), true);
|
||||
assert.equal((await get(`/api/lanes/${lane.id}`)).status, 404);
|
||||
@@ -590,7 +590,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
// unreachable from the terminal.
|
||||
const removed = await cli(["lanes", "remove", String(id), "--yes"], adoptedDir);
|
||||
assert.equal(removed.status, 0, removed.stderr);
|
||||
assert.match(removed.stdout, new RegExp(`Removed lane #${id}`));
|
||||
assert.match(removed.stdout, new RegExp(`Removed workspace #${id}`));
|
||||
assert.equal((await get(`/api/lanes/${id}`)).status, 404);
|
||||
assert.equal(fs.existsSync(adoptedDir), true);
|
||||
assert.equal(fs.readFileSync(preserved, "utf8"), "do not delete\n");
|
||||
@@ -610,7 +610,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
assert.match(
|
||||
r.stdout,
|
||||
new RegExp(
|
||||
`Purged lane #${lane.id}: ${sessions} sessions, ${events} events, ${tokenRows} token rows`
|
||||
`Purged workspace #${lane.id}: ${sessions} sessions, ${events} events, ${tokenRows} token rows`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -131,11 +131,11 @@ async function isSecretOnDisk(dir, file) {
|
||||
*/
|
||||
function lanePath(file) {
|
||||
if (typeof file !== "string" || !file || file.includes("\0")) {
|
||||
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
|
||||
throw badRequest("EBADPATH", "path must be a relative path inside the workspace");
|
||||
}
|
||||
// A backslash is an ordinary file-name byte to git on POSIX, not a separator.
|
||||
if (file.startsWith("/") || file.split("/").includes("..")) {
|
||||
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
|
||||
throw badRequest("EBADPATH", "path must be a relative path inside the workspace");
|
||||
}
|
||||
const norm = path.posix
|
||||
.normalize(file)
|
||||
@@ -160,7 +160,7 @@ async function resolveInLane(dir, file) {
|
||||
};
|
||||
// Lexically first, so `../x` is EBADPATH whether or not x exists.
|
||||
if (escapes(path.resolve(dir), path.resolve(dir, file))) {
|
||||
throw badRequest("EBADPATH", "path escapes the lane directory");
|
||||
throw badRequest("EBADPATH", "path escapes the workspace directory");
|
||||
}
|
||||
const root = await fs.promises.realpath(dir);
|
||||
let real;
|
||||
@@ -169,7 +169,7 @@ async function resolveInLane(dir, file) {
|
||||
} catch {
|
||||
throw badRequest("ENOFILE", "no such file in the working tree");
|
||||
}
|
||||
if (escapes(root, real)) throw badRequest("EBADPATH", "path escapes the lane directory");
|
||||
if (escapes(root, real)) throw badRequest("EBADPATH", "path escapes the workspace directory");
|
||||
if (await isSecretOnDisk(root, file))
|
||||
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
|
||||
return real;
|
||||
@@ -821,7 +821,7 @@ async function listTree(dir, { subdir = "" } = {}) {
|
||||
const clean = String(subdir || "")
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.trim();
|
||||
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the lane");
|
||||
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the workspace");
|
||||
const [{ all, cut }, st] = await Promise.all([
|
||||
lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS),
|
||||
treeStatus(dir),
|
||||
|
||||
@@ -200,7 +200,7 @@ async function assertNothingStagedOutside(dir) {
|
||||
if (outside) {
|
||||
throw bad(
|
||||
"ESTAGEDOUTSIDE",
|
||||
`refusing to commit: ${outside} is staged outside this lane's directory`
|
||||
`refusing to commit: ${outside} is staged outside this workspace's directory`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -505,7 +505,7 @@ async function mergeAction(dir, action, body) {
|
||||
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
|
||||
throw bad(
|
||||
"ESUBDIR",
|
||||
"a merge changes the whole repository; run it from a lane at the repo root"
|
||||
"a merge changes the whole repository; run it from a workspace at the repo root"
|
||||
);
|
||||
}
|
||||
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
|
||||
|
||||
@@ -150,7 +150,7 @@ function archiveActiveFeature(laneId) {
|
||||
function activateFeature(laneId, rawSlug, options = {}) {
|
||||
const slug = canonicalizeSlug(rawSlug);
|
||||
const lane = lanesLib.getLane(laneId);
|
||||
if (!lane) throw Object.assign(new Error(`no lane ${laneId}`), { code: "ENOLANE" });
|
||||
if (!lane) throw Object.assign(new Error(`no workspace ${laneId}`), { code: "ENOLANE" });
|
||||
|
||||
const current = lane.active_feature_id ? getFeatureById(lane.active_feature_id) : null;
|
||||
if (current && current.slug === slug) {
|
||||
|
||||
@@ -59,7 +59,7 @@ function requireProfile(lane) {
|
||||
if (!profile) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`lane has no .agenthub/profile — looked in: ${profileSearchPaths(lane).join(", ")}`
|
||||
`workspace has no .agenthub/profile — looked in: ${profileSearchPaths(lane).join(", ")}`
|
||||
),
|
||||
{ code: "ENOPROFILE", searched: profileSearchPaths(lane) }
|
||||
);
|
||||
@@ -176,7 +176,7 @@ function resolveLaneDirEntry(lane, entry) {
|
||||
const target = path.resolve(lane.cwd, entry);
|
||||
const relative = path.relative(lane.cwd, target);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throw Object.assign(new Error(`LANE_DIRS entry escapes the lane: ${entry}`), {
|
||||
throw Object.assign(new Error(`LANE_DIRS entry escapes the workspace: ${entry}`), {
|
||||
code: "EBADLANEDIR",
|
||||
entry,
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ function allocateSlot(laneId) {
|
||||
lanesLib.setProvisioningFacts(laneId, { slot });
|
||||
return slot;
|
||||
}
|
||||
throw Object.assign(new Error(`all ${limit} lane slots are in use`), { code: "ESLOTS" });
|
||||
throw Object.assign(new Error(`all ${limit} workspace slots are in use`), { code: "ESLOTS" });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -282,7 +282,7 @@ async function continueSync(lane, profile, branchArg) {
|
||||
|
||||
const current = (await git(lane.cwd, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
||||
if (current !== branch) {
|
||||
throw badBranch(`--continue: lane is not on '${branch}' (currently on '${current}')`);
|
||||
throw badBranch(`--continue: workspace is not on '${branch}' (currently on '${current}')`);
|
||||
}
|
||||
|
||||
const unresolved = await unmergedFiles(lane.cwd);
|
||||
|
||||
+3
-3
@@ -480,7 +480,7 @@ function appendStageEvent(lane, nodeId, { source, result, evidence } = {}) {
|
||||
|
||||
function setStage(id, { stage, status, evidence, note, result } = {}) {
|
||||
const lane = getLane(id);
|
||||
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
|
||||
if (!lane) throw Object.assign(new Error(`no workspace ${id}`), { code: "ENOLANE" });
|
||||
const next = stage || lane.stage;
|
||||
const changed = next !== lane.stage;
|
||||
const stages = { ...lane.stages };
|
||||
@@ -543,7 +543,7 @@ function setStage(id, { stage, status, evidence, note, result } = {}) {
|
||||
*/
|
||||
function recordDetection(id, { nodeId, signal } = {}) {
|
||||
const lane = getLane(id);
|
||||
if (!lane) throw Object.assign(new Error(`no lane ${id}`), { code: "ENOLANE" });
|
||||
if (!lane) throw Object.assign(new Error(`no workspace ${id}`), { code: "ENOLANE" });
|
||||
const pipeline = getPipeline(lane.pipeline);
|
||||
const nodeIdx = phaseIdx(pipeline, nodeId);
|
||||
if (nodeIdx === -1) return { written: false, reason: "unknown-node" };
|
||||
@@ -839,7 +839,7 @@ function hasActiveLaneSession(lane) {
|
||||
*/
|
||||
function purgeLaneSessions(laneId) {
|
||||
const lane = getLane(laneId);
|
||||
if (!lane) throw Object.assign(new Error(`no lane ${laneId}`), { code: "ENOLANE" });
|
||||
if (!lane) throw Object.assign(new Error(`no workspace ${laneId}`), { code: "ENOLANE" });
|
||||
|
||||
const result = { sessions: 0, events: 0, tokenRows: 0 };
|
||||
|
||||
|
||||
+2
-2
@@ -166,14 +166,14 @@ function proofFile(laneId, slug, group, file) {
|
||||
*/
|
||||
function deleteProof(laneId, { slug, group, images } = {}) {
|
||||
const lane = lanesLib.getLane(laneId);
|
||||
if (!lane) throw Object.assign(new Error(`no lane ${laneId}`), { code: "ENOLANE" });
|
||||
if (!lane) throw Object.assign(new Error(`no workspace ${laneId}`), { code: "ENOLANE" });
|
||||
if (badName(slug)) throw Object.assign(new Error("invalid slug"), { code: "EBADPATH" });
|
||||
|
||||
let base;
|
||||
try {
|
||||
base = fs.realpathSync(proofRoot(lane));
|
||||
} catch {
|
||||
throw Object.assign(new Error("no proof for this lane"), { code: "ENOFEATURE" });
|
||||
throw Object.assign(new Error("no proof for this workspace"), { code: "ENOFEATURE" });
|
||||
}
|
||||
const inBase = (p) => p === base || p.startsWith(base + path.sep);
|
||||
|
||||
|
||||
@@ -388,7 +388,7 @@ function spawnRun(args) {
|
||||
else if (resumeSessionId || initialPrompt) {
|
||||
throw makeErr(
|
||||
"ERUNLIVE",
|
||||
`lane ${laneId}'s pane is already running \`${tmux.paneCommand(id) || "a process"}\` — stop it before resuming a session or sending a prompt`
|
||||
`workspace ${laneId}'s pane is already running \`${tmux.paneCommand(id) || "a process"}\` — stop it before resuming a session or sending a prompt`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ function applyRouterToLane(lane, profileName) {
|
||||
// AI tool runs on its vendor's own settings, so native is the one fit.
|
||||
if (!ROUTER_CAPABLE.has(lane.tool || "claude")) {
|
||||
if (profile.builtin) return { tool: lane.tool, file: null, profile: profile.name };
|
||||
throw Object.assign(new Error(`a ${lane.tool} lane cannot use an AI Router`), {
|
||||
throw Object.assign(new Error(`a ${lane.tool} workspace cannot use an AI Router`), {
|
||||
code: "EUNSUPPORTED",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ async function deleteBranchSafely(sourceRepo, branch, baseBranch) {
|
||||
*/
|
||||
function assertManaged(lane) {
|
||||
if (lane.kind !== "managed") {
|
||||
const err = new Error(`lane kind is ${lane.kind}, not managed`);
|
||||
const err = new Error(`workspace kind is ${lane.kind}, not managed`);
|
||||
err.code = "ENOTMANAGED";
|
||||
throw err;
|
||||
}
|
||||
@@ -348,7 +348,7 @@ async function assertDestroyable(lane) {
|
||||
resolvedCwd = fs.realpathSync(lane.cwd);
|
||||
} catch {
|
||||
// Path doesn't exist, which means it's not a live worktree
|
||||
const err = new Error(`lane cwd does not exist: ${lane.cwd}`);
|
||||
const err = new Error(`workspace cwd does not exist: ${lane.cwd}`);
|
||||
err.code = "EOUTSIDEROOT";
|
||||
throw err;
|
||||
}
|
||||
@@ -365,7 +365,9 @@ async function assertDestroyable(lane) {
|
||||
// Check that cwd is inside LANES_ROOT on a path boundary
|
||||
const relativePath = path.relative(resolvedRoot, resolvedCwd);
|
||||
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
||||
const err = new Error(`lane cwd is outside LANES_ROOT: ${resolvedCwd} not in ${resolvedRoot}`);
|
||||
const err = new Error(
|
||||
`workspace cwd is outside LANES_ROOT: ${resolvedCwd} not in ${resolvedRoot}`
|
||||
);
|
||||
err.code = "EOUTSIDEROOT";
|
||||
throw err;
|
||||
}
|
||||
@@ -384,7 +386,7 @@ async function assertDestroyable(lane) {
|
||||
}
|
||||
});
|
||||
if (!exists) {
|
||||
const err = new Error(`lane is not listed as a worktree in ${lane.source_repo}`);
|
||||
const err = new Error(`workspace is not listed as a worktree in ${lane.source_repo}`);
|
||||
err.code = "ENOTWORKTREE";
|
||||
throw err;
|
||||
}
|
||||
@@ -520,7 +522,7 @@ async function removeWorktree(lane) {
|
||||
if (!fs.existsSync(cwd)) {
|
||||
assertManaged(lane);
|
||||
if (!isInsideLanesRoot(cwd)) {
|
||||
const err = new Error(`lane cwd is outside LANES_ROOT: ${cwd} not in ${LANES_ROOT}`);
|
||||
const err = new Error(`workspace cwd is outside LANES_ROOT: ${cwd} not in ${LANES_ROOT}`);
|
||||
err.code = "EOUTSIDEROOT";
|
||||
throw err;
|
||||
}
|
||||
|
||||
+49
-30
@@ -229,7 +229,9 @@ router.get("/pipeline-stats", (req, res) => {
|
||||
if (laneFilter !== undefined && laneFilter !== "" && laneFilter !== "all") {
|
||||
laneId = Number(laneFilter);
|
||||
if (!Number.isInteger(laneId) || laneId < 1) {
|
||||
return res.status(400).json({ error: { code: "EBADLANE", message: "lane must be an id" } });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADLANE", message: "workspace must be an id" } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,7 +502,8 @@ router.get("/browse", (req, res) => {
|
||||
|
||||
router.get("/:id", (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
res.json({ lane: payload(lane) });
|
||||
});
|
||||
|
||||
@@ -523,7 +526,7 @@ router.post("/", sameOriginGuard, (req, res) => {
|
||||
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: { code: "EDUPCWD", message: "a lane already owns that cwd" } });
|
||||
.json({ error: { code: "EDUPCWD", message: "a workspace already owns that cwd" } });
|
||||
}
|
||||
res.status(500).json({ error: { message: err.message } });
|
||||
}
|
||||
@@ -531,7 +534,7 @@ router.post("/", sameOriginGuard, (req, res) => {
|
||||
|
||||
router.patch("/:id", sameOriginGuard, (req, res) => {
|
||||
if (!lanesLib.getLane(req.params.id)) {
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
let lane;
|
||||
try {
|
||||
@@ -558,7 +561,7 @@ router.patch("/:id", sameOriginGuard, (req, res) => {
|
||||
router.post("/:id/stage", sameOriginGuard, (req, res) => {
|
||||
const current = lanesLib.getLane(req.params.id);
|
||||
if (!current) {
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
// A stage naming no node of this lane's pipeline used to be stored and then
|
||||
// silently ignored by every reader — `agenthub stage test` on a ship-feature
|
||||
@@ -575,7 +578,7 @@ router.post("/:id/stage", sameOriginGuard, (req, res) => {
|
||||
error: {
|
||||
code: "ENOPIPELINE",
|
||||
message:
|
||||
"no pipeline template is installed, so this lane has no stages to declare " +
|
||||
"no pipeline template is installed, so this workspace has no stages to declare " +
|
||||
"(drop a template into DASHBOARD_PIPELINES_DIR to turn stages on)",
|
||||
stages: [],
|
||||
},
|
||||
@@ -600,7 +603,8 @@ router.post("/:id/stage", sameOriginGuard, (req, res) => {
|
||||
|
||||
router.get("/:id/preflight", async (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
const action = String(req.query.action || "");
|
||||
if (!["reset", "remove", "purge"].includes(action)) {
|
||||
return res
|
||||
@@ -623,13 +627,15 @@ router.get("/:id/preflight", async (req, res) => {
|
||||
*/
|
||||
router.get("/:id/features", (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
res.json({ features: laneFeatures.listFeatures(lane.id).map(featurePayload) });
|
||||
});
|
||||
|
||||
router.get("/:id/features/:slug", (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
const feature = laneFeatures.getFeature(lane.id, req.params.slug);
|
||||
if (!feature) {
|
||||
return res.status(404).json({ error: { code: "ENOFEATURE", message: "no such feature" } });
|
||||
@@ -639,7 +645,8 @@ router.get("/:id/features/:slug", (req, res) => {
|
||||
|
||||
router.post("/:id/features/activate", sameOriginGuard, (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
const slug = typeof req.body?.slug === "string" ? req.body.slug : "";
|
||||
if (!slug) {
|
||||
return res.status(400).json({ error: { code: "EBADSLUG", message: "slug is required" } });
|
||||
@@ -660,7 +667,8 @@ router.post("/:id/features/activate", sameOriginGuard, (req, res) => {
|
||||
|
||||
router.get("/:id/proof", (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
res.json({ features: proofLib.listProof(lane.id) });
|
||||
});
|
||||
|
||||
@@ -677,7 +685,8 @@ router.get("/:id/proof/:slug/:group/:file", (req, res) => {
|
||||
|
||||
router.delete("/:id/proof/:slug", sameOriginGuard, (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
try {
|
||||
const result = proofLib.deleteProof(lane.id, {
|
||||
slug: req.params.slug,
|
||||
@@ -698,7 +707,8 @@ router.delete("/:id/proof/:slug", sameOriginGuard, (req, res) => {
|
||||
|
||||
router.post("/:id/proof-link", sameOriginGuard, (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
res.json(proofLib.ensureProofLink(lane));
|
||||
});
|
||||
|
||||
@@ -712,7 +722,8 @@ router.post("/:id/proof-link", sameOriginGuard, (req, res) => {
|
||||
*/
|
||||
router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
try {
|
||||
res.json(await syncMcp(lane));
|
||||
} catch (err) {
|
||||
@@ -734,7 +745,8 @@ router.post("/:id/mcp/sync", sameOriginGuard, async (req, res) => {
|
||||
*/
|
||||
router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
const profile = (req.body || {}).profile;
|
||||
if (typeof profile !== "string" || !profile) {
|
||||
return res.status(400).json({ error: { code: "EBADREQ", message: "profile is required" } });
|
||||
@@ -775,7 +787,8 @@ router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
|
||||
*/
|
||||
router.post("/:id/profile/init", sameOriginGuard, (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
|
||||
const facts = detectNode(lane.cwd);
|
||||
if (!facts) {
|
||||
@@ -800,7 +813,8 @@ router.post("/:id/profile/init", sameOriginGuard, (req, res) => {
|
||||
*/
|
||||
router.get("/:id/integrations/:name", (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
res.json({ enabled: isIntegrationEnabled(lane, req.params.name) });
|
||||
});
|
||||
|
||||
@@ -821,7 +835,8 @@ router.get("/:id/integrations/:name", (req, res) => {
|
||||
*/
|
||||
router.get("/:id/git", async (req, res) => {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
try {
|
||||
res.json({ available: true, ...(await gitFacts(lane.cwd)) });
|
||||
} catch {
|
||||
@@ -1138,7 +1153,7 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: "EEMPTYREPO",
|
||||
message: "sourceRepo has no commits yet — add it as a Repo lane and commit first",
|
||||
message: "sourceRepo has no commits yet — add it as a Repo workspace and commit first",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1201,7 +1216,7 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
|
||||
if (err.code === "SQLITE_CONSTRAINT_UNIQUE" || String(err.message).includes("UNIQUE")) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: { code: "EDUPCWD", message: "a lane already owns that cwd" } });
|
||||
.json({ error: { code: "EDUPCWD", message: "a workspace already owns that cwd" } });
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
@@ -1273,7 +1288,7 @@ function assertExpectedPreflight(action, current, expected) {
|
||||
}
|
||||
const changed = fields.some((field) => current[field] !== expected[field]);
|
||||
if (changed) {
|
||||
const err = lifecycleError("ESTALE", "lane state changed since preflight");
|
||||
const err = lifecycleError("ESTALE", "workspace state changed since preflight");
|
||||
err.expected = expected;
|
||||
err.current = current;
|
||||
throw err;
|
||||
@@ -1316,7 +1331,7 @@ async function stopLaneRun(lane) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw lifecycleError(
|
||||
"ERUNTIMEOUT",
|
||||
`lane run ${id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
|
||||
`workspace run ${id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
|
||||
);
|
||||
}
|
||||
await wait(RUN_EXIT_POLL_MS);
|
||||
@@ -1410,7 +1425,7 @@ function gitErrorCode(err) {
|
||||
function laneOr404(req, res) {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) {
|
||||
res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
return null;
|
||||
}
|
||||
return lane;
|
||||
@@ -1556,7 +1571,7 @@ router.post("/:id/hook/:name", sameOriginGuard, (req, res) => {
|
||||
}
|
||||
if (!lane.slot) {
|
||||
return res.status(409).json({
|
||||
error: { code: "ENOSLOT", message: "lane has no runtime yet — bring it up first" },
|
||||
error: { code: "ENOSLOT", message: "workspace has no runtime yet — bring it up first" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1635,7 +1650,8 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
.json({ error: { code: "EBADACTION", message: `unknown action ${action}` } });
|
||||
}
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
if (!lane)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
const body = req.body || {};
|
||||
|
||||
if (DESTRUCTIVE_ACTIONS.has(action)) {
|
||||
@@ -1647,7 +1663,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const result = await withLaneLock(lane.id, async () => {
|
||||
const lockedLane = lanesLib.getLane(lane.id);
|
||||
if (!lockedLane) throw lifecycleError("ENOLANE", "lane not found");
|
||||
if (!lockedLane) throw lifecycleError("ENOLANE", "workspace not found");
|
||||
|
||||
await stopLaneRun(lockedLane);
|
||||
// A running stack holds files open in the very directory `reset` is about
|
||||
@@ -1782,12 +1798,14 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
return {};
|
||||
});
|
||||
if (outcome.missing) {
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
if (outcome.conflict) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: { code: "ERUNLIVE", message: "lane already has a live run" } });
|
||||
.json({ error: { code: "ERUNLIVE", message: "workspace already has a live run" } });
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1808,7 +1826,7 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
error: {
|
||||
code: "EUNSUPPORTED",
|
||||
message:
|
||||
"sending input to a lane's run is no longer supported via REST — open the lane's terminal in Workspace and type directly (attaches over WebSocket to the same tmux session)",
|
||||
"sending input to a workspace's run is no longer supported via REST — open the workspace's terminal in Workspace and type directly (attaches over WebSocket to the same tmux session)",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1839,7 +1857,8 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
|
||||
router.delete("/:id", sameOriginGuard, (req, res) => {
|
||||
const ok = lanesLib.deleteLane(req.params.id);
|
||||
if (!ok) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
||||
if (!ok)
|
||||
return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
broadcast("lane_update", { removed: Number(req.params.id) });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user