docs(lanes): document ccam lanes profile init/check (A3)
This commit is contained in:
+9
-1
@@ -377,7 +377,15 @@ graph TD
|
||||
| `lib/stream-json-parser.js` | Newline-delimited JSON line buffer for parsing `claude --output-format stream-json` output. Reassembles arbitrarily chunked stdout into discrete envelopes. Robust: malformed lines are reported via an `onError` callback but never throw |
|
||||
| `lib/run-spawner.js` | Spawns and supervises `claude` subprocesses for the Run page. Two modes: **headless** (`-p "<prompt>"` in argv, stdin closed, exits after one turn) and **conversation** (`--input-format stream-json`, prompt + follow-ups piped over stdin, multi-turn). Conversation mode also supports `resumeSessionId` → `--resume <id>`; an empty `prompt` is permitted in this case (the spawner skips the initial stdin write so `claude` idles on the resumed transcript until the user POSTs a follow-up via `/run/:id/message`). The argv builder also passes through an optional `effort` (`low`/`medium`/`high`) → `--effort`. Output is always `--output-format stream-json --verbose --include-partial-messages` so the parser yields character-level deltas (`stream_event` envelopes) the UI can render token-by-token; each envelope is broadcast as `run_stream` over the existing WebSocket. Status transitions broadcast as `run_status`. A failed spawn records an actual-exit timestamp too: no child started, so lane teardown can safely proceed instead of waiting for a nonexistent `exit` event. SIGTERM escalation checks that timestamp rather than Node's delivery-acknowledgement `child.killed`, so a child that ignores SIGTERM still receives SIGKILL after five seconds. Concurrency is effectively uncapped (default ceiling 10000 — matches the terminal TUI which has no cap; the cap is sanity-only to prevent fork-bomb footguns from a buggy client; override with `RUN_MAX_CONCURRENT`, NaN-safe). Per-handle bounded envelope log (cap 500) lets late-attaching clients replay history via `?envelopes=1`. The Run page additionally reconciles this in-memory log against the session's on-disk JSONL transcript on every attach (incl. clicking Resume / View on a row) — when the transcript has more user/assistant messages than the spawner saw (e.g., a resumed run whose prior history never traversed stdout), it supersedes; otherwise the spawner's log wins (it has stream_event deltas the transcript doesn't carry until each turn finalizes). This is what makes leaving a resumed run and coming back show the same chat the user saw initially. Completed handles reaped after 5 min; full transcripts persist via the normal hook ingestion pipeline because every spawned `claude` fires hooks like any other CLI session |
|
||||
| `routes/run.js` | HTTP surface for the Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). cwd sanitization: must be absolute and exist as a directory. `GET /` lists handles + concurrency state. `GET /binary` probes whether `claude` is on `PATH`. `GET /cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /files?cwd=&q=` powers the Run page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /` spawns (accepts `effort` in body). `POST /:id/message` sends a follow-up turn. `GET /:id` returns the handle; `?envelopes=1` includes the in-memory envelope log for re-attach. `DELETE /:id` SIGTERMs (escalates to SIGKILL after 5 s) |
|
||||
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug` and `base_branch` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. |
|
||||
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.ccam/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. |
|
||||
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
|
||||
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and CCAM, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
|
||||
| `lib/lane-profile.js` | The stack seam. Resolves `<repo>/.ccam/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced** — `$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash <hook>` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `<NAME>_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/<hook>.log` and that broadcast. Hook names come from a fixed allowlist, never from a request |
|
||||
| `lib/lane-runtime.js` | Lane stack lifecycle. `upLane` runs `boot` then `health` (never `bootstrap` — that belongs to provision/reset) and leaves processes running on a failed health check, because their logs are the evidence. Before `boot` it also repairs `.env`, ensures the database exists, migrates every boot, and seeds only the boot that created the database (A2). `downLane` kills each recorded pid tree bottom-up (a parent killed first reparents children to init) and only sweeps port listeners when a pid file existed — an unconditional sweep would kill a server the user started on a lane's stale port. `runtimeFacts` **computes** liveness on every read from pid files and probes rather than caching it (plus the database name/Redis index, never a connection string), which is both correct when a process dies unobserved and why adopting a stack after a dashboard restart needs no code at all. `provisionLane`/`resetLaneData`/`removeLaneData` (A2) drive the data-isolation lifecycle at worktree-provision, reset, and remove time — see `docs/LANES.md#data-isolation-database-redis-and-env-a2`. Writes `slot`/`ports` and nothing else on the lane row: `status=running` means an agent is working, not that a server is listening, and conflating them would corrupt lane liveness |
|
||||
| `lib/lane-detect.js` | (A3) Node.js project detection and `.ccam/profile/` scaffolding. `detectNode` reads `package.json` layout (root, or `backend/`+`frontend/`) and `docker-compose.yml` (a service matching `/postgres/i` or `/redis/i`) — read-only, never executes anything. Any detected name written into GENERATED SHELL TEXT (an npm script name, a compose service name) must pass a strict identifier check first; a `profile.env` value is already safe regardless, since that file is parsed, never sourced. `scaffoldProfile` writes the profile, always leaving `migrate`/`seed` as an `exit 0` TODO stub when a database is found rather than guessing a migration tool. `checkProfile` is the read-only hard gate: unresolved `TODO:`, a missing/non-executable hook, or a port already in use all fail it; a missing `~/.ccam/secrets.env` only warns |
|
||||
| `lib/secrets.js` | (A2) Reads `~/.ccam/secrets.env` — machine-level database/Redis credentials, deliberately outside any repository. Parsed with `lane-profile.js`'s literal `KEY=VALUE` reader, never sourced. Falls back to local defaults (with a one-time warning) when the file is absent; refuses to load a file readable by group or world rather than trusting it. Never returned by any route |
|
||||
| `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config |
|
||||
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — CCAM stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and CCAM can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
|
||||
|
||||
### API Documentation
|
||||
|
||||
|
||||
+29
@@ -20,6 +20,7 @@ The complete guide to `ccam`, the Claude Code Agent Monitor command-line interfa
|
||||
- [Pricing](#pricing)
|
||||
- [Import](#import)
|
||||
- [Remote Sources](#remote-sources)
|
||||
- [Lanes](#lanes)
|
||||
- [Administration](#administration)
|
||||
- [Safety Model](#safety-model)
|
||||
- [Output & Scripting](#output--scripting)
|
||||
@@ -216,6 +217,34 @@ Manage the remote (SSH) machines this dashboard pulls Claude Code history from
|
||||
| `ccam remote-sources sync [id]` | Pull history now — one source by id, or **all** sources when the id is omitted. Prints imported / tagged counts |
|
||||
| `ccam remote-sources rm <id> [--purge]` | Remove a source (its imported sessions are detached back to `local` by default; `--purge` also **deletes** them) |
|
||||
|
||||
### Lanes
|
||||
|
||||
A lane is a durable unit of parallel agent work — one working directory, many sessions over time. Full guide: [`docs/LANES.md`](LANES.md).
|
||||
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
| `ccam lanes` | List lanes with stage, status, liveness and progress |
|
||||
| `ccam lanes add --cwd <path> --title <text>` | Adopt an existing directory as a lane |
|
||||
| `ccam lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]` | Provision a dashboard-managed git worktree as a new lane |
|
||||
| `ccam lanes profile init <repo> [--force]` | Detect a Node.js project (single-service or backend+frontend monorepo) and scaffold `.ccam/profile/`. Refuses to overwrite an existing one without `--force` |
|
||||
| `ccam lanes profile check [<path>]` | Validate a profile — parses, every referenced hook exists and is executable, no leftover `TODO:`, declared ports free. `<path>` defaults to the current directory (not a lane id) |
|
||||
| `ccam lanes reset\|remove\|purge <id> [--force] [--keep-db] --yes` | Show preflight facts, then perform a destructive action. Refuses without `--yes`; `--force` is required when commits are unpushed; `--keep-db` (`reset` only) skips dropping/recreating a data-isolated lane's database |
|
||||
| `ccam stage <stage> [--evidence <text>] [--note <text>] [--result pass\|fail]` | Declare the lane's current pipeline stage. Called by a skill at each phase boundary |
|
||||
|
||||
**Runtime** — the lane's own application stack, as opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one lane id. Each requires the repository to declare a profile at `<repo>/.ccam/profile/` ([contract](LANES.md#lane-runtime-running-a-lanes-own-stack)); without one they report that nothing is configured rather than failing.
|
||||
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
| `ccam lanes up [<id>] [--no-build]` | Boot the stack through the profile's `boot` + `health` hooks, then poll until it is healthy or the boot fails. `--no-build` reuses an existing build |
|
||||
| `ccam lanes down [<id>]` | Stop the stack. Idempotent, and a no-op for a lane that was never up |
|
||||
| `ccam lanes runtime [<id>]` | Slot, ports (flagging any that stepped aside from its base), the lane's database name and Redis index when its profile declares them (see [Data isolation](LANES.md#data-isolation-database-redis-and-env-a2)), per-service liveness, log paths, and the last boot error |
|
||||
| `ccam lanes logs [<id>] <service> [--tail N]` | Tail one service or hook log (`--tail` in bytes, default 64 KiB) |
|
||||
| `ccam lanes hook [<id>] <name> [args…]` | Run one of the profile's hooks: `bootstrap`, `boot`, `health`, `migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop` |
|
||||
|
||||
Omit `<id>` and the command addresses the lane owning the current directory, so a session running inside a lane never needs to know its own id. Only a leading all-digits argument is read as an id — `ccam lanes logs web --tail 4096` addresses the lane by directory, not lane 4096.
|
||||
|
||||
Because a lane's services are fully detached, restarting the dashboard (or `ccam update`) never stops a running stack.
|
||||
|
||||
### Administration
|
||||
|
||||
| Command | Description |
|
||||
|
||||
+195
-4
@@ -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`)
|
||||
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
# Shipyard parity for CCAM lanes — full plan
|
||||
|
||||
**Source of truth for "Shipyard":** `~/MyDrive/Projects/ResearchAndDevelopment/AgentWorkflow/`
|
||||
(the "Parallel Feature Harness"). Every capability below traces to a real file
|
||||
there, so each port can be checked against the original rather than a memory of it.
|
||||
|
||||
**Goal:** bring the whole harness into CCAM — isolated per-lane runtime and data,
|
||||
per-feature state with archive, proof gallery, cross-lane locks, the
|
||||
`ship-feature` pipeline with its QC agents, and the optional integrations.
|
||||
|
||||
**Non-goal (explicit):** OS-level sandboxing. Shipyard does not containerize
|
||||
either. Its isolation is *resource namespacing on the host*: separate working
|
||||
copy, ports, database, Redis logical DB, `.env`, upload dir. All lanes run as the
|
||||
same user, share the network, and can read any file the user can. Anything
|
||||
stronger is a separate design (§ Future).
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
| | Subsystem | Depends on | Status |
|
||||
|---|---|---|---|
|
||||
| **A1** | Slots, ports, profile hooks, detached lifecycle | — | ✅ **done** 2026-08-03 |
|
||||
| **A2** | Data isolation: `.env`, database, Redis index | A1 | ✅ **done** 2026-08-03 |
|
||||
| **A3** | Stack detection + profile scaffolding | A2 | ✅ **done** 2026-08-04 |
|
||||
| **B** | Per-feature state + archive | — | planned |
|
||||
| **C** | Proof gallery | B | planned |
|
||||
| **D** | Cross-lane named locks | — | planned |
|
||||
| **E** | `ship-feature` skill + QC agents | A2·B·C·D | planned |
|
||||
| **F** | Integrations (tracker / dev-QC / CI) | E | planned |
|
||||
|
||||
A1 spec: `docs/superpowers/specs/2026-08-03-lane-runtime-isolation-design.md`.
|
||||
|
||||
**Detail below tapers on purpose.** A2 is written at implementation grade because
|
||||
everything it needs is known. A3, B, C, D are at design grade — decisions named,
|
||||
shapes fixed, exact code left to their own spec. E and F are at scope grade: E's
|
||||
skill is a port of a 15-stage document whose every `bin/…` call must be rewritten
|
||||
against command surfaces that B–D have not built yet, and writing that in detail
|
||||
now would be writing against imagined APIs. Each subsystem still gets its own
|
||||
spec → plan → implement cycle; this document is the map, not a substitute.
|
||||
|
||||
---
|
||||
|
||||
## Invariants every phase must preserve
|
||||
|
||||
Carried from `CLAUDE.md` and proven in A1. Re-check each at review time.
|
||||
|
||||
1. **CCAM does not orchestrate.** It offers primitives; the session sequences them.
|
||||
No chaining, no queue, no retry, no gate evaluation.
|
||||
2. **The runtime never writes `stage`, `status` or `notes`** — only allocation
|
||||
facts. `status=running` means an agent is working, not that a server listens.
|
||||
3. **Liveness is computed, never stored.** Anything CCAM does not control
|
||||
(a process, a port, a deploy) is re-derived on read.
|
||||
4. **Never build a shell command as a string.** `execFile`/`spawn` with argv
|
||||
arrays. Hooks are the deliberate exception and are spawned with a fixed argv.
|
||||
5. **Config is parsed, never sourced.** Hooks execute on purpose; declarations
|
||||
are only read.
|
||||
6. **Adopted lanes are never destroyed and never written into.**
|
||||
7. **Every destructive path re-runs its own guard**, never trusts its caller.
|
||||
|
||||
---
|
||||
|
||||
## A2 — Data isolation
|
||||
|
||||
**Goal:** a lane gets its own database, its own Redis logical DB, its own `.env`,
|
||||
and its own data directories, so two lanes can run their stack at once without
|
||||
sharing state. This is the half of Shipyard's isolation A1 did not cover.
|
||||
|
||||
**Traces to:** `bin/lane-env-seed.sh`, the `createdb`/`dropdb` blocks in
|
||||
`bin/lane-bootstrap.sh` / `lane-up.sh` / `lane-reset.sh` / `lane-remove.sh`,
|
||||
`_common.sh:lane_db` / `lane_db_url` / `lane_redis_url` / `lane_upload_dir`.
|
||||
|
||||
### Decisions to settle first
|
||||
|
||||
| Question | Proposed answer | Why |
|
||||
|---|---|---|
|
||||
| Who creates the database? | A `db-create.sh` / `db-drop.sh` **hook** (allowlist already exists in A1) | CCAM must not assume Postgres. `createdb` vs `mysqladmin create` vs `touch foo.db` genuinely differ; the template ships Shipyard's docker-compose Postgres commands so a Postgres user gets parity by copying. |
|
||||
| Who rewrites `.env`? | **CCAM**, not a hook | Mechanical and identical across stacks: parse `KEY=VALUE`, replace a declared set, keep the rest. Pushing it to every repo would duplicate ~30 lines and re-lose the `JWT_SECRET` lesson below in each. |
|
||||
| Where do database credentials live? | `~/.ccam/secrets.env`, mode `0600` | Machine-level, not repo-level (Shipyard's `config/secrets.env`). **Never** returned by any route and never logged; `GET /runtime` may report key *names* present, never values. |
|
||||
| Does a lane without a database break? | No | `DB_PREFIX` empty (the default) means "no per-lane database": no name allocated, no hook called, no dead code. Same for `REDIS=0`. |
|
||||
|
||||
### Profile additions
|
||||
|
||||
```bash
|
||||
# .ccam/profile/profile.env (all optional; empty = feature off)
|
||||
DB_PREFIX="myapp_l" # lane in slot 3 -> myapp_l3 ; empty = no per-lane DB
|
||||
DB_KIND="postgres" # informational for A3's scaffolder; A2 gates on DB_PREFIX
|
||||
DB_URL_SCHEME="postgresql" # DATABASE_URL scheme
|
||||
REDIS=1 # 1 = allocate logical index = slot
|
||||
ENV_FILES="backend/.env" # files 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 EXISTING file on a --force refresh
|
||||
UPLOAD_SUBDIR="backend/data/uploads" # exported as UPLOAD_DIR
|
||||
```
|
||||
|
||||
`DB_NAME`, `DATABASE_URL`, `REDIS_URL`, `UPLOAD_DIR`, `TEST_DATABASE_URL`
|
||||
(`<DB_NAME>_test`, for `ci-gate`) join the hook environment contract.
|
||||
|
||||
### Steps
|
||||
|
||||
1. **`server/lib/secrets.js`** — read `~/.ccam/secrets.env` with the A1 parser
|
||||
(`lane-profile.js:parseEnvFile`, reuse it). Warn once and continue when the
|
||||
file is absent; refuse to load it when its mode is group/world-readable.
|
||||
2. **`server/lib/lane-slots.js`** — extend `slotFacts`: `dbName`,
|
||||
`databaseUrl`, `redisUrl`, `uploadDir`, `testDatabaseUrl`. Same one place
|
||||
every slot-derived fact already comes from.
|
||||
3. **`server/lib/lane-env.js`** (new) — `seedEnv(lane, profile, { force })`.
|
||||
Copies `<source_repo>/$ENV_SOURCE` → `<lane>/$ENV_FILES` when missing (or on
|
||||
`--force`), then rewrites `$ENV_REWRITE` keys **in the file**, so the file is
|
||||
correct on its own rather than merely masked by runtime exports.
|
||||
- **Port the two hard-won behaviours verbatim.** A `--force` refresh
|
||||
preserves `$ENV_PRESERVE` from the lane's existing file: swapping in the
|
||||
source's `JWT_SECRET` 401s a running lane until reboot. And a missing source
|
||||
`.env` falls back to `.env.example` with a loud warning, not silent success.
|
||||
- Refuses on an adopted lane (`assertManaged`): that file is the user's real
|
||||
working config.
|
||||
4. **`server/lib/lane-services.js`** (new) — `ensureDatabase` / `dropDatabase`
|
||||
calling the `db-create` / `db-drop` hooks.
|
||||
- `dropDatabase` runs `assertManaged` **and** asserts
|
||||
`DB_NAME === slotFacts(lane.slot).dbName` before spawning anything. Only a
|
||||
name CCAM derived can ever be dropped; nothing from a request reaches it.
|
||||
5. **Wire into the lifecycle** (`lane-runtime.js`, `routes/lanes.js`):
|
||||
- *provision* → allocate slot → `seedEnv` → `bootstrap` → `db-create` →
|
||||
`migrate` → `seed`
|
||||
- *up* → `seedEnv` (repair) → ensure DB exists → `migrate` **every boot**
|
||||
(Shipyard's note is right: lanes drift while idle and a stale schema
|
||||
cascade-fails the whole e2e suite) → `seed` only if the DB was just created
|
||||
- *reset* → down → git reset (existing) → `bootstrap` → clear `LANE_DIRS` →
|
||||
drop + create + `migrate` + `seed`, unless `--keep-db`
|
||||
- *remove* → down → `db-drop` for `<db>` and `<db>_test` → existing teardown
|
||||
6. **Preflight + confirmation** — `lane-preflight.js` reports the database name a
|
||||
`reset`/`remove` will drop, and the confirm dialog echoes it back like the
|
||||
existing counted facts. Dropping a database is the most destructive thing this
|
||||
whole roadmap adds; it gets the same echo-or-refuse contract as the rest.
|
||||
7. **CLI** — `ccam lanes reset --keep-db`; `ccam lanes runtime` gains the DB name
|
||||
and Redis index rows.
|
||||
8. **Profile template** — `server/data/profile-templates/postgres-compose/` with
|
||||
`db-create.sh` / `db-drop.sh` holding Shipyard's exact
|
||||
`docker compose exec -T $DB_SERVICE createdb/dropdb` commands.
|
||||
|
||||
### Verify
|
||||
|
||||
- Fixture repo with a SQLite `db-create.sh` (`touch $DB_NAME.db`) — no Docker
|
||||
needed in CI: provision → file exists; reset → file recreated; remove → gone.
|
||||
- `.env` seeding: keys in `ENV_REWRITE` replaced, every other line byte-identical;
|
||||
`--force` preserves `ENV_PRESERVE` from the existing file; missing source falls
|
||||
back to `.env.example` and warns.
|
||||
- `dropDatabase` with a tampered `DB_NAME` throws **before** spawning.
|
||||
- `seedEnv` and `dropDatabase` both throw `ENOTMANAGED` on an adopted lane.
|
||||
- Secrets: a `0644` `~/.ccam/secrets.env` is refused; `GET /runtime` response
|
||||
contains no secret value (assert on the serialized JSON).
|
||||
- Two lanes up at once, each writing its own database, neither seeing the other's
|
||||
rows — the actual point of the phase.
|
||||
|
||||
### Risks
|
||||
|
||||
- **Dropping the wrong database.** Mitigated by derived-name-only, the managed
|
||||
guard, and the echoed preflight. Review this path twice.
|
||||
- **Secrets leaking into a log.** `runHook` streams hook output to the websocket;
|
||||
a hook that echoes `$DATABASE_URL` publishes a password to every browser tab.
|
||||
Add a redaction pass over hook output for values that came from `secrets.env`.
|
||||
|
||||
---
|
||||
|
||||
## A3 — Detection and scaffolding
|
||||
|
||||
**Goal:** `ccam lanes profile init <repo>` writes a working `.ccam/profile/`, so
|
||||
adopting a repo is one command instead of nine hand-written hooks. This is the
|
||||
gap that left Shipyard's own `profiles/` empty.
|
||||
|
||||
**Design grade.** The presets must be derived from the profiles actually written
|
||||
during A1/A2 — that is why this phase is last in A, and its spec should start by
|
||||
reading them.
|
||||
|
||||
- **Detect** from file signals, never from guessing: `package.json` (scripts
|
||||
`dev`/`build`/`start`, `vite`/`next`/`express` deps), `docker-compose.yml`
|
||||
(services named `postgres`/`mysql`/`redis`), `pyproject.toml` /
|
||||
`requirements.txt` (`django`/`fastapi`), `manage.py`, `alembic.ini`,
|
||||
`prisma/schema.prisma`, `go.mod`, `Gemfile`.
|
||||
- **Scaffold, don't interpret.** Detection writes a concrete, readable
|
||||
`.ccam/profile/` the user owns and edits. The runtime never re-detects at boot;
|
||||
a wrong guess is fixed by editing a file, not by changing CCAM.
|
||||
- Unknown values are written as explicit `TODO:` markers rather than plausible
|
||||
defaults — a wrong default that boots something is worse than a refusal.
|
||||
- **`ccam lanes profile check`** — the `harness-doctor` equivalent: profile
|
||||
parses, declared hooks exist and are executable, no `TODO:` left, declared
|
||||
ports are free, `secrets.env` has what the declared database needs. Must exit
|
||||
non-zero on any of these.
|
||||
- Templates in `server/data/profile-templates/<preset>/`.
|
||||
|
||||
**Verify:** a fixture repo per preset scaffolds, `profile check` passes, and
|
||||
`ccam lanes up` boots it — the same end-to-end proof A1 used, once per preset.
|
||||
|
||||
---
|
||||
|
||||
## B — Per-feature state and archive
|
||||
|
||||
**Goal:** a lane's history survives switching features. Today `clearLane` erases;
|
||||
Shipyard archives into `state/laneN/<slug>.json` with an `.active` pointer and
|
||||
keeps every past feature browsable.
|
||||
|
||||
**Independent of A** — can be built in parallel.
|
||||
|
||||
- **Schema:** `lane_features` (`lane_id`, `slug`, `title`, `branch`, `stage`,
|
||||
`stage_since`, `status`, `gate_decision`, `ci_status`, `qc_dev`, `stages`,
|
||||
`links`, `notes`, `archived_at`), unique on `(lane_id, slug)`, plus
|
||||
`lanes.active_feature_id`. The `lanes` row stays the live view so nothing
|
||||
downstream breaks.
|
||||
- `clearLane` becomes: snapshot the row into `lane_features` with `archived_at`,
|
||||
then reset. Nothing is lost by tidying up.
|
||||
- **Slug canonicalization is the load-bearing detail.** The slug keys three
|
||||
things — the state row, the branch, and (in C) the proof directory. Port
|
||||
Shipyard's `state.sh activate` rule exactly: drop a `feat/` prefix, `/` and
|
||||
spaces to `-`, keep `[A-Za-z0-9._-]`, refuse empty, and **echo the canonical
|
||||
slug back** so callers store what the server stored. Reuse
|
||||
`worktree.js:slugify` only if its output is identical; otherwise a separate
|
||||
function with its own test. Do not let the two drift.
|
||||
- Routes `GET/POST /api/lanes/:id/features…`; CLI `ccam feature list|activate|show`.
|
||||
- UI: a feature picker that swaps the detail panel to an archived snapshot while
|
||||
the lane keeps running.
|
||||
|
||||
**Verify:** activate → clear → activate a second slug → the first is still
|
||||
browsable with its final stage intact and the live row is clean; a slug with
|
||||
slashes and spaces lands as one flat segment; `DELETE /api/lanes/:id` cascades.
|
||||
|
||||
---
|
||||
|
||||
## C — Proof gallery
|
||||
|
||||
**Goal:** the screenshots QC agents capture are visible in the dashboard, grouped
|
||||
by feature and phase. Depends on B for the grouping key.
|
||||
|
||||
- Store at `<lane>/.playwright-mcp/proof/<slug>/{qc-local,qc-dev,ticket}/`.
|
||||
- **Port `_common.sh:ensure_proof_link`.** Clone-root `proof/` becomes a symlink
|
||||
into the canonical directory, because screenshots land there whenever an MCP's
|
||||
`--output-dir` is not yet in effect. It exists to fix a real class of stranded
|
||||
evidence; skipping it recreates the bug.
|
||||
- **Path containment is the entire security surface.** Resolve, `realpath`, and
|
||||
assert the result is inside the lane's proof root. No request path reaches `fs`
|
||||
unresolved. This is the one part of C worth reviewing carefully.
|
||||
- Routes: manifest, file, delete. UI: thumbnails grouped feature → phase,
|
||||
lightbox, `+N` overflow, the 🎫 ticket-report link.
|
||||
|
||||
**Verify:** traversal attempts (`../`, absolute, symlink out of the root) all
|
||||
rejected; a proof written to the clone root still appears in the manifest.
|
||||
|
||||
---
|
||||
|
||||
## D — Cross-lane named locks
|
||||
|
||||
**Goal:** serialize the steps that thrash a shared machine — builds, e2e runs —
|
||||
across all lanes. **Independent; the smallest phase; ship it whenever.**
|
||||
|
||||
`server/lib/lane-lock.js` today is an in-process promise chain: it serializes
|
||||
work *within one lane, within one process*. This is the other axis and must be a
|
||||
**separate module** (`named-lock.js`); conflating them would be a subtle bug.
|
||||
|
||||
- `mkdir` for atomicity, owner file `lane<slot> <epoch>` — port `lane-lock.sh`.
|
||||
No `flock` dependency, so Shipyard's macOS `brew install flock` prerequisite
|
||||
disappears.
|
||||
- **Time-based staleness with a floor.** A holder older than `LOCK_MAX_HOLD`
|
||||
(default 2700s) is broken on the next acquire, but the floor of 300s stays:
|
||||
it is what stops a caller from force-breaking a live lock via an env var.
|
||||
- `acquire` heartbeats the waiting lane (~60s) so waiting never reads as stalled.
|
||||
- **Port the etiquette text into `docs/LANES.md`.** Waiting is normal; never kill
|
||||
a holder, never delete the lock directory, never shrink `LOCK_MAX_HOLD`. Agents
|
||||
read docs, and this is the rule they break.
|
||||
- `GET /api/locks`, `ccam lock status|acquire|release`, and a lock indicator on
|
||||
the lane card so a human can see *why* a lane is sitting still.
|
||||
|
||||
**Verify:** second acquire blocks then succeeds after release; a holder backdated
|
||||
past `LOCK_MAX_HOLD` is broken; one backdated *within the floor* is not, even
|
||||
with `LOCK_MAX_HOLD=1`; release from a non-holder is refused and leaves the lock.
|
||||
|
||||
---
|
||||
|
||||
## E — `ship-feature` and the QC agents
|
||||
|
||||
**Goal:** port Shipyard's driving skill and its five agents. Needs A2·B·C·D.
|
||||
|
||||
**This is where "CCAM does not orchestrate" is preserved by construction:** the
|
||||
skill runs in the session and calls `ccam` commands; the dashboard still only
|
||||
records.
|
||||
|
||||
- **Pipeline template** `server/data/pipelines/ship-feature.json` — the stages
|
||||
with aliases and `detect` rules in the existing node format. `default.json`
|
||||
untouched; a lane opts in via `pipeline`.
|
||||
- **Skill** `.claude/skills/ship-feature-lane/SKILL.md` — every `"$HARNESS/bin/…"`
|
||||
call rewritten to its `ccam` equivalent (`state.sh set` → `ccam stage`;
|
||||
`activate` → `ccam feature activate`; `lane-ci-gate.sh` → `ccam lanes hook
|
||||
ci-gate`; `lane-up.sh --qc` → `ccam lanes up --qc`; the `.harness-lane` marker
|
||||
disappears because CCAM already resolves a lane from `cwd`).
|
||||
- **Keep, do not trim, the hard-won rules.** Each encodes a real failure: the
|
||||
turn-liveness rule (every turn between stage 1 and done leaves a re-invoker
|
||||
pending), backgrounded-`sleep` polling, the e2e active-poll (a hung suite never
|
||||
fires its completion re-invoke), no-retry-cap/phase-clock, one-driver-per-MCP,
|
||||
never-push-base, and the test-only / localized re-entry fast paths.
|
||||
- **Drop what CCAM makes unnecessary:** the `@@HARNESS_ROOT@@` install-time
|
||||
placeholder and the zsh `source _common.sh` warning. Keep an
|
||||
integration-mismatch check even though its original cause is gone — silently
|
||||
skipping ticket/dev-QC while reporting success is the failure that matters.
|
||||
- **Agents** → `.claude/agents/`: `qc-local`, `dev-qc`, `senior-gate-reviewer`,
|
||||
`ticketer`, `pr-reviewer`. Shipyard generates these per lane with embedded
|
||||
credentials; port that as `ccam lanes agents install`, writing to
|
||||
`<lane>/.claude/agents/`, git-excluded, creds at mode `0600`.
|
||||
- **`ccam lanes sync-base`** — port `lane-sync-dev.sh`. The valuable part is the
|
||||
**migration-number collision pre-check (exit 5)**: another lane's migration
|
||||
landed on the base with the number yours uses. Generic form driven by
|
||||
`MIGRATIONS_DIR` + a filename-number pattern. Also port the keep-ours merge
|
||||
driver for `GENERATED_MERGE_PATHS` plus the post-merge `regen` hook — it kills
|
||||
the single most common cross-lane conflict.
|
||||
|
||||
**Verify:** the skill is prose, so verification is a real dry run on a scratch
|
||||
repo walking stages 0→8, confirming every `ccam` command it names exists and
|
||||
behaves as documented. Each of those commands gets a CLI test.
|
||||
`sync-base --check` gets a unit test with a fixture collision.
|
||||
|
||||
---
|
||||
|
||||
## F — Integrations
|
||||
|
||||
**Goal:** tracker, dev-site QC, CI deploy-wait. All off by default, read from
|
||||
`<repo>/.ccam/profile/integrations.env`, exposed as `ccam lanes integration <name>`
|
||||
(exit 0/1) and in `GET /runtime`.
|
||||
|
||||
- **Tracker** — file one ticket per feature, idempotent (update, never
|
||||
duplicate), writing `proof/<slug>/ticket/REPORT.html`.
|
||||
- **Dev-site QC** — post-merge browser QC against the deployed site with a
|
||||
per-lane account; owns the `qc_dev` field; checkpoints to
|
||||
`RESULTS.partial.md` so a died agent resumes instead of restarting.
|
||||
- **CI deploy-wait** — port `ci-job.sh` (`status`/`failures`/`rerun`/`cancel`) as
|
||||
`ccam ci`, which is what makes the skill's flake-triage rule executable.
|
||||
- **`ccam lanes mcp sync`** — port `lane-mcp-sync.sh`: writes the lane's
|
||||
`.mcp.json` with Playwright servers pinned to the lane's own `--output-dir`.
|
||||
This is what stops two lanes' browser QC from sharing a profile directory.
|
||||
|
||||
**Verify:** all toggles off → the runtime reports them off and the skill's
|
||||
stage-skip path runs; a toggle on in the file but off via the API → the mismatch
|
||||
check fires instead of silently degrading.
|
||||
|
||||
---
|
||||
|
||||
## Order
|
||||
|
||||
```
|
||||
A1 ✅ ──▶ A2 ✅ ──▶ A3 ✅
|
||||
│
|
||||
B ──▶ C ─────────────┼──▶ E ──▶ F
|
||||
D ────────────────────┘
|
||||
```
|
||||
|
||||
B next (it blocks E, and its presets must be derived from the profiles
|
||||
actually written during A1/A2). B, C, D are independent of the A chain and of
|
||||
each other except C→B — take D first if the pain right now is lanes thrashing
|
||||
the machine, B first if it is `clear` erasing history.
|
||||
|
||||
**Per phase, every time:** spec → plan → implement → `npm run test:server` +
|
||||
`npm run test:client` → docs (`update-project-docs`) → review. No phase is done
|
||||
without its own end-to-end manual run, the way A1 proved detachment by killing
|
||||
the server and watching the stack survive.
|
||||
|
||||
---
|
||||
|
||||
## Future: container isolation
|
||||
|
||||
Real isolation beyond Shipyard — per-lane container with its own filesystem view,
|
||||
network namespace and resource limits.
|
||||
|
||||
The hard part is not the container. It is that Claude Code, its hooks and its MCP
|
||||
servers must run *inside* it, so the hook → API path crosses a container
|
||||
boundary, and the `cwd` a hook reports becomes a container path that no longer
|
||||
matches the lane's host `cwd`. That breaks `resolveLaneByCwd` — the binding
|
||||
between a session and a lane, and the assumption the entire lane model rests on.
|
||||
`~/.claude` would also have to be mounted or synthesized per lane.
|
||||
|
||||
A design document of its own, not a phase of this one.
|
||||
Reference in New Issue
Block a user