feat(lanes): lane runtime UI, docs, and env additions (A1+A2)

Client-side rendering for the per-lane runtime facts (slot, ports,
database, Redis index, service liveness) added in the server-side
A1/A2 work, plus the doc updates (README, CLAUDE.md, docs/API.md,
client/server READMEs) describing the new profile.env keys, hook
environment contract, and REST endpoints.
This commit is contained in:
2026-08-04 10:04:58 +07:00
parent 9d145865dd
commit 4d9a385c5e
14 changed files with 614 additions and 24 deletions
+128 -5
View File
@@ -108,7 +108,8 @@ https://dashboard.example.com
Lane mutations (`POST /api/lanes/ensure`, `POST /api/lanes/worktree`,
`PATCH /api/lanes/:id`, confirmed
actions, and `DELETE /api/lanes/:id`) enforce the same loopback same-origin
actions, the runtime routes `POST /api/lanes/:id/up`, `/down` and
`/hook/:name`, and `DELETE /api/lanes/:id`) enforce the same loopback same-origin
guard as `/api/run`: browser requests must originate from `localhost`,
`127.0.0.1`, `::1`, or `0.0.0.0`; CLI and curl requests without `Origin` pass.
@@ -195,13 +196,21 @@ GET /api/lanes/:id/preflight?action=reset|remove|purge
```
Returns the exact facts a user must confirm. `reset` and `remove` return
`head`, `dirty`, `untracked`, `unpushed`, `blocked`, and `warnings`;
`head`, `dirty`, `untracked`, `unpushed`, `database`, `blocked`, and `warnings`;
`purge` returns `sessions`, `events`, `tokenRows`,
`bytesEstimate`, and `activeSessionSkipped`. `expect` is required: reset/remove
must include all of `head`, `dirty`, `untracked`, and `unpushed`; purge must
must include all of `head`, `dirty`, `untracked`, and `unpushed` (not `database`
— see below); purge must
include `sessions`, `events`, and `tokenRows`. Missing or incomplete
confirmation facts return `400 EEXPECT`.
`database` is the name a `reset` (unless `keepDb: true`) or `remove` will drop
`null` when the lane has no slot yet or its profile declares no `DB_PREFIX`.
It is not part of the staleness-checked `expect` set: it is derived from the
lane's slot and profile, not from mutable git/session state, so it cannot go
stale between preflight and the action. See
`docs/LANES.md#data-isolation-database-redis-and-env-a2`.
`blocked` reports conditions that affect the action: `adopted`
(not a managed worktree), `missing` (directory gone), `unreadable` (directory
exists but git failed against it), and `unpushed-commits` (unpushed count > 0
@@ -243,6 +252,7 @@ exit returns `500 ERUNTIMEOUT` and does not run git. `reset` and `remove` additi
{
"confirm": true,
"force": true,
"keepDb": false,
"expect": {
"head": "9b3e74a",
"dirty": 4,
@@ -257,9 +267,14 @@ preflight. If any echoed fact changed, the action returns `409 ESTALE` with its
`expected` and `current` diagnostics, without resetting, removing, or purging.
Missing `force` for unpushed work returns `409 EUNPUSHED`.
`reset` restores the managed worktree's feature branch from its base, cleans
untracked files but preserves ignored files, and clears the lane stage state.
untracked files but preserves ignored files, clears the lane stage state, and —
when the lane's profile declares data isolation (`docs/LANES.md#data-isolation-database-redis-and-env-a2`)
— refreshes `.env`, re-runs `bootstrap`, clears `LANE_DIRS`, and drops +
recreates + migrates + reseeds the database, unless `keepDb: true` is passed
(reset only; skips that whole block, leaving the database untouched).
`remove` removes the managed git worktree, prunes it, deletes its feature branch,
then deletes the lane row. `purge` returns the deleted counts:
drops the lane's database and its `_test` sibling (best-effort; never for an
adopted lane), then deletes the lane row. `purge` returns the deleted counts:
```json
{
@@ -274,6 +289,114 @@ dashboard row forgotten, and its directory is never changed. `400` preserves
`ENOTMANAGED`, `EOUTSIDEROOT`, and `ENOTWORKTREE` guard failures on the managed
destroy path. Git failures return `500` with their git `stderr` in `error.stderr`.
#### Read a lane's runtime
```http
GET /api/lanes/:id/runtime
```
What is actually running for this lane, recomputed on every call from pid files
and port probes — never cached, because a process can die without telling
anyone. See `docs/LANES.md#lane-runtime-running-a-lanes-own-stack`.
```json
{
"available": true,
"provisioned": true,
"slot": 3,
"kind": "managed",
"hooks": ["bootstrap", "boot", "health"],
"profileDir": "/work/myapp/.ccam/profile",
"services": [{ "name": "api", "pid": 40213, "alive": true }],
"ports": { "api": { "port": 8103, "expected": 8003, "listening": true } },
"database": { "name": "myapp_l3", "testName": "myapp_l3_test" },
"redisIndex": 3,
"steppedAside": true,
"up": true,
"healthy": true,
"logs": ["boot.log", "health.log", "api.log"],
"logDir": "/home/you/.claude/ccam-lanes/.state/lane3/logs",
"lastError": null
}
```
- `database` is `null` when the profile declares no `DB_PREFIX`; `redisIndex`
is `null` when `REDIS` is not `1`. Both are names/indices only — never a
connection string, so this endpoint never leaks the `~/.ccam/secrets.env`
password even though it derives `DATABASE_URL` internally to run hooks.
- A lane whose repository declares no `.ccam/profile` returns
`{"available": false, "searched": [...]}` with HTTP **200** — the same
contract as `GET /:id/git`. Most lanes never run a stack; that is a normal
state, not a fault.
- A lane with a profile but no slot yet returns `{"available": true,
"provisioned": false, "hooks": [...], "ports": {}}`.
- `expected` is `PORT_BASE_<name> + slot`; when `port` differs, the allocator
stepped aside from a number already in use and `steppedAside` is `true`.
- Not folded into `GET /api/lanes` on purpose: it opens a socket per declared
port and stats every pid file, and the lane list is polled and re-broadcast on
every hook.
#### Boot or stop a lane's stack
```http
POST /api/lanes/:id/up { "build": true }
POST /api/lanes/:id/down
```
`up` returns **202** and boots in the background, because a build can run for
minutes. Progress streams as `lane_hook_output` WebSocket messages and the
attempt finishes with a `lane_runtime` message carrying the fresh facts (or an
`error`). `build: false` passes `--no-build` to the `boot` hook.
`up` runs the profile's `boot` then `health` hooks; it does **not** run
`bootstrap`. A failing `health` leaves the processes running so their logs
remain readable, and records `EUNHEALTHY` in the runtime's `lastError`.
`down` is synchronous, idempotent, and a no-op for a lane that was never up:
```json
{ "ok": true, "killed": [40213, 40219], "runtime": { "…": "…" } }
```
Both write only `slot` and `ports` on the lane row. Neither writes `stage`,
`status`, or `notes` — in CCAM those describe the agent's work, not the stack's
state. Adopted lanes may be brought up and down.
Errors: `400 ENOPROFILE` (with the paths searched), `409 ESLOTS` (every slot
taken), `409 EPORTBUSY` (with `port` and the occupying `pids`), `404 ENOLANE`.
#### Run a profile hook
```http
POST /api/lanes/:id/hook/:name { "args": ["--scope", "smoke"] }
```
Runs one of the profile's hooks — 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`.
`:name` is checked against a fixed allowlist (`bootstrap`, `boot`, `health`,
`migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop`) **before**
anything is spawned, and `args` travels as an array of strings straight into
argv — neither is ever joined into a command string. An unknown name returns
`400 ENOHOOK` with the allowed list; a lane with no slot returns `409 ENOSLOT`.
#### Tail a lane's log
```http
GET /api/lanes/:id/logs/:svc?tail=65536
```
```json
{ "available": true, "svc": "api", "size": 20481, "truncated": false, "text": "…" }
```
`tail` is the number of trailing bytes (default 64 KiB, capped at 1 MiB). The
resolved path is confined to the lane's log directory after `realpath`, so a
name from the request can never escape it; anything else is `404 ENOLOG`. A lane
with no slot returns `{"available": false}`.
### Sessions
#### List Sessions