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:
+128
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user