feat(lanes): per-lane database, Redis, and .env isolation (A1+A2)

Gives each lane its own slot-derived runtime (ports, detached process
lifecycle, profile-driven hooks) and its own database/Redis logical
index/.env file, so two lanes running the same repo's stack at once no
longer share state. Machine-level DB/Redis credentials live at
~/.ccam/secrets.env (mode 0600, never returned by any route); a hook's
output is redacted of that password (raw and URL-encoded forms) before
it reaches a log file or the lane_hook_output websocket broadcast.
Wired into provision/up/reset/remove; reset accepts --keep-db to skip
the drop/recreate/migrate/reseed block entirely.
This commit is contained in:
2026-08-04 10:03:40 +07:00
parent d71086f677
commit 9d145865dd
19 changed files with 3265 additions and 12 deletions
+155
View File
@@ -8820,6 +8820,161 @@ paths:
description: available:true with {branch, head, subject, dirty, untracked}, or available:false alone.
'404':
description: Lane not found.
/api/lanes/{id}/runtime:
get:
tags:
- Lanes
summary: A lane's own application stack
description: Slot, ports, per-service liveness, log paths and the last boot error. Recomputed on every call from pid files and port probes rather than cached, because a process can die to OOM or a stray kill without telling anyone. Read-only, so no same-origin guard. Kept out of GET /api/lanes because it opens a socket per declared port and stats every pid file. A lane whose repository declares no .ccam/profile returns available:false with HTTP 200 — most lanes never run a stack, which is a normal state, not a fault.
operationId: getLaneRuntime
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: available:false (no profile); available:true with provisioned:false (no slot yet); or the full facts with slot, ports, services, up, healthy, steppedAside, logs and lastError.
'404':
description: Lane not found.
/api/lanes/{id}/up:
post:
tags:
- Lanes
summary: Boot a lane's stack
description: 'Runs the profile''s boot then health hooks. Returns 202 and finishes in the background because a build can take minutes; progress streams as lane_hook_output and the attempt ends with a lane_runtime message. Does NOT run bootstrap — installing dependencies on every boot would make a routine restart minutes long. A failing health check leaves the processes running, because their logs are what identify the service that never came up. Writes only slot and ports on the lane row: never stage, status or notes.'
operationId: upLane
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
build:
type: boolean
default: true
description: false passes --no-build to the boot hook, reusing an existing build.
responses:
'202':
description: Accepted; the boot runs in the background.
'400':
description: ENOPROFILE — no .ccam/profile, with the paths searched.
'403':
description: The browser request was not same-origin/loopback.
'404':
description: Lane not found.
'409':
description: ESLOTS (every slot taken) or EPORTBUSY (with the occupying pids).
/api/lanes/{id}/down:
post:
tags:
- Lanes
summary: Stop a lane's stack
description: Kills each recorded pid tree bottom-up (killing a parent first reparents its children to init, where nothing knows to look for them), then sweeps listeners on the lane's ports ONLY when a pid file existed — a lane whose stack is already down still owns its port numbers, and an unconditional sweep would kill a server the user started there. Idempotent, and a no-op for a lane that was never up.
operationId: downLane
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: '{ok, killed: number[], runtime}.'
'403':
description: The browser request was not same-origin/loopback.
'404':
description: Lane not found.
/api/lanes/{id}/hook/{name}:
post:
tags:
- Lanes
summary: Run one of the lane profile's hooks
description: The surface a driving session uses for ci-gate, e2e, migrate and friends. Returns 202; output streams as lane_hook_output and completion arrives as lane_hook_result with the exit code. The name is checked against a fixed allowlist BEFORE anything is spawned, and args travels as an array of strings straight into argv — neither is ever joined into a command string.
operationId: runLaneHook
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: name
in: path
required: true
schema:
type: string
enum:
- bootstrap
- boot
- health
- migrate
- seed
- ci-gate
- e2e
- regen
- db-create
- db-drop
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
args:
type: array
items:
type: string
responses:
'202':
description: Accepted; the hook runs in the background.
'400':
description: ENOHOOK (name outside the allowlist) or ENOPROFILE.
'403':
description: The browser request was not same-origin/loopback.
'404':
description: Lane not found.
'409':
description: ENOSLOT — the lane has no runtime yet; bring it up first.
/api/lanes/{id}/logs/{svc}:
get:
tags:
- Lanes
summary: Tail a lane's hook or service log
description: The resolved path is confined to the lane's log directory after realpath, so a name from the request can never escape it. Read-only, so no same-origin guard.
operationId: getLaneLog
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: svc
in: path
required: true
schema:
type: string
- name: tail
in: query
required: false
schema:
type: integer
default: 65536
maximum: 1048576
description: Trailing bytes to return; capped at 1 MiB.
responses:
'200':
description: '{available, svc, size, truncated, text}, or {available:false} for a lane with no slot.'
'404':
description: Lane not found, or ENOLOG — no such log for this lane.
/api/lanes/branches:
get:
tags: