docs(lanes): document ccam lanes profile init/check (A3)

This commit is contained in:
2026-08-04 09:52:58 +07:00
parent 113ed01504
commit d71086f677
4 changed files with 610 additions and 5 deletions
+195 -4
View File
@@ -37,12 +37,12 @@ ccam lanes add --repo /path/to/repo --title "My Feature" --base main --slug my-f
Reset a managed worktree, remove one, or purge the lane's eligible session history with the CLI:
```bash
ccam lanes reset <id> [--force] --yes
ccam lanes reset <id> [--force] [--keep-db] --yes
ccam lanes remove <id> [--force] --yes
ccam lanes purge <id> --yes
```
Each command first fetches and prints its preflight counts. `reset` and `remove` show `head`, `dirty`, `untracked`, and `unpushed`; `purge` shows `sessions`, `events`, and `tokenRows`. The action refuses to run without `--yes`, and sends those exact facts back as its confirmation. Use `--force` only when the preflight reports unpushed commits. An adopted lane points at a directory you own, so the CLI refuses to `reset` it. `remove` IS allowed for an adopted lane: it drops only the dashboard's record and leaves the directory and its contents untouched.
Each command first fetches and prints its preflight counts. `reset` and `remove` show `head`, `dirty`, `untracked`, and `unpushed` (plus `database`, the name a data-isolated lane's reset/remove will drop — see "Data isolation" below); `purge` shows `sessions`, `events`, and `tokenRows`. The action refuses to run without `--yes`, and sends those exact facts back as its confirmation. Use `--force` only when the preflight reports unpushed commits. `--keep-db` (reset only) skips the whole drop-recreate-migrate-reseed sequence, leaving the lane's database exactly as it was. An adopted lane points at a directory you own, so the CLI refuses to `reset` it. `remove` IS allowed for an adopted lane: it drops only the dashboard's record and leaves the directory, its contents, and its database (if any) untouched.
## Lifecycle
@@ -75,7 +75,7 @@ One documented exception, which does not weaken them: when a managed lane's dire
`GET /api/lanes/:id/preflight?action=reset|remove|purge` (`server/lib/lane-preflight.js`) is read-only and returns:
- **`reset`/`remove`**: `{ action, lane, kind, branch, head, dirty, untracked, unpushed, blocked[], warnings[] }`
- **`reset`/`remove`**: `{ action, lane, kind, branch, head, dirty, untracked, unpushed, database, blocked[], warnings[] }`
- **`purge`**: `{ action, lane, sessions, events, tokenRows, bytesEstimate, activeSessionSkipped }`
`blocked[]` can contain: `adopted` (not a managed worktree), `missing` (the directory is gone), `unreadable` (directory exists but git failed against it), and `unpushed-commits` (unpushed count > 0 — the only one a `force: true` can override).
@@ -84,6 +84,8 @@ One documented exception, which does not weaken them: when a managed lane's dire
`unpushed` is the count of commits the action would really discard: with remotes, commits on no remote; without remotes, the commits ahead of the lane's `base_branch`, which is this lane's own work. A freshly provisioned worktree in a local-only repository reports `0`, not that repository's whole history — the `no-remote` warning, not an inflated count, is what tells the user nothing is backed up. A lane with no `base_branch` at all (an adopted one) falls back to the total commit count. `warnings[]` holds purely informational facts that never gate the action: currently just `no-remote` (no git remote configured at all — nothing here is backed up remotely, but the action proceeds normally). Both arrays are data, not an exception.
`database` is the database name a `reset` (unless `--keep-db`) or `remove` will drop — `null` when the lane has no slot yet or its profile declares no `DB_PREFIX`. It is derived, not stored, the same way `slot`/`ports` are.
`bytesEstimate` is a **rough estimate**, not a measured disk size: it is derived as `(events + tokenRows) * 512`, a flat per-row byte guess, purely to give the confirmation dialog an order-of-magnitude sense of what purging will reclaim.
Every destructive `POST /api/lanes/:id/:action` must echo back an `expect` object with exactly the fields listed above for that action. The server re-runs preflight at execution time and rejects the request (`400 EEXPECT` if incomplete, `409 ESTALE` if any field has changed since the client read it) rather than trusting a client-supplied count.
@@ -97,7 +99,196 @@ Every destructive `POST /api/lanes/:id/:action` must echo back an `expect` objec
### Limitation: a fresh worktree has no dependencies
`ccam lanes add --repo` runs `git worktree add` only. It does not run `npm install` (or any other package manager), and it does not copy the source repo's untracked local files (`.env`, IDE config, etc.) into the new worktree. A managed lane is ready for `git` immediately but needs its own dependency install and local env setup before a session can run the project inside it — bootstrapping that automatically is out of scope for this feature (see `docs/superpowers/specs/2026-07-28-worktree-lanes-design.md`).
`ccam lanes add --repo` runs `git worktree add` only. It does not run `npm install` (or any other package manager), and it does not copy the source repo's untracked local files (`.env`, IDE config, etc.) into the new worktree. A managed lane is ready for `git` immediately but needs its own dependency install and local env setup before a session can run the project inside it.
A repository can close the dependency half of that gap itself by declaring a `bootstrap` hook in its profile (below) and running `ccam lanes hook bootstrap`. Seeding a lane's `.env` and its database is handled automatically once the profile opts in — see "Data isolation" below.
## Lane runtime: running a lane's own stack
A lane isolates code. Without more, two lanes running their project at the same time collide on everything else — the same ports, the same directories. The **runtime** layer gives each lane its own slot, and derives its ports and per-lane directories from it.
This is resource namespacing on the host, **not** a container: lanes run as the same user, share the network, and can read any file you can. If you need stronger isolation than that, this is not it.
### Declaring a profile
Everything stack-specific lives in the repository, at `<repo>/.ccam/profile/`. CCAM is multi-repo, so there is no global profile setting — a profile travels with the repo it describes, and therefore already exists in every worktree cut from it.
```
myapp/
.ccam/profile/
profile.env
hooks/
bootstrap.sh boot.sh health.sh
migrate.sh seed.sh ci-gate.sh e2e.sh regen.sh
db-create.sh db-drop.sh
```
`profile.env` is a `KEY=VALUE` declaration file. It is **parsed, never sourced**`$(…)`, backticks and `${VAR}` are kept literally, because sourcing arbitrary shell from a repository into the dashboard process would be a code-execution path. Hooks are executed on purpose; config is only read.
```bash
# .ccam/profile/profile.env
PORTS="api fe worker" # names; each gets a <NAME>_PORT in the hook environment
PORT_BASE_api=8000 # lane in slot 3 prefers :8003
PORT_BASE_fe=3000
PORT_BASE_worker=9000
LANE_DIRS="uploads .cache" # directories created per lane, inside its working copy
BACKEND_DIR=backend
FRONTEND_DIR=frontend
```
Every key has a default, so a missing one never breaks a lane: `PORTS="api fe"`, `PORT_BASE_api=8000`, `PORT_BASE_fe=3000`, `LANE_DIRS=""`, `BACKEND_DIR=backend`, `FRONTEND_DIR=frontend`, `API_PATH=/api`. A port name with no declared base falls back to `8000`.
CCAM resolves the profile from the **lane's own working copy first**, then its source repository. A branch that changes a boot command must boot with the command it changed; the source-repo fallback exists for a profile kept gitignored, which never reaches a worktree through git.
### Scaffolding a profile automatically
```bash
ccam lanes profile init <repo> [--force] # detect + write .ccam/profile/
ccam lanes profile check [<path>] # validate one (path defaults to cwd)
```
`profile init` currently detects **Node.js only**, in exactly two layouts: a root `package.json` (single-service), or `backend/package.json` **and** `frontend/package.json` both present (monorepo, checked first). Anything else — Python/Go/Ruby, a pnpm/yarn/turborepo workspace, non-standard directory names — is refused with an actionable message rather than guessed at; write `.ccam/profile/` by hand for those, following the reference above.
Detection reads `package.json` (which of `start`/`dev`/`preview` exists) and `docker-compose.yml` (a service matching `/postgres/i` or `/redis/i`), never executes anything, and never guesses a migration tool: a detected database gets `migrate.sh`/`seed.sh` scaffolded as an explicit, always-`exit 0` TODO stub, not a guessed Prisma/Knex/TypeORM command. `profile check` is the hard gate — it fails (non-zero exit, every problem listed) on any leftover `TODO:`, any missing or non-executable hook, or a declared port already in use; it only *warns* when `~/.ccam/secrets.env` doesn't exist yet for a database-declaring profile.
`profile check` takes a **path**, not a lane id or `--lane`/`--cwd` flag like every other `lanes` subcommand — it is meant to run against a bare repository right after `init`, before any lane or worktree exists for it.
### Slots and ports
A slot is the small integer every runtime fact derives from. Slots are allocated **lazily** — a lane that is only ever watched never takes one — from `1..LANE_MAX_SLOTS` (default 9), lowest free first, and are freed when the lane is removed. A `reset` keeps the slot: moving a lane's ports out from under a session mid-feature would be a silent, confusing failure rather than a fresh start.
Ports prefer `PORT_BASE_<name> + slot`. When that number is already in use, the allocator steps aside by `+100`, `+200`, … up to ten times, which keeps the last digit equal to the slot so a stepped-aside port still reads as "lane 3". The number a lane actually got is recorded in `lanes.ports` and reused on the next boot, so a lane's URL does not move once it has one. A number is rejected when something is listening on it, when another lane has recorded it (a lane whose stack is down still owns its number), or when an earlier port name in the same boot took it.
If every candidate is busy the boot fails with `EPORTBUSY`, naming the process holding the preferred port.
### The hook contract
Before running a hook, CCAM exports:
| Variable | Value |
|---|---|
| `LANE` | the **slot** number (not the lane id) |
| `LANE_ID` | the lane id |
| `LANE_DIR` | the lane's working copy |
| `SOURCE_REPO` | the repository it was cut from |
| `PROFILE_DIR` | the resolved `.ccam/profile` |
| `RUN_DIR` / `LOG_DIR` | pid files and logs, under `$LANES_ROOT/.state/lane<slot>/` |
| `<NAME>_PORT` | one per declared port, upper-cased (`PORTS="api fe"``API_PORT`, `FE_PORT`) |
| everything in `profile.env` | verbatim |
Two shell helpers are injected: `die <msg>`, and `harness_spawn <name> <workdir> <cmd…>` which backgrounds a long-lived service with fully detached stdio and records its pid at `$RUN_DIR/<name>.pid`. **Use `harness_spawn` in `boot`** — a child that inherits the caller's stdout holds that pipe open, and the hook never returns.
Inherited `GIT_*` variables are scrubbed exactly as `server/lib/worktree.js` scrubs them, so a hook that shells out to git cannot inherit a git context pointing at the dashboard's own repository.
```bash
# .ccam/profile/hooks/boot.sh
#!/usr/bin/env bash
set -euo pipefail
harness_spawn api "$LANE_DIR/$BACKEND_DIR" npm run start -- --port "$API_PORT"
harness_spawn fe "$LANE_DIR/$FRONTEND_DIR" npm run preview -- --port "$FE_PORT"
```
```bash
# .ccam/profile/hooks/health.sh
#!/usr/bin/env bash
set -euo pipefail
curl -sf --retry 30 --retry-delay 1 --retry-all-errors "http://127.0.0.1:$FE_PORT/" >/dev/null
```
Runnable hook names are a fixed allowlist: `bootstrap`, `boot`, `health`, `migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop`. A name from a request is never turned into a path.
### Up, down, and surviving a restart
```bash
ccam lanes up # boot the lane owning this directory
ccam lanes up 3 --no-build # boot lane 3, reusing an existing build
ccam lanes runtime # slot, ports, service liveness, last boot error
ccam lanes logs api # tail one service or hook log
ccam lanes down # stop the stack
ccam lanes hook ci-gate # run any allowlisted hook
```
`up` runs `boot` then `health`. It deliberately does **not** run `bootstrap` — installing dependencies on every boot would make a routine restart minutes long. A failing health check **leaves the processes running**: their logs are what tell you which service never came up, and killing them to report a tidy failure destroys the evidence.
Services are fully detached, so **a lane's stack outlives the dashboard**. Restarting or updating CCAM does not touch a running lane. Nothing has to re-adopt them either: whether a stack is up is recomputed from pid files and port probes on every read, never cached, because a process can die to OOM or a stray `kill` without telling anyone.
`down` kills each recorded pid tree bottom-up (a parent killed first reparents its children to init, where nothing knows to look for them), then — **only when there was a pid file** — sweeps any listener still on the lane's ports. That condition matters: a lane whose stack is already down still owns its port numbers, and an unconditional sweep would kill a server you started there yourself.
While a hook runs, each output line is broadcast as a **`lane_hook_output`** WebSocket message (`{ laneId, hook, stream, line }`) and also appended to `$LOG_DIR/<hook>.log`. A boot ends with **`lane_runtime`** (fresh facts, or an `error`); a hook run started through `POST /api/lanes/:id/hook/:name` ends with **`lane_hook_result`** carrying its exit code. That is what lets a lane card show live progress through a multi-minute build instead of a disabled button that reads as a hang.
### What the runtime does and does not write
The runtime writes `slot` and `ports` on the lane row, and nothing else. It never writes `stage`, `status` or `notes`.
In CCAM those describe **the agent's work**, not the stack's state: `status=running` means a session is working, and a booted server is not a session. Conflating them would corrupt lane liveness. This is the same boundary as "the console never writes a lane's stage". Boot failures live in `$LANES_ROOT/.state/lane<slot>/last-error.json` and surface through `GET /api/lanes/:id/runtime`.
Adopted lanes may be brought up and down — `up` only runs what you could run yourself — but every path that writes into their working copy is refused.
### Runtime environment variables
- **`LANE_MAX_SLOTS`** (default `9`) — how many lanes may hold a runtime at once. Nine keeps `base + slot` readable as a single digit; raising it costs that readability.
- **`LANE_BOOT_TIMEOUT_MS`** (default `900000`) — hard limit on the `boot` hook.
- **`LANE_HEALTH_TIMEOUT_MS`** (default `180000`) — hard limit on the `health` hook; a health check that never returns is a failed boot, not an eternal wait.
- **`LANE_PORT_PROBE_MS`** (default `300`) — connect timeout when probing whether a port is in use.
### Data isolation: database, Redis and `.env` (A2)
Two lanes running their stack at once need more than separate ports — they need separate data, or one lane's migration corrupts the other's session. This is the other half of runtime isolation, and every piece of it is **off by default**: a profile that never declares `DB_PREFIX` gets no database, `REDIS=1` gets no Redis index, and no `ENV_FILES` gets no `.env` writes at all.
**Profile declarations** (`.ccam/profile/profile.env`, all optional):
```bash
DB_PREFIX="myapp_l" # lane in slot 3 -> myapp_l3 ; empty = no per-lane DB
DB_KIND="postgres" # informational
DB_URL_SCHEME="postgresql" # DATABASE_URL scheme
REDIS=1 # 1 = allocate a logical Redis index = slot
# (stock Redis ships 16 logical DBs, 0-15 — keep
# LANE_MAX_SLOTS <= 15 if REDIS=1 is declared)
ENV_FILES="backend/.env" # file(s) to seed, relative to the lane
ENV_SOURCE="backend/.env" # source path in the source repo (defaults to ENV_FILES)
ENV_REWRITE="DATABASE_URL REDIS_URL UPLOAD_DIR" # keys CCAM overwrites per lane
ENV_PRESERVE="JWT_SECRET" # keys kept from the lane's OWN file on a --force refresh
UPLOAD_SUBDIR="backend/data/uploads" # exported as UPLOAD_DIR
```
A ready-to-copy template (including `db-create.sh`/`db-drop.sh` for a Postgres-in-docker-compose stack) lives at `server/data/profile-templates/postgres-compose/`.
**Machine-level credentials** live at `~/.ccam/secrets.env` (mode `0600`), never in the repo:
```bash
PG_HOST=127.0.0.1
PG_PORT=5432
PG_USER=postgres
PG_PASS=postgres
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
```
Parsed with the same literal `KEY=VALUE` reader as `profile.env` — never sourced. A missing file falls back to those same defaults (with a one-time warning); a file readable by group or world is refused outright rather than trusted. No route ever returns a value from this file — `GET /api/lanes/:id/runtime` reports a database's *name* and a Redis *index*, never a connection string.
**Who creates the database?** A `db-create.sh` / `db-drop.sh` hook (already in the allowlist) — CCAM stays stack-agnostic, since `createdb` vs `mysqladmin create` vs `touch foo.db` genuinely differ. **Who rewrites `.env`?** CCAM itself: mechanical and identical across stacks, so pushing it into every repo would duplicate the same ~30 lines.
**Hook environment additions**, present only when their owning declaration is (a `db-create.sh` that forgot to check `DB_PREFIX` fails loudly on an unset variable instead of touching a database named "undefined"):
| Variable | Present when | Value |
|---|---|---|
| `DB_NAME` | `DB_PREFIX` set | `<prefix><slot>` |
| `DATABASE_URL` / `TEST_DATABASE_URL` | `DB_PREFIX` set | full connection string to `DB_NAME` / `DB_NAME_test` |
| `PG_HOST` / `PG_PORT` / `PG_USER` | `DB_PREFIX` set | from `secrets.env` (`PG_PASS` is deliberately withheld — nothing in the ported hooks needs it) |
| `REDIS_URL` | `REDIS=1` | `redis://<host>:<port>/<slot>` |
| `REDIS_HOST` / `REDIS_PORT` | `REDIS=1` | from `secrets.env` |
| `UPLOAD_DIR` | `UPLOAD_SUBDIR` set | `<lane>/<UPLOAD_SUBDIR>` |
**When each step runs:**
- **Provisioning** a new managed worktree (`ccam lanes add --repo`) — after the worktree is created: seed `.env`, run `bootstrap`, create the database, `migrate`, `seed`. Runs once.
- **`up`** — repair `.env` (a hand-edited or never-seeded file gets fixed), ensure the database exists (cheap when it already does), `migrate` on every boot (a lane's schema drifts while it sits idle), and `seed` only on the boot that actually created the database.
- **`reset`** — refresh `.env` with `--force` (preserving `ENV_PRESERVE` keys from the lane's own file — swapping in the source's `JWT_SECRET` would 401 a running lane's tokens until reboot), re-run `bootstrap` (a reset can land on a branch with new dependencies), clear the declared `LANE_DIRS`, then drop, recreate, migrate and reseed the database — unless `--keep-db`, which skips that whole block.
- **`remove`** — drops the database and its `_test` sibling before the rest of teardown. Best-effort: a failed drop is logged, never blocks removing the lane's record. **Never runs for an adopted lane** — its data was never CCAM's to create, so it is never CCAM's to destroy, the same invariant that protects an adopted lane's worktree.
A missing source `.env` falls back to `.env.example` with a loud warning, never a silent success. And any hook output that echoes a value from `secrets.env` (the password, specifically) is redacted before it reaches `$LOG_DIR/<hook>.log` or the `lane_hook_output` WebSocket message — that stream reaches a browser tab, and a hook debugging its own environment must not publish a database password to everyone watching.
## The Workspace page (`/run`)