feat: Claude Code Monitor — lanes, pipelines and a merged workspace
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.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
# Worktree-lanes: final-review follow-ups
|
||||
|
||||
Base: bf312c6 (branch `feat/worktree-lanes`). One commit for the whole set.
|
||||
|
||||
## Follow-up 1: unreadable worktrees were genuinely unremovable
|
||||
|
||||
### (a) Made an unreadable managed lane genuinely removable
|
||||
|
||||
`server/lib/worktree.js`: `removeWorktree` unconditionally called
|
||||
`git worktree remove --force`, which git refuses outright — even with a
|
||||
second `--force` — when the worktree's OWN `.git` pointer fails its own
|
||||
validation (a corrupt/garbage `.git` file). All three `assertDestroyable`
|
||||
checks pass for this shape (the directory exists, resolves inside
|
||||
`LANES_ROOT`, and is still listed by the source repo), so the lane was stuck:
|
||||
`removeWorktree` threw, the route 500'd, the lane row survived.
|
||||
|
||||
Fix: wrapped the `git worktree remove --force` call in a try/catch. On
|
||||
failure, `findWorktreeAdminDir(sourceRepo, cwd)` locates the worktree's
|
||||
administrative directory under the source repo's common dir
|
||||
(`<common>/worktrees/<name>`) by reading each entry's `gitdir` file — that
|
||||
file's content is the absolute path to the worktree's own `.git` file, read
|
||||
from the *source repo's* side, so it still resolves correctly even though the
|
||||
worktree's own `.git` file is corrupt. If found, `fs.rmSync` deletes only
|
||||
that administrative directory (never the worktree directory itself), which
|
||||
deregisters the worktree from `git worktree list`. Branch delete and lane-row
|
||||
deletion then proceed exactly as for every other remove. If no matching
|
||||
admin directory is found (paranoid case — should not happen for a lane that
|
||||
passed all three checks), the original git error is rethrown rather than
|
||||
silently continuing, so the failure is never swallowed.
|
||||
|
||||
This is a genuine deregistration, not a workaround: it never touches the
|
||||
worktree's own files, and after it runs, `git worktree remove --force` on
|
||||
the same path correctly reports `'...' is not a working tree` — proof the
|
||||
repo no longer thinks it manages that directory.
|
||||
|
||||
### Experiment (raw output)
|
||||
|
||||
Built a real corrupt worktree under a throwaway repo and ran the exact
|
||||
sequence a reviewer had already reproduced, then the new fallback:
|
||||
|
||||
```
|
||||
=== remove --force (expect fail) ===
|
||||
fatal: validation failed, cannot remove working tree: '/tmp/tmp.4kZLeHvThU/lanes/src__corrupt/.git' is not a .git file, error code 5
|
||||
exit=128
|
||||
|
||||
=== manually delete admin dir ===
|
||||
(no output)
|
||||
|
||||
=== worktree list after manual admin removal ===
|
||||
worktree /tmp/tmp.4kZLeHvThU/src
|
||||
HEAD 0a1ed2bb01a8ac88b758180d569997b8c0bb22e8
|
||||
branch refs/heads/main
|
||||
|
||||
=== branch still exists? ===
|
||||
feat/corrupt
|
||||
|
||||
=== worktree directory + corrupted .git file still present? ===
|
||||
total 16
|
||||
drwxrwxr-x 2 smartgiftailab smartgiftailab 4096 ... .
|
||||
drwxrwxr-x 3 smartgiftailab smartgiftailab 4096 ... ..
|
||||
-rw-rw-r-- 1 smartgiftailab smartgiftailab 8 ... .git
|
||||
-rw-rw-r-- 1 smartgiftailab smartgiftailab 6 ... README.md
|
||||
garbage
|
||||
|
||||
=== git worktree remove again now (expect: not a working tree, confirms deregistered) ===
|
||||
fatal: '/tmp/tmp.4kZLeHvThU/lanes/src__corrupt' is not a working tree
|
||||
exit=128
|
||||
```
|
||||
|
||||
This confirms: (1) `remove --force` genuinely refuses a corrupt worktree,
|
||||
matching the earlier report exactly (error code 5 here vs 7 in the original
|
||||
report — git version difference, same refusal); (2) deleting only
|
||||
`.git/worktrees/<name>` deregisters the worktree from `git worktree list`
|
||||
without touching the worktree directory or its files; (3) after
|
||||
deregistration git itself confirms the path is no longer a working tree.
|
||||
|
||||
The actual code path (`removeWorktree` against a real corrupt worktree
|
||||
inside `LANES_ROOT`, through `findWorktreeAdminDir`) was then exercised by
|
||||
the new server test below and produced the same result end to end.
|
||||
|
||||
### (b) Reworded the false promise
|
||||
|
||||
`destructive.notice.unreadable` in `client/src/i18n/locales/{en,zh,vi,ko}/lanes.json`
|
||||
no longer promises forced success. New English text: "The lane directory
|
||||
cannot be read as a Git worktree. Removal is attempted; if Git itself
|
||||
refuses, its worktree record is cleared directly instead. Either way, the
|
||||
directory itself is never touched." zh/vi/ko were translated with the same
|
||||
meaning (not machine-transliterated word-for-word), matching each locale's
|
||||
existing terminology for "lane"/"worktree"/"record" already used elsewhere
|
||||
in the same file.
|
||||
|
||||
`docs/API.md` (blocked/remove paragraph) and `docs/LANES.md` (the three
|
||||
safety checks section) both had the same "force-removes an unreadable one"
|
||||
claim; both now describe the attempt-then-fallback behavior and name the
|
||||
corrupt-`.git`-pointer case explicitly.
|
||||
|
||||
### (c) Added the two missing modal tests
|
||||
|
||||
`client/src/components/lanes/__tests__/DestructiveLaneModal.test.tsx`:
|
||||
- `disables RESET when the worktree directory is unreadable` — pins
|
||||
`unreadable` staying in `HARD_BLOCKERS.reset`.
|
||||
- `ENABLES remove when the worktree directory is unreadable` — pins
|
||||
`unreadable` staying absent from `HARD_BLOCKERS.remove`, and that the
|
||||
reworded notice text renders and the modal still echoes back the exact
|
||||
`expect` block on confirm.
|
||||
|
||||
### Regression test for (a)
|
||||
|
||||
`server/__tests__/worktree.test.js`: new test builds a real worktree, writes
|
||||
garbage into its `.git` file (reproducing the exact corruption), asserts
|
||||
`git status` inside it fails (sanity), calls `removeWorktree` directly, then
|
||||
asserts: the source repo no longer lists it, its branch is gone, **and** the
|
||||
directory plus a file written into it still exist afterward untouched.
|
||||
|
||||
## Follow-up 2: `start` was atomic by accident, not by invariant
|
||||
|
||||
`server/routes/lanes.js`: the `start` case read `lane.run_id` and later wrote
|
||||
a new one with no `await` in between — atomic today only because nothing
|
||||
yields the event loop in that stretch. Wrapped the whole check-then-spawn in
|
||||
`withLaneLock(lane.id, async () => {...})`, re-fetching the lane inside the
|
||||
lock (a concurrent `remove` could have deleted the row while queued, so a
|
||||
`missing` case now returns 404 `ENOLANE` instead of dereferencing a null
|
||||
lane — a real edge case introduced by the lock itself, not present before).
|
||||
The `409 ERUNLIVE` code and message are unchanged.
|
||||
|
||||
Regression test (`server/__tests__/lane-lifecycle.test.js`,
|
||||
`"start is serialized behind the per-lane lock..."`): holds the same lane's
|
||||
lock directly from the test (`withLaneLock(lane.id, () => new Promise(...))`),
|
||||
fires `POST .../start` while that lock is held, confirms the request has NOT
|
||||
settled 50ms later and that no run_id was written, then releases the held
|
||||
lock and confirms `start` only proceeds (spawns, returns 200) after release.
|
||||
This proves the route genuinely shares the per-lane lock rather than proving
|
||||
only that the current zero-await code happens to be atomic.
|
||||
|
||||
## Follow-up 3: openapi.yaml drift check in CI
|
||||
|
||||
`.github/workflows/ci.yml`: added a step to the existing "🧹 Check Formatting"
|
||||
job — `npm run openapi:yaml` (regenerate) followed by
|
||||
`git diff --exit-code openapi.yaml`. No new job, no git hook (the pre-commit
|
||||
hook is already slow, per instruction). Verified locally: regenerating
|
||||
produces zero diff against the currently committed file.
|
||||
|
||||
## Follow-up 4: missing `sameOriginGuard` on two mutating routes
|
||||
|
||||
`server/routes/lanes.js`: added `sameOriginGuard` to `POST /api/lanes/`
|
||||
(lane creation) and `POST /api/lanes/:id/stage` (stage reporting) —
|
||||
previously the only two mutating lane routes without it.
|
||||
|
||||
Checked both callers before changing anything:
|
||||
- `bin/ccam.js`'s `post()` helper (backing `ccam lanes add` and `ccam stage`)
|
||||
only ever sends a `Content-Type` header, never `Origin` or `Referer` — the
|
||||
guard passes any request with no Origin header through unconditionally
|
||||
(same rule already relied on by every other guarded lane route). Verified
|
||||
end to end by re-running `server/__tests__/lanes-cli.test.js` (13/13) after
|
||||
the change — both `ccam lanes add --repo` and the destructive-lifecycle
|
||||
CLI flows (which call `stage` indirectly via `clear`) still pass.
|
||||
- `grep -rn "api/lanes" mcp/ scripts/` — no hits. Nothing else calls these
|
||||
routes.
|
||||
|
||||
No legitimate caller was broken; no need to weaken the guard or stop and
|
||||
ask.
|
||||
|
||||
Regression tests added to `server/__tests__/lanes-api.test.js`:
|
||||
`"rejects cross-origin lane creation"` and `"rejects cross-origin stage
|
||||
reporting"`, mirroring the existing cross-origin PATCH/DELETE tests exactly
|
||||
(assert `403 EBADORIGIN`, and for stage, that the lane's stage was not
|
||||
changed).
|
||||
|
||||
## Commands run, with tallies
|
||||
|
||||
- `node --test server/__tests__/worktree.test.js` → 19/19 (was 18)
|
||||
- `node --test server/__tests__/lane-lifecycle.test.js` → 29/29 (was 28)
|
||||
- `node --test server/__tests__/lanes-api.test.js` → 22/22 (was 20)
|
||||
- `node --test server/__tests__/lanes-cli.test.js` → 13/13 (unchanged, re-run
|
||||
as a caller check for follow-up 4)
|
||||
- `npm run test:server` → **861/861** (baseline 857)
|
||||
- `cd client && npx vitest run src/components/lanes/__tests__/DestructiveLaneModal.test.tsx`
|
||||
→ 16/16 (was 14)
|
||||
- `npm run test:client` → **299/299** (baseline 297)
|
||||
- `npm run build` → succeeded (client build, `tsc -b && vite build`)
|
||||
- `bash .claude/skills/file-headers/scripts/check-headers.sh` → exit 0
|
||||
- `node scripts/generate-openapi-yaml.js && git diff --exit-code openapi.yaml`
|
||||
→ exit 0, no diff
|
||||
- `npm run format:check` → all files pass
|
||||
|
||||
## Left out / deferred, with reasons
|
||||
|
||||
- Did not touch the "Each verb → remove" bullet in `docs/LANES.md` (a
|
||||
different paragraph from the one named in the brief) — it only describes
|
||||
the ordinary managed-removal steps and makes no claim about the
|
||||
corrupt-`.git` case, so it was not inaccurate and editing it would have
|
||||
been unrequested scope.
|
||||
- Did not add a CLI-level test reproducing the corrupt-`.git` removal
|
||||
through `ccam lanes remove` — the brief asked for "a test proving (a)"
|
||||
which the new `worktree.test.js` case already does directly against
|
||||
`removeWorktree` (the same function the route and CLI both call); adding a
|
||||
second, slower end-to-end CLI version would duplicate coverage without
|
||||
proving anything new.
|
||||
- Did not add a real concurrent-`claude`-process test for follow-up 2 (two
|
||||
genuine `POST /start` calls racing against real spawned processes) — the
|
||||
codebase's own existing tests avoid ever spawning `claude` twice
|
||||
concurrently for exactly this reason (slow, and the real `claude` binary's
|
||||
behavior isn't what's under test). The lock-holding test proves the same
|
||||
invariant deterministically without that cost.
|
||||
Reference in New Issue
Block a user