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.
581 lines
32 KiB
Markdown
581 lines
32 KiB
Markdown
# Worktree-backed Lanes Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Let a lane own a git worktree that CCAM creates, resets, removes and purges, with every destructive action gated on counted facts and on three independent safety checks.
|
|
|
|
**Architecture:** Design doc: `docs/superpowers/specs/2026-07-28-worktree-lanes-design.md` — read it once before Task 1. All git work goes through one module (`server/lib/worktree.js`) that shells out with `execFile` and an argv array, never a shell string, and re-verifies its own safety preconditions. Lanes gain a `kind` of `adopted` (pointer at a directory the user already had — never destroyable) or `managed` (a worktree CCAM created — destroyable). Destructive actions are serialised per lane and preceded by a preflight endpoint that returns counts, which the confirmation UI renders and the server re-checks before acting.
|
|
|
|
**Tech Stack:** Node 18+, Express, better-sqlite3, `node:child_process.execFile`, real `git` against temp-directory fixtures, `node:test` (server), React 18 + TypeScript + Vitest (client).
|
|
|
|
## Global Constraints
|
|
|
|
- Branch: create `feat/worktree-lanes` off the current head of `feat/lanes-pipeline`. Never work on `master`.
|
|
- Every `.js/.ts/.tsx` file created or modified MUST start with a file overview comment plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. Verify with `bash .claude/skills/file-headers/scripts/check-headers.sh` (must exit 0).
|
|
- Schema changes are additive only and migration-safe on an existing database: `try { SELECT col } catch { ALTER TABLE … ADD COLUMN }`, the pattern at `server/db.js:412-418`. Existing rows must migrate to `kind='adopted'`.
|
|
- **Never `rm -rf` a lane directory.** Removal goes through `git worktree remove`; if git refuses, surface git's error unchanged.
|
|
- **Never build a shell command string.** `execFile("git", [...args])` only. No `shell: true`, no template-literal commands.
|
|
- Destructive routes stay behind the existing same-origin guard exported from `server/routes/run.js`.
|
|
- Preserve existing behavior: no existing route, response shape, WebSocket type, or CLI command changes meaning. `lane_update` stays the only lane WS type.
|
|
- Server is CommonJS. No new npm dependencies. Server tests use `node:test` + `node:assert/strict`; client tests use Vitest + Testing Library.
|
|
- The pre-commit hook runs Prettier and the full server suite; a commit takes minutes. Do not disable it.
|
|
- Baseline before this plan: 790 server tests, 279 client tests, all passing.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**Create**
|
|
- `server/lib/worktree.js` — every git invocation, plus the three-check safety guard. No Express, no DB.
|
|
- `server/lib/lane-preflight.js` — counts for `reset` / `remove` / `purge`. Reads git and the DB; mutates nothing.
|
|
- `server/lib/lane-lock.js` — per-lane async mutex.
|
|
- `server/__tests__/worktree.test.js` — git behaviour against a real temp repo.
|
|
- `server/__tests__/lane-lifecycle.test.js` — HTTP: add / preflight / reset / remove / purge.
|
|
- `client/src/components/lanes/DestructiveLaneModal.tsx` — preflight table inside the existing `ConfirmModal`.
|
|
|
|
**Modify**
|
|
- `server/db.js` — four additive columns.
|
|
- `server/lib/lanes.js` — `kind`/`source_repo`/`base_branch`/`slug` in create/patch/payload; `purgeLaneSessions`.
|
|
- `server/routes/lanes.js` — `POST /worktree`, `GET /:id/preflight`, `reset` + `purge` actions, lock usage.
|
|
- `bin/ccam.js` — `ccam lanes add --repo`, `ccam lanes reset|remove|purge`.
|
|
- `client/src/lib/api.ts`, `client/src/lib/types.ts` — preflight + worktree types and calls.
|
|
- `client/src/components/lanes/LaneCard.tsx` — kind badge; destructive buttons only for `managed`.
|
|
- `docs/LANES.md`, `CLAUDE.md` — the new lifecycle.
|
|
|
|
---
|
|
|
|
## Task 1: `server/lib/worktree.js` — git plumbing and the safety guard
|
|
|
|
**Files:**
|
|
- Create: `server/lib/worktree.js`
|
|
- Test: `server/__tests__/worktree.test.js`
|
|
|
|
**Interfaces:**
|
|
- Consumes: nothing from earlier tasks.
|
|
- Produces:
|
|
- `LANES_ROOT` — `process.env.LANES_ROOT || path.join(os.homedir(), ".claude", "ccam-lanes")`
|
|
- `git(cwd, args): Promise<{stdout, stderr}>` — rejects with `err.git = {args, code, stderr}` on non-zero
|
|
- `isGitRepo(dir): Promise<boolean>`
|
|
- `resolveBase(sourceRepo, wanted): Promise<string>` — `origin/<wanted>` → `<wanted>` → current HEAD
|
|
- `slugify(text): string` — lowercase, non-alphanumerics to `-`, collapsed, trimmed, max 40 chars
|
|
- `listWorktrees(sourceRepo): Promise<Array<{path, branch, locked}>>` — parses `--porcelain`
|
|
- `branchCheckedOutAt(sourceRepo, branch): Promise<string|null>`
|
|
- `addWorktree({sourceRepo, dir, branch, base}): Promise<{dir, branch, created: boolean}>`
|
|
- `assertDestroyable(lane): Promise<void>` — the three checks; throws `err.code = "ENOTMANAGED" | "EOUTSIDEROOT" | "ENOTWORKTREE"`
|
|
- `resetWorktree(lane): Promise<void>`
|
|
- `removeWorktree(lane): Promise<void>`
|
|
- `statusCounts(dir): Promise<{dirty, untracked, head}>`
|
|
- `unpushedCount(dir): Promise<number>`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `server/__tests__/worktree.test.js`. It builds a real repository in a temp directory — mocks would test nothing that matters here.
|
|
|
|
```js
|
|
/**
|
|
* @file Tests for server/lib/worktree.js against a REAL git repository created
|
|
* in a temp directory. Every behaviour worth testing here is git's own — branch
|
|
* collisions, what `clean -fd` spares, what `worktree list` reports — so mocking
|
|
* git would only test our idea of git.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { execFileSync } = require("node:child_process");
|
|
|
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-wt-"));
|
|
process.env.LANES_ROOT = path.join(ROOT, "lanes");
|
|
|
|
const wt = require("../lib/worktree");
|
|
|
|
const SRC = path.join(ROOT, "src-repo");
|
|
const g = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8" });
|
|
|
|
before(() => {
|
|
fs.mkdirSync(SRC, { recursive: true });
|
|
g(SRC, "init", "-b", "main");
|
|
g(SRC, "config", "user.email", "t@example.com");
|
|
g(SRC, "config", "user.name", "Test");
|
|
fs.writeFileSync(path.join(SRC, "README.md"), "hello\n");
|
|
fs.writeFileSync(path.join(SRC, ".gitignore"), "node_modules/\n.env\n");
|
|
g(SRC, "add", "-A");
|
|
g(SRC, "commit", "-m", "init");
|
|
});
|
|
|
|
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
|
|
|
function laneFor(dir, branch, over = {}) {
|
|
return { id: 1, kind: "managed", cwd: dir, branch, source_repo: SRC, base_branch: "main", ...over };
|
|
}
|
|
|
|
describe("worktree", () => {
|
|
it("slugifies a title into a safe single segment", () => {
|
|
assert.equal(wt.slugify("Rename Metric → Rule!"), "rename-metric-rule");
|
|
assert.equal(wt.slugify(" a//b "), "a-b");
|
|
assert.ok(wt.slugify("x".repeat(80)).length <= 40);
|
|
});
|
|
|
|
it("resolves the base branch, falling back when origin has none", async () => {
|
|
assert.equal(await wt.resolveBase(SRC, "main"), "main");
|
|
assert.equal(await wt.resolveBase(SRC, "does-not-exist"), "main");
|
|
});
|
|
|
|
it("creates a worktree on a new branch and lists it", async () => {
|
|
const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
|
|
const r = await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/alpha", base: "main" });
|
|
assert.equal(r.created, true);
|
|
assert.ok(fs.existsSync(path.join(dir, "README.md")));
|
|
const list = await wt.listWorktrees(SRC);
|
|
assert.ok(list.some((w) => w.path === dir && w.branch === "feat/alpha"));
|
|
});
|
|
|
|
it("refuses a branch already checked out in another worktree", async () => {
|
|
const dir2 = path.join(process.env.LANES_ROOT, "src-repo__alpha2");
|
|
await assert.rejects(
|
|
() => wt.addWorktree({ sourceRepo: SRC, dir: dir2, branch: "feat/alpha", base: "main" }),
|
|
(e) => e.code === "EBRANCHBUSY" && typeof e.checkedOutAt === "string",
|
|
);
|
|
});
|
|
|
|
it("counts dirty, untracked and unpushed work", async () => {
|
|
const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
|
|
fs.appendFileSync(path.join(dir, "README.md"), "edit\n");
|
|
fs.writeFileSync(path.join(dir, "scratch.txt"), "untracked\n");
|
|
fs.mkdirSync(path.join(dir, "node_modules"), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, "node_modules", "dep.js"), "x\n");
|
|
const s = await wt.statusCounts(dir);
|
|
assert.equal(s.dirty, 1);
|
|
assert.equal(s.untracked, 1); // node_modules is ignored, so it does not count
|
|
assert.match(s.head, /^[0-9a-f]{7,40}$/);
|
|
assert.equal(await wt.unpushedCount(dir), 0); // no upstream yet
|
|
});
|
|
|
|
it("reset restores base, drops untracked files, and spares gitignored ones", async () => {
|
|
const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
|
|
await wt.resetWorktree(laneFor(dir, "feat/alpha"));
|
|
assert.equal(fs.readFileSync(path.join(dir, "README.md"), "utf8"), "hello\n");
|
|
assert.equal(fs.existsSync(path.join(dir, "scratch.txt")), false);
|
|
assert.equal(fs.existsSync(path.join(dir, "node_modules", "dep.js")), true);
|
|
const s = await wt.statusCounts(dir);
|
|
assert.equal(s.dirty, 0);
|
|
});
|
|
|
|
it("refuses to destroy an adopted lane, a path outside LANES_ROOT, or a non-worktree", async () => {
|
|
const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
|
|
await assert.rejects(
|
|
() => wt.assertDestroyable(laneFor(dir, "feat/alpha", { kind: "adopted" })),
|
|
(e) => e.code === "ENOTMANAGED",
|
|
);
|
|
await assert.rejects(
|
|
() => wt.assertDestroyable(laneFor("/tmp", "feat/alpha")),
|
|
(e) => e.code === "EOUTSIDEROOT",
|
|
);
|
|
const ghost = path.join(process.env.LANES_ROOT, "src-repo__ghost");
|
|
fs.mkdirSync(ghost, { recursive: true });
|
|
await assert.rejects(
|
|
() => wt.assertDestroyable(laneFor(ghost, "feat/ghost")),
|
|
(e) => e.code === "ENOTWORKTREE",
|
|
);
|
|
});
|
|
|
|
it("removes the worktree and its branch, leaving git's list clean", async () => {
|
|
const dir = path.join(process.env.LANES_ROOT, "src-repo__alpha");
|
|
await wt.removeWorktree(laneFor(dir, "feat/alpha"));
|
|
assert.equal(fs.existsSync(dir), false);
|
|
const list = await wt.listWorktrees(SRC);
|
|
assert.equal(list.some((w) => w.path === dir), false);
|
|
const branches = g(SRC, "branch", "--list", "feat/alpha").trim();
|
|
assert.equal(branches, "");
|
|
});
|
|
|
|
it("never deletes the base branch even if a lane claims it", async () => {
|
|
const dir = path.join(process.env.LANES_ROOT, "src-repo__beta");
|
|
await wt.addWorktree({ sourceRepo: SRC, dir, branch: "feat/beta", base: "main" });
|
|
await wt.removeWorktree(laneFor(dir, "main")); // lane lies about its branch
|
|
assert.match(g(SRC, "branch", "--list", "main"), /main/);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test server/__tests__/worktree.test.js`
|
|
Expected: FAIL — `Cannot find module '../lib/worktree'`.
|
|
|
|
- [ ] **Step 3: Implement the module**
|
|
|
|
Create `server/lib/worktree.js`. Key requirements the tests pin, restated so nothing is inferred:
|
|
|
|
- `git(cwd, args)` wraps `execFile("git", args, {cwd, maxBuffer: 8 * 1024 * 1024})` promisified. On failure throw an `Error` carrying `err.git = { args, code, stderr }`.
|
|
- `slugify` — `text.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40)`, and if the result is empty throw `err.code = "EBADSLUG"`.
|
|
- `resolveBase(sourceRepo, wanted)` — try `git rev-parse --verify --quiet origin/<wanted>`, then `<wanted>`, then `git rev-parse --abbrev-ref HEAD`. Return the first that resolves.
|
|
- `listWorktrees` — parse `git worktree list --porcelain`: records separated by blank lines, `worktree <path>`, `branch refs/heads/<name>`, bare `locked` line. Return `{path, branch, locked}` with `branch` null for a detached worktree.
|
|
- `branchCheckedOutAt(sourceRepo, branch)` — the `path` from `listWorktrees` whose branch matches, else null.
|
|
- `addWorktree({sourceRepo, dir, branch, base})`:
|
|
- if `branchCheckedOutAt` returns a path, throw `err.code = "EBRANCHBUSY"`, `err.checkedOutAt = thatPath`
|
|
- `fs.mkdirSync(path.dirname(dir), {recursive: true})`
|
|
- if `git rev-parse --verify --quiet <branch>` succeeds, run `worktree add <dir> <branch>` and return `{created: false}`; otherwise `worktree add -b <branch> <dir> <base>` and return `{created: true}`
|
|
- `assertDestroyable(lane)` — in order: `kind !== "managed"` → `ENOTMANAGED`; `fs.realpathSync(lane.cwd)` not inside `fs.realpathSync(LANES_ROOT)` on a path boundary → `EOUTSIDEROOT` (a non-existent path fails this check too, which is correct — it cannot be a live worktree); not present in `listWorktrees(lane.source_repo)` → `ENOTWORKTREE`.
|
|
- `PROTECTED_BRANCHES = new Set(["main", "master"])`, plus the lane's own `base_branch`: `deleteBranchSafely(sourceRepo, branch, baseBranch)` returns without acting when the branch is protected or falsy.
|
|
- `resetWorktree(lane)` — `assertDestroyable` first, then, all in `lane.cwd`: `fetch origin --prune` (tolerate failure when there is no remote), `checkout <base>` (creating it from `origin/<base>` if absent), `reset --hard <base>`, `clean -fd` (**never** `-x`), then in `source_repo` `deleteBranchSafely(lane.branch)`, then back in the worktree `checkout -b <lane.branch> <base>`.
|
|
- `removeWorktree(lane)` — `assertDestroyable`, then in `source_repo`: `worktree unlock <dir>` (ignore failure), `worktree remove --force <dir>`, `worktree prune`, `deleteBranchSafely(lane.branch, lane.base_branch)`. If `worktree remove` fails, rethrow git's error untouched — do not fall back to filesystem deletion.
|
|
- `statusCounts(dir)` — parse `git status --porcelain=v1 --untracked-files=normal`: lines starting `??` are untracked, others dirty. `head` from `git rev-parse --short HEAD`.
|
|
- `unpushedCount(dir)` — `git rev-list --count @{u}..HEAD`; when there is no upstream, git exits non-zero — return 0.
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `node --test server/__tests__/worktree.test.js`
|
|
Expected: PASS, 8 tests.
|
|
|
|
- [ ] **Step 5: Header audit and commit**
|
|
|
|
Run: `bash .claude/skills/file-headers/scripts/check-headers.sh`
|
|
|
|
```bash
|
|
git add server/lib/worktree.js server/__tests__/worktree.test.js
|
|
git commit -m "feat(lanes): git worktree plumbing with a three-check destroy guard"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Schema, lane fields, and per-lane locking
|
|
|
|
**Files:**
|
|
- Modify: `server/db.js` (the `lanes` block)
|
|
- Modify: `server/lib/lanes.js`
|
|
- Create: `server/lib/lane-lock.js`
|
|
- Test: `server/__tests__/lanes-lib.test.js` (append a `describe`)
|
|
|
|
**Interfaces:**
|
|
- Consumes: nothing from Task 1 (kept independent so both can be reviewed alone).
|
|
- Produces:
|
|
- four columns on `lanes`: `kind` (`NOT NULL DEFAULT 'adopted'`), `source_repo`, `base_branch`, `slug`
|
|
- `createLane` accepts and stores `kind`, `source_repo`, `base_branch`, `slug`; unknown values of `kind` are rejected with `err.code = "EBADKIND"`
|
|
- `PATCHABLE` gains `kind`, `source_repo`, `base_branch`, `slug`
|
|
- `purgeLaneSessions(id): {sessions, events, tokenRows}` — deletes the lane's sessions (never the one in `lanes.session_id`), their events, and `token_usage` rows left orphaned
|
|
- `server/lib/lane-lock.js`: `withLaneLock(id, fn): Promise<any>` — serialises per lane id, releases on throw
|
|
|
|
- [ ] **Step 1: Write the failing test** (append to `server/__tests__/lanes-lib.test.js`)
|
|
|
|
```js
|
|
const { withLaneLock } = require("../lib/lane-lock");
|
|
|
|
describe("lane kind, worktree fields and purge", () => {
|
|
it("defaults to adopted and stores worktree fields when given", () => {
|
|
const a = lanes.createLane({ cwd: "/tmp/wt-kind-a" });
|
|
assert.equal(a.kind, "adopted");
|
|
const m = lanes.createLane({
|
|
cwd: "/tmp/wt-kind-b", kind: "managed",
|
|
source_repo: "/tmp/src", base_branch: "main", slug: "b",
|
|
});
|
|
assert.equal(m.kind, "managed");
|
|
assert.equal(m.source_repo, "/tmp/src");
|
|
assert.equal(m.base_branch, "main");
|
|
assert.equal(m.slug, "b");
|
|
lanes.deleteLane(a.id);
|
|
lanes.deleteLane(m.id);
|
|
});
|
|
|
|
it("rejects an unknown kind", () => {
|
|
assert.throws(() => lanes.createLane({ cwd: "/tmp/wt-kind-c", kind: "gremlin" }),
|
|
(e) => e.code === "EBADKIND");
|
|
});
|
|
|
|
it("purges a lane's sessions, their events and orphaned token rows, sparing the live one", () => {
|
|
const l = lanes.createLane({ cwd: "/tmp/wt-purge" });
|
|
const { db } = require("../db");
|
|
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'completed')").run("purge-1", "/tmp/wt-purge/sub");
|
|
db.prepare("INSERT INTO sessions (id, cwd, status) VALUES (?, ?, 'active')").run("purge-live", "/tmp/wt-purge");
|
|
db.prepare("INSERT INTO events (session_id, event_type) VALUES (?, 'PostToolUse')").run("purge-1");
|
|
db.prepare("INSERT INTO token_usage (session_id, model, input_tokens) VALUES (?, 'm', 5)").run("purge-1");
|
|
lanes.updateLane(l.id, { session_id: "purge-live" });
|
|
|
|
const counts = lanes.purgeLaneSessions(l.id);
|
|
assert.equal(counts.sessions, 1);
|
|
assert.equal(counts.events, 1);
|
|
assert.equal(counts.tokenRows, 1);
|
|
assert.equal(db.prepare("SELECT COUNT(*) c FROM sessions WHERE id='purge-live'").get().c, 1);
|
|
assert.equal(db.prepare("SELECT COUNT(*) c FROM events WHERE session_id='purge-1'").get().c, 0);
|
|
assert.equal(db.prepare("SELECT COUNT(*) c FROM token_usage WHERE session_id='purge-1'").get().c, 0);
|
|
lanes.deleteLane(l.id);
|
|
});
|
|
|
|
it("serialises work per lane and releases the lock when the body throws", async () => {
|
|
const order = [];
|
|
const slow = withLaneLock(7, async () => { order.push("a-start"); await new Promise((r) => setTimeout(r, 50)); order.push("a-end"); });
|
|
const fast = withLaneLock(7, async () => { order.push("b"); });
|
|
await Promise.all([slow, fast]);
|
|
assert.deepEqual(order, ["a-start", "a-end", "b"]);
|
|
await assert.rejects(() => withLaneLock(7, async () => { throw new Error("boom"); }));
|
|
await withLaneLock(7, async () => order.push("c"));
|
|
assert.equal(order[order.length - 1], "c");
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test server/__tests__/lanes-lib.test.js`
|
|
Expected: FAIL — `Cannot find module '../lib/lane-lock'`.
|
|
|
|
- [ ] **Step 3: Add the columns**
|
|
|
|
In `server/db.js`, after the `lanes` table and its index, following the probe pattern at `server/db.js:412-418`:
|
|
|
|
```js
|
|
// Managed lanes own a git worktree CCAM created and may be destroyed; adopted
|
|
// lanes merely point at a directory the user already had and never may be.
|
|
// Existing rows default to 'adopted', so no lane gains a destructive path by
|
|
// upgrading.
|
|
try {
|
|
db.prepare("SELECT kind FROM lanes LIMIT 1").get();
|
|
} catch {
|
|
db.prepare("ALTER TABLE lanes ADD COLUMN kind TEXT NOT NULL DEFAULT 'adopted'").run();
|
|
db.prepare("ALTER TABLE lanes ADD COLUMN source_repo TEXT").run();
|
|
db.prepare("ALTER TABLE lanes ADD COLUMN base_branch TEXT").run();
|
|
db.prepare("ALTER TABLE lanes ADD COLUMN slug TEXT").run();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Extend `server/lib/lanes.js` and write the lock**
|
|
|
|
`createLane` gains the four fields (validating `kind` against `new Set(["adopted", "managed"])`), `PATCHABLE` gains them, and `purgeLaneSessions(id)` runs inside one `db.transaction`:
|
|
|
|
- select the lane's sessions: `WHERE (cwd = ? OR cwd LIKE ? || '/%')` against `lane.cwd`, excluding `lanes.session_id` and any session whose `status = 'active'`
|
|
- count and delete their `events`, then their `token_usage`, then the sessions themselves
|
|
- return `{sessions, events, tokenRows}`
|
|
- run `db.pragma("optimize")` after the transaction commits — never `VACUUM`, which locks the whole database
|
|
|
|
Create `server/lib/lane-lock.js` — a `Map<laneId, Promise>` chain:
|
|
|
|
```js
|
|
const chains = new Map();
|
|
|
|
function withLaneLock(id, fn) {
|
|
const key = String(id);
|
|
const prev = chains.get(key) || Promise.resolve();
|
|
const run = prev.then(fn, fn); // run regardless of how the previous holder settled
|
|
// Keep the chain alive but never let a rejection poison the next waiter.
|
|
chains.set(key, run.then(() => {}, () => {}));
|
|
return run;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests to verify they pass**
|
|
|
|
Run: `node --test server/__tests__/lanes-lib.test.js`
|
|
Expected: PASS — the four new tests plus every earlier one.
|
|
|
|
- [ ] **Step 6: Full suite and commit**
|
|
|
|
Run: `npm run test:server`
|
|
|
|
```bash
|
|
git add server/db.js server/lib/lanes.js server/lib/lane-lock.js server/__tests__/lanes-lib.test.js
|
|
git commit -m "feat(lanes): managed/adopted kinds, worktree fields, session purge, per-lane lock"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Preflight — counted facts before anything destructive
|
|
|
|
**Files:**
|
|
- Create: `server/lib/lane-preflight.js`
|
|
- Test: `server/__tests__/lane-lifecycle.test.js` (new file; later tasks append to it)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `statusCounts`, `unpushedCount`, `listWorktrees` (Task 1); `getLane` (Task 2).
|
|
- Produces: `preflight(lane, action): Promise<object>` where `action ∈ "reset" | "remove" | "purge"`.
|
|
- `reset` / `remove` → `{action, lane, kind, branch, dirty, untracked, unpushed, head, blocked: string[], warnings: string[]}`
|
|
- `purge` → `{action, lane, sessions, events, tokenRows, bytesEstimate, activeSessionSkipped: boolean}`
|
|
- `blocked` contains `"adopted"` when the lane is not managed, `"missing"` when the directory is gone, and `"unpushed-commits"` when `unpushed > 0`. It is advisory data, not an exception — the route decides.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `server/__tests__/lane-lifecycle.test.js` with the standard harness (temp `DASHBOARD_DB_PATH`, `DASHBOARD_REMOTE_SYNC_MS=0`, `DASHBOARD_LIVENESS_PROBE=0`, `LANES_ROOT` pointed at a temp dir, `startServer(createApp(), 0)`; copy the request helper from `server/__tests__/lanes-api.test.js`), plus a real git fixture repo as in Task 1. Tests:
|
|
|
|
```js
|
|
it("preflight on an adopted lane blocks and counts nothing", async () => { /* create adopted lane, GET preflight?action=reset, expect blocked includes "adopted" */ });
|
|
it("preflight counts dirty, untracked and unpushed for a managed lane", async () => { /* dirty the worktree, expect dirty:1 untracked:1 and a head sha */ });
|
|
it("preflight for purge counts only this lane's non-live sessions", async () => { /* two sessions, one bound live, expect sessions:1 and activeSessionSkipped:true */ });
|
|
it("preflight 404s for an unknown lane and 400s for an unknown action", async () => {});
|
|
```
|
|
|
|
- [ ] **Step 2: Run to verify it fails**
|
|
|
|
Run: `node --test server/__tests__/lane-lifecycle.test.js`
|
|
Expected: FAIL — the route does not exist yet (404 with an HTML body).
|
|
|
|
- [ ] **Step 3: Implement `server/lib/lane-preflight.js` and the route**
|
|
|
|
The module is read-only. `bytesEstimate` is `(events + tokenRows) * 512` — label it in `docs/LANES.md` as a rough estimate, because a real per-row size needs `dbstat`, which is not compiled in by default.
|
|
|
|
In `server/routes/lanes.js`, add **before** the `/:id/:action` route so it is not swallowed:
|
|
|
|
```js
|
|
router.get("/:id/preflight", async (req, res) => {
|
|
const lane = lanesLib.getLane(req.params.id);
|
|
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
|
const action = String(req.query.action || "");
|
|
if (!["reset", "remove", "purge"].includes(action)) {
|
|
return res.status(400).json({ error: { code: "EBADACTION", message: `unknown action ${action}` } });
|
|
}
|
|
try {
|
|
res.json(await preflight(lane, action));
|
|
} catch (err) {
|
|
res.status(500).json({ error: { code: err.code, message: err.message } });
|
|
}
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Run to verify it passes**
|
|
|
|
Run: `node --test server/__tests__/lane-lifecycle.test.js`
|
|
Expected: PASS, 4 tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add server/lib/lane-preflight.js server/routes/lanes.js server/__tests__/lane-lifecycle.test.js
|
|
git commit -m "feat(lanes): preflight counts for reset, remove and purge"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: `add` — provision a worktree in the background
|
|
|
|
**Files:**
|
|
- Modify: `server/routes/lanes.js`
|
|
- Test: `server/__tests__/lane-lifecycle.test.js` (append)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `addWorktree`, `resolveBase`, `slugify`, `LANES_ROOT` (Task 1); `createLane`, `updateLane` (Task 2); `broadcastLane`, `sameOriginGuard` (existing).
|
|
- Produces: `POST /api/lanes/worktree` with body `{sourceRepo, title, base?, slug?}` → `202 {lane}` with `status: "provisioning"`, then a background `lane_update` when the worktree is ready or `status: "failed"` with the git error in `notes`.
|
|
|
|
- [ ] **Step 1: Write the failing tests** (append)
|
|
|
|
```js
|
|
it("creates a managed lane, returns 202 provisioning, then flips to idle when the worktree lands", async () => {});
|
|
it("rejects a sourceRepo that is not an absolute path or not a git repo", async () => {});
|
|
it("suffixes the slug when the directory already exists", async () => {});
|
|
it("marks the lane failed with git's message when provisioning fails", async () => {});
|
|
```
|
|
|
|
Poll `GET /api/lanes/:id` until `status !== "provisioning"` with a bounded deadline (2 s, 50 ms interval) — never a bare sleep.
|
|
|
|
- [ ] **Step 2: Run to verify they fail**
|
|
|
|
Run: `node --test server/__tests__/lane-lifecycle.test.js`
|
|
Expected: FAIL — `POST /api/lanes/worktree` 404s.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Registered before `/:id/:action`, behind `sameOriginGuard`. Validate: `sourceRepo` absolute, exists, `isGitRepo`. Compute `slug = slugify(req.body.slug || req.body.title)`, `dir = path.join(LANES_ROOT, `${path.basename(sourceRepo)}__${slug}`)`, suffixing `-2`, `-3`… while the directory exists. Create the lane row `kind: "managed", status: "provisioning"`, respond `202`, then in the background — wrapped in `withLaneLock(lane.id, …)` — resolve the base, `addWorktree`, and `updateLane` to `status: "idle"` (or `"failed"` with `notes` set to `err.git?.stderr || err.message`), broadcasting either way.
|
|
|
|
Provisioning must never leave a half-state: if `addWorktree` throws, the lane row stays with `kind: "managed"` and `status: "failed"` so the user can `remove` it, and no directory is left behind that git does not know about.
|
|
|
|
- [ ] **Step 4: Run to verify they pass** — Expected: PASS, 8 tests total in the file.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add server/routes/lanes.js server/__tests__/lane-lifecycle.test.js
|
|
git commit -m "feat(lanes): provision a git worktree for a managed lane"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: `reset`, `remove`, `purge` actions
|
|
|
|
**Files:**
|
|
- Modify: `server/routes/lanes.js`
|
|
- Test: `server/__tests__/lane-lifecycle.test.js` (append)
|
|
|
|
**Interfaces:**
|
|
- Consumes: everything from Tasks 1-4.
|
|
- Produces: `reset` and `purge` join the `ACTIONS` set; `remove` gains worktree teardown. All three require `{confirm: true}`; `reset` and `remove` additionally require `{force: true}` when preflight reports `unpushed > 0`, and accept `{expect: {head, dirty, untracked, unpushed}}` — a mismatch returns `409 ESTALE`.
|
|
|
|
- [ ] **Step 1: Write the failing tests** (append)
|
|
|
|
```js
|
|
it("reset requires confirm, restores the branch from base and clears lane state", async () => {});
|
|
it("reset refuses with 409 when the worktree has unpushed commits, and proceeds with force", async () => {});
|
|
it("reset returns 409 ESTALE when the head moved since preflight", async () => {});
|
|
it("remove tears down the worktree and the branch, and deletes the lane row", async () => {});
|
|
it("reset and remove refuse an adopted lane with 400 ENOTMANAGED", async () => {});
|
|
it("purge deletes the lane's sessions and reports the counts", async () => {});
|
|
```
|
|
|
|
The adopted-lane refusal is the single most important test in this plan: it is what stands between a mis-click and a user's real project directory.
|
|
|
|
- [ ] **Step 2: Run to verify they fail** — Expected: FAIL, the actions are unknown or non-destructive.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
Inside the existing `/:id/:action` handler, all three branches run within `withLaneLock(lane.id, async () => …)`, and each begins by killing the lane's run and awaiting its exit (poll `runs.getRun(lane.run_id)` until it is no longer `running`/`spawning`, bounded, then clear `run_id`).
|
|
|
|
Map the guard errors to HTTP: `ENOTMANAGED` / `EOUTSIDEROOT` / `ENOTWORKTREE` → `400` with the code intact; `ESTALE` → `409`; `EUNPUSHED` → `409`; git failures → `500` carrying `err.git.stderr`.
|
|
|
|
- [ ] **Step 4: Run to verify they pass** — Expected: PASS, 14 tests in the file.
|
|
|
|
- [ ] **Step 5: Full suite and commit**
|
|
|
|
Run: `npm run test:server`
|
|
|
|
```bash
|
|
git add server/routes/lanes.js server/__tests__/lane-lifecycle.test.js
|
|
git commit -m "feat(lanes): reset, remove and purge with preflight and stale-state guards"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: CLI
|
|
|
|
**Files:**
|
|
- Modify: `bin/ccam.js`
|
|
- Test: `server/__tests__/lanes-cli.test.js` (append)
|
|
|
|
**Interfaces:**
|
|
- Consumes: the routes from Tasks 3-5, via the existing `get` / `post` helpers (`bin/ccam.js:191-192`).
|
|
- Produces: `ccam lanes add --repo <path> [--title <t>] [--base <branch>]` (worktree mode; the existing `--cwd` form still adopts); `ccam lanes reset|remove|purge <id> [--force]`, each printing the preflight table and refusing without `--yes`.
|
|
|
|
- [ ] **Step 1: Write the failing tests** (append) — worktree add via CLI lands a managed lane; `reset` without `--yes` exits non-zero and changes nothing; `--yes` performs it.
|
|
- [ ] **Step 2: Run to verify they fail.**
|
|
- [ ] **Step 3: Implement**, reusing the async `cli()` harness and the existing flag reader. Print the preflight counts as a small aligned table before asking for `--yes`, so the terminal path has the same "confirm against numbers" property as the UI.
|
|
- [ ] **Step 4: Run to verify they pass.**
|
|
- [ ] **Step 5: Commit** — `feat(lanes): ccam lanes add --repo, reset, remove, purge`
|
|
|
|
---
|
|
|
|
## Task 7: UI and docs
|
|
|
|
**Files:**
|
|
- Create: `client/src/components/lanes/DestructiveLaneModal.tsx`
|
|
- Modify: `client/src/components/lanes/LaneCard.tsx`, `client/src/lib/api.ts`, `client/src/lib/types.ts`, `client/src/i18n/locales/*/lanes.json`
|
|
- Modify: `docs/LANES.md`, `CLAUDE.md`
|
|
- Test: `client/src/components/lanes/__tests__/DestructiveLaneModal.test.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `api.lanes.preflight(id, action)` and `api.lanes.action(id, action, body)`.
|
|
- Produces: `<DestructiveLaneModal lane action onClose onConfirm>` — fetches preflight on open, renders the counts, disables the confirm button while loading or when `blocked` contains anything other than `unpushed-commits`, and exposes a "Force" checkbox only for `unpushed-commits`.
|
|
|
|
- [ ] **Step 1: Write the failing test** — the modal renders the counts it was given; the confirm button is disabled for an `adopted` lane; ticking Force enables confirm when the only blocker is unpushed commits; confirming passes back the `expect` block it displayed.
|
|
- [ ] **Step 2: Run to verify it fails.**
|
|
- [ ] **Step 3: Implement**, wrapping the repo's existing `ConfirmModal`. `LaneCard` shows a `managed`/`adopted` badge and renders reset/remove/purge only for `managed` lanes. Every string goes through i18n in all four locales.
|
|
- [ ] **Step 4: Run `npm run test:client` and `npm run build`.** Review the screens snapshot diff before accepting it.
|
|
- [ ] **Step 5: Docs** — `docs/LANES.md` gains a Lifecycle section covering the two kinds, the three safety checks, each verb with what it destroys and what it spares (`clean -fd` keeps gitignored files), the preflight contract, the env vars, and the fresh-worktree-has-no-dependencies limitation. `CLAUDE.md`'s Lanes section gains the rule: **never `rm -rf` a lane; never build a git command as a shell string; adopted lanes are not destroyable.**
|
|
- [ ] **Step 6: Header audit and commit** — `feat(lanes): destructive-action modal with preflight counts, lifecycle docs`
|
|
|
|
---
|
|
|
|
## Out of scope
|
|
|
|
- Dependency bootstrap for a fresh worktree (`node_modules`, `.env`) — Shipyard's profile-hook subsystem. A separate sub-project if wanted.
|
|
- `VACUUM` as part of `purge` — it locks the whole database; if disk reclamation is wanted it becomes its own maintenance action.
|
|
- Per-lane ports, databases, Docker services.
|
|
- Stage auto-detection (sub-project B) and the merged Workspace page (sub-project A) — separate specs.
|