57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
134 lines
8.9 KiB
Markdown
134 lines
8.9 KiB
Markdown
# Worktree-backed lanes + Shipyard-style lifecycle — design
|
|
|
|
**Status:** approved 2026-07-28. Sub-project C of three (B = stage auto-detection, A = merged Workspace page) — each gets its own spec, plan and execution cycle. C is being built first because it fixes the lane data model that the other two build on.
|
|
|
|
## Problem
|
|
|
|
A lane today is a pointer at a directory that already exists. Two agents working in parallel therefore work in the *same* checkout and collide — the exact failure Shipyard solves by giving every lane its own clone. CCAM has no provisioning at all: no way to create a lane's working copy, no way to reset it between features, no way to remove it, and no way to reclaim the database a finished lane leaves behind (a real install reached 121 MB).
|
|
|
|
## Goals
|
|
|
|
- A lane can own a **git worktree** that CCAM creates, resets and removes.
|
|
- The lifecycle verbs mirror Shipyard's, because that vocabulary is proven: `add`, `clear`, `reset`, `remove`, plus `purge` as CCAM's analogue of Shipyard's per-lane `dropdb`.
|
|
- Every destructive action is confirmed **against counted facts**, not adjectives.
|
|
- Directories the user already had must be impossible for CCAM to destroy.
|
|
|
|
## Non-goals
|
|
|
|
- No dependency bootstrap. A fresh worktree has no `node_modules`, no `.env`, no `.claude/settings.local.json` — all gitignored. Shipyard solves this with per-project `bootstrap`/`migrate`/`seed` hooks, which is a whole subsystem. Out of scope; documented as a limitation.
|
|
- No per-lane ports, databases, or Docker services.
|
|
- No orchestration. Unchanged from the existing feature: the driving Claude session declares its own stage.
|
|
- `VACUUM` is not part of `purge` (see Database reclamation).
|
|
|
|
## Data model
|
|
|
|
Additive columns on `lanes`, each guarded by the repo's `try { SELECT col } catch { ALTER }` probe:
|
|
|
|
| column | meaning |
|
|
|---|---|
|
|
| `kind` | `adopted` \| `managed`. Default `adopted`, so every pre-existing row migrates into the safe class. |
|
|
| `source_repo` | absolute path of the checkout a managed worktree was created from |
|
|
| `base_branch` | the branch the worktree was cut from, e.g. `development` |
|
|
| `slug` | sanitised from the title; used for both the directory and the branch name |
|
|
|
|
`branch`, `cwd`, `stage`, `stages` and the rest keep their current meaning. `cwd` stays `UNIQUE`.
|
|
|
|
Environment: `LANES_ROOT` (default `~/.claude/ccam-lanes`), `LANE_BASE_BRANCH`, `LANE_BRANCH_PREFIX` (default `feat/`).
|
|
|
|
Layout: worktree at `$LANES_ROOT/<repo-basename>__<slug>`, branch `<prefix><slug>`. Numbered `lane1..lane9` slots were considered and rejected — CCAM is multi-repo, and slot numbers carry no meaning without Shipyard's per-lane ports.
|
|
|
|
## Safety model
|
|
|
|
`adopted` lanes expose no destructive verb. No reset, no remove-with-worktree, no branch deletion. The UI hides those controls; the API refuses them.
|
|
|
|
A `managed` lane may be destroyed only when **all three** independent checks pass:
|
|
|
|
1. `kind === 'managed'`
|
|
2. the lane's `cwd`, fully resolved (symlinks included), lies inside `LANES_ROOT`
|
|
3. `git worktree list --porcelain` run in `source_repo` actually lists that path
|
|
|
|
Shipyard gets away with one check (`case "$DIR" in */lane$N`) because its directory names are fixed. Dropping numbered slots costs that guarantee, so three cheaper checks replace it. Every destructive function in `server/lib/worktree.js` re-runs the three checks itself rather than trusting its caller.
|
|
|
|
CCAM never runs `rm -rf` on a lane. Removal goes through `git worktree remove`; if git refuses, the error surfaces unchanged.
|
|
|
|
## Verbs
|
|
|
|
| verb | steps | destructive |
|
|
|---|---|---|
|
|
| `add` | resolve base → `git worktree add -b <prefix><slug> <dir> <base>` → insert lane row `kind=managed` | no |
|
|
| `adopt` | today's `POST /api/lanes` — point a lane at an existing directory, `kind=adopted` | no |
|
|
| `clear` | reset stage/status fields only (already implemented) | no |
|
|
| `reset` | kill and await the run → `git fetch origin --prune` → checkout base → `reset --hard <base>` → `clean -fd` → delete the feature branch → recreate it from base → clear lane state | **yes** |
|
|
| `remove` | kill and await the run → unlock if locked → `git worktree remove --force` → `git worktree prune` → delete the branch → delete the lane row | **yes** |
|
|
| `purge` | delete the lane's sessions and their events plus the orphan `token_usage` rows | **yes** |
|
|
|
|
`clean -fd` deliberately omits `-x`, exactly as Shipyard does: gitignored files (`node_modules`, `.env`) survive a reset, untracked-but-not-ignored files do not.
|
|
|
|
Branch deletion never touches `main`, `master`, or the lane's `base_branch`, and only runs after the worktree holding that branch is gone.
|
|
|
|
## Preflight
|
|
|
|
`GET /api/lanes/:id/preflight?action=reset|remove|purge` returns counted facts, never prose:
|
|
|
|
```json
|
|
{
|
|
"action": "reset",
|
|
"lane": 3, "branch": "feat/criteria-form", "kind": "managed",
|
|
"dirty": 4, "untracked": 11, "unpushed": 2,
|
|
"head": "9b3e74a",
|
|
"blocked": ["unpushed-commits"],
|
|
"warnings": ["no-remote"]
|
|
}
|
|
```
|
|
|
|
For `purge`: `sessions`, `events`, `tokenRows`, `bytesEstimate`, and `activeSessionSkipped`.
|
|
|
|
The confirmation modal renders those numbers. The action then re-verifies: the client echoes back the `head` and counts it was shown, and the server returns `409` if they moved. `unpushed > 0` blocks `reset`, and `remove` when a managed worktree is actually at risk, unless the request carries `{force: true}`. `unpushed` counts what the action would really discard — with no remote that is `<base_branch>..HEAD`, this lane's own work, not the repository's whole history.
|
|
|
|
## Concurrency
|
|
|
|
One mutex per lane serialises destructive actions, mirroring `repo_lock()` in the AutomaticWorkflow bot — where concurrent git operations on a shared checkout produced a real `git checkout` exit 128, not a theoretical one. A destructive action first kills the lane's run and **awaits its exit** before touching git.
|
|
|
|
`add` can take seconds on a large repo, so it returns `202` with `status=provisioning` and finishes in the background, broadcasting `lane_update` on completion — the same pattern Shipyard uses to drive its spinner.
|
|
|
|
## Edge cases and their resolutions
|
|
|
|
- **Branch already exists:** if unused, `git worktree add` without `-b`; if checked out elsewhere, refuse and name the other path. Slug collisions get a `-2` suffix.
|
|
- **Base branch missing on origin:** resolve `origin/<base>` → local `<base>` → the source repo's current HEAD.
|
|
- **Source repo is itself a worktree, or bare:** works; git resolves through `--git-common-dir`.
|
|
- **Repo with no commits:** `worktree add` fails and the lane lands `failed` with git's stderr in `notes`. Preflight does not pre-empt it — the lane is created first, then provisioning reports the git failure, and the row is forgotten with `DELETE /api/lanes/:id`.
|
|
- **Worktree directory deleted by hand:** the lane reports `missing` and only `remove` is offered, taking the prune path.
|
|
- **`cwd` uniqueness:** `remove` deletes the row, so re-adding the same slug is clean; `reset` keeps the path.
|
|
|
|
## Database reclamation
|
|
|
|
`events` cascades from `sessions`, but `token_usage` has no foreign key — `purge` must delete those rows explicitly or leave orphans. The currently-live session is never purged.
|
|
|
|
SQLite does not shrink on `DELETE`. `purge` runs `DELETE` plus `PRAGMA optimize` and reports the reclaimable size; `VACUUM` is a separate, explicitly-labelled maintenance action because it locks the whole database for seconds. Hiding a database-wide lock inside a button labelled "clean up" would be a trap.
|
|
|
|
## Surfaces
|
|
|
|
**API:** `POST /api/lanes/worktree` (add), `GET /api/lanes/:id/preflight`, and `reset` / `purge` joining the existing `POST /api/lanes/:id/:action` set, all behind the existing same-origin guard.
|
|
|
|
**CLI:** `ccam lanes add --repo <path> [--title <t>] [--base <branch>]`, `ccam lanes reset|remove|purge <id> [--force]`.
|
|
|
|
**UI:** a `managed` / `adopted` badge on the lane card; destructive buttons rendered only for `managed`; the existing `ConfirmModal` showing the preflight table.
|
|
|
|
## Testing
|
|
|
|
Against a real git repository fixture created in a temp directory — no mocks, because every bug worth catching here lives in git's actual behaviour:
|
|
|
|
- worktree created, listed, removed, pruned clean
|
|
- each of the three safety refusals: an `adopted` lane, a path outside `LANES_ROOT`, a path git does not list as a worktree
|
|
- `reset` keeps gitignored files and removes untracked ones
|
|
- the unpushed-commit guard blocks, and `force` overrides it
|
|
- preflight's counts equal what the action actually changes
|
|
- `purge` removes sessions, events and token rows, and skips the live session
|
|
- `add` returns 202 and broadcasts `lane_update` when provisioning finishes
|
|
|
|
## Known limitations
|
|
|
|
- A fresh worktree has no installed dependencies or local env files (see Non-goals).
|
|
- Nine worktrees of a large repository cost nine working trees of disk; the `add` preflight estimates the size first.
|
|
- `--repo` may point anywhere the user can read. That is their own machine; validation is limited to "absolute, exists, is a git repo", and every route stays behind the loopback guard.
|