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,240 @@
|
||||
# Remove Native SQLite Dependency — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace `better-sqlite3` (native C++ module requiring Python/build tools) with a compatibility layer over Node.js built-in `node:sqlite`, so `npm install` succeeds on any machine without native compilation tools.
|
||||
|
||||
**Architecture:** Create `server/compat-sqlite.js` — a thin wrapper that gives `DatabaseSync` (from `node:sqlite`) the same API as `better-sqlite3`. Move `better-sqlite3` to `optionalDependencies` so it's preferred when prebuilds are available but doesn't block install. The `server/db.js` loader tries `better-sqlite3` first, falls back to the compat wrapper. Update minimum Node version to 22.
|
||||
|
||||
**Tech Stack:** Node.js `node:sqlite` (DatabaseSync), existing Express/WS server
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|---------------|
|
||||
| `server/compat-sqlite.js` | **Create** | Wrapper class: DatabaseSync → better-sqlite3 API |
|
||||
| `server/db.js` | **Modify** (line 1) | Try better-sqlite3, fallback to compat wrapper |
|
||||
| `scripts/clear-data.js` | **Modify** (line 8) | Same fallback import |
|
||||
| `package.json` | **Modify** | Move better-sqlite3 to optionalDependencies, bump engines to >=22 |
|
||||
| `server/__tests__/api.test.js` | **Modify** (lines 952-959) | Fix `db.pragma()` calls to work with both backends |
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Core Implementation
|
||||
|
||||
### Task 1: Create `server/compat-sqlite.js`
|
||||
|
||||
**Files:**
|
||||
- Create: `server/compat-sqlite.js`
|
||||
|
||||
- [ ] **Step 1: Write the compat wrapper**
|
||||
|
||||
The wrapper must bridge these API differences:
|
||||
|
||||
| better-sqlite3 | node:sqlite (DatabaseSync) |
|
||||
|----------------|---------------------------|
|
||||
| `new Database(path)` | `new DatabaseSync(path)` |
|
||||
| `db.pragma("key = value")` | `db.exec("PRAGMA key = value")` |
|
||||
| `db.pragma("key")` → value | `db.prepare("PRAGMA key").get()` → `{key: value}` |
|
||||
| `db.pragma("key", { simple: true })` → value | same as above, extract single value |
|
||||
| `db.transaction(fn)` → wrapper fn | manual `BEGIN`/`COMMIT`/`ROLLBACK` |
|
||||
| `db.prepare(sql)` → stmt with `.run()`, `.get()`, `.all()` | identical API |
|
||||
| `db.exec(sql)` | identical |
|
||||
| `db.close()` | identical |
|
||||
|
||||
```js
|
||||
// server/compat-sqlite.js
|
||||
const { DatabaseSync } = require("node:sqlite");
|
||||
|
||||
class Database {
|
||||
constructor(filePath) {
|
||||
this._db = new DatabaseSync(filePath);
|
||||
}
|
||||
|
||||
exec(sql) {
|
||||
this._db.exec(sql);
|
||||
return this;
|
||||
}
|
||||
|
||||
pragma(str, options) {
|
||||
if (str.includes("=")) {
|
||||
this._db.exec(`PRAGMA ${str}`);
|
||||
return undefined;
|
||||
}
|
||||
const row = this._db.prepare(`PRAGMA ${str}`).get();
|
||||
if (!row) return undefined;
|
||||
const keys = Object.keys(row);
|
||||
if (options?.simple || keys.length === 1) return row[keys[0]];
|
||||
return row;
|
||||
}
|
||||
|
||||
prepare(sql) {
|
||||
return this._db.prepare(sql);
|
||||
}
|
||||
|
||||
transaction(fn) {
|
||||
const db = this._db;
|
||||
const wrapper = (...args) => {
|
||||
db.exec("BEGIN");
|
||||
try {
|
||||
const result = fn(...args);
|
||||
db.exec("COMMIT");
|
||||
return result;
|
||||
} catch (err) {
|
||||
db.exec("ROLLBACK");
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
close() {
|
||||
this._db.close();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Database;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the wrapper works standalone**
|
||||
|
||||
Run: `node -e "const DB = require('./server/compat-sqlite'); const db = new DB(':memory:'); db.pragma('journal_mode = WAL'); db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)'); const s = db.prepare('INSERT INTO t (v) VALUES (?)'); console.log(s.run('hi')); console.log(db.prepare('SELECT * FROM t').all()); const tx = db.transaction((items) => { for (const i of items) s.run(i); }); tx(['a','b','c']); console.log(db.prepare('SELECT COUNT(*) as c FROM t').get()); db.close(); console.log('OK')"`
|
||||
|
||||
Expected: `OK` printed at the end with correct query results.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/compat-sqlite.js
|
||||
git commit -m "feat: add node:sqlite compat wrapper for better-sqlite3 API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Update `server/db.js` to use fallback import
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/db.js:1`
|
||||
|
||||
- [ ] **Step 1: Replace the import**
|
||||
|
||||
Change line 1 from:
|
||||
```js
|
||||
const Database = require("better-sqlite3");
|
||||
```
|
||||
To:
|
||||
```js
|
||||
let Database;
|
||||
try {
|
||||
Database = require("better-sqlite3");
|
||||
} catch {
|
||||
Database = require("./compat-sqlite");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify server starts**
|
||||
|
||||
Run: `node -e "process.env.DASHBOARD_DB_PATH = require('path').join(require('os').tmpdir(), 'test-fallback-' + Date.now() + '.db'); const { db, stmts } = require('./server/db'); console.log('stmts keys:', Object.keys(stmts).length); stmts.insertSession.run('test-1', 'Test', 'active', null, null, null); console.log(stmts.getSession.get('test-1')); db.close(); console.log('OK')"`
|
||||
|
||||
Expected: Prints statement count (39), session row, and `OK`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/db.js
|
||||
git commit -m "feat: fallback to node:sqlite when better-sqlite3 unavailable"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Update `scripts/clear-data.js` to use fallback import
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/clear-data.js:8`
|
||||
|
||||
- [ ] **Step 1: Replace the import**
|
||||
|
||||
Change line 8 from:
|
||||
```js
|
||||
const Database = require("better-sqlite3");
|
||||
```
|
||||
To:
|
||||
```js
|
||||
let Database;
|
||||
try {
|
||||
Database = require("better-sqlite3");
|
||||
} catch {
|
||||
Database = require("../server/compat-sqlite");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/clear-data.js
|
||||
git commit -m "fix: use fallback sqlite import in clear-data script"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update `package.json`
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
|
||||
- [ ] **Step 1: Move better-sqlite3 to optionalDependencies, bump engines**
|
||||
|
||||
Move `"better-sqlite3": "^11.7.0"` from `dependencies` to `optionalDependencies`.
|
||||
Change engines from `"node": ">=18.0.0"` to `"node": ">=22.0.0"`.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json
|
||||
git commit -m "chore: make better-sqlite3 optional, require Node >= 22"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Fix test pragma calls
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/__tests__/api.test.js:952-959`
|
||||
|
||||
- [ ] **Step 1: Fix pragma calls in Database Integrity tests**
|
||||
|
||||
The tests call `db.pragma("journal_mode", { simple: true })` and `db.pragma("foreign_keys", { simple: true })`. The compat wrapper supports `{ simple: true }`, so these should work as-is. However, WAL mode isn't available for in-memory databases (returns "memory"). The test creates a file-based DB via `TEST_DB`, so WAL should work.
|
||||
|
||||
No change needed — verify by running tests.
|
||||
|
||||
- [ ] **Step 2: Run full test suite**
|
||||
|
||||
Run: `node --test server/__tests__/api.test.js`
|
||||
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 3: Run setup to verify npm install succeeds without Python**
|
||||
|
||||
Run: `npm run setup`
|
||||
|
||||
Expected: Install succeeds (better-sqlite3 may warn but won't fail since it's optional).
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `SETUP.md` (if it mentions better-sqlite3 or Python requirements)
|
||||
|
||||
- [ ] **Step 1: Check and update SETUP.md**
|
||||
|
||||
Remove any mentions of Python or build tools as requirements. Note that Node >= 22 is required.
|
||||
|
||||
- [ ] **Step 2: Commit all remaining changes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "docs: update setup requirements for native-free SQLite"
|
||||
```
|
||||
@@ -0,0 +1,875 @@
|
||||
# JSONL Reading Performance Optimization
|
||||
|
||||
> **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:** Eliminate redundant full-file reads of JSONL transcript files by caching extracted token data and using incremental reads.
|
||||
|
||||
**Architecture:** Add a lightweight in-memory cache keyed by `(transcriptPath, mtime, size)` that stores the extracted `{tokensByModel, compaction}` result. On each hook event, stat the file first — if unchanged, return cached result. For files that did change, use byte-offset tracking to only read new lines appended since last parse. The periodic compaction scanner shares this same cache.
|
||||
|
||||
**Tech Stack:** Node.js `fs.statSync`, in-memory `Map` cache, byte-offset tracking via `fs.openSync`/`fs.readSync`.
|
||||
|
||||
---
|
||||
|
||||
## Performance Problem Analysis
|
||||
|
||||
### Current Behavior
|
||||
|
||||
Three code paths read JSONL files **fully, synchronously, with zero caching**:
|
||||
|
||||
| Path | File | Trigger | Frequency |
|
||||
|------|------|---------|-----------|
|
||||
| `extractTokensFromTranscript()` | `server/routes/hooks.js:15-62` | Every POST `/api/hooks/event` with `transcript_path` | 1-10x/min per active session |
|
||||
| `findCompactionsInFile()` | `scripts/import-history.js:658-674` | 2-minute periodic scan | Every 2 min × active sessions |
|
||||
| `parseSessionFile()` | `scripts/import-history.js:22-131` | Server startup import | Once per JSONL file at startup |
|
||||
|
||||
### Why This Hurts
|
||||
|
||||
1. **`extractTokensFromTranscript` is the hot path.** Called on *every* hook event. For a session producing 5 events/min with a 10K-line JSONL (typical long session), that's 5 full file reads + 50K `JSON.parse` calls per minute.
|
||||
|
||||
2. **JSONL files are append-only** (until compaction rewrites them). Between hook events, only a few new lines are appended. Reading the entire file to re-sum tokens that haven't changed is pure waste.
|
||||
|
||||
3. **`readFileSync` blocks the event loop.** Long sessions (50K+ lines, several MB) block the Express request handler for tens of milliseconds, stalling concurrent hook ingestion and API responses.
|
||||
|
||||
4. **Periodic scanner duplicates work.** `findCompactionsInFile` re-reads the same files that `extractTokensFromTranscript` already parsed seconds ago.
|
||||
|
||||
### Quantified Impact (estimated)
|
||||
|
||||
| Session Length | Lines | File Size | Parse Time (sync) | Events/min | Wasted CPU/min |
|
||||
|---------------|-------|-----------|--------------------|------------|----------------|
|
||||
| Short (30min) | 500 | ~100KB | ~2ms | 3 | ~6ms |
|
||||
| Medium (2hr) | 5,000 | ~1MB | ~15ms | 5 | ~75ms |
|
||||
| Long (8hr+) | 20,000 | ~4MB | ~50ms | 8 | ~400ms |
|
||||
| Marathon (24hr) | 50,000+ | ~10MB+ | ~120ms+ | 10 | ~1.2s |
|
||||
|
||||
With multiple concurrent sessions, this compounds. The 2-minute scanner adds another full read per active session on top.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Action |
|
||||
|------|---------------|--------|
|
||||
| `server/lib/transcript-cache.js` | In-memory cache + incremental reader for JSONL files | **Create** |
|
||||
| `server/lib/__tests__/transcript-cache.test.js` | Unit tests for cache + incremental read logic | **Create** |
|
||||
| `server/routes/hooks.js` | Hook event handler — swap `extractTokensFromTranscript` to use cache | **Modify** (lines 15-62, 353-354) |
|
||||
| `scripts/import-history.js` | Periodic compaction scanner — swap `findCompactionsInFile` to use cache | **Modify** (lines 658-674) |
|
||||
| `server/index.js` | Wire cache into periodic scanner; add cache stats to settings | **Modify** (lines 104-128) |
|
||||
| `server/routes/settings.js` | Expose cache stats in `/api/settings/info` | **Modify** |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create the Transcript Cache Module
|
||||
|
||||
**Files:**
|
||||
- Create: `server/lib/transcript-cache.js`
|
||||
- Test: `server/lib/__tests__/transcript-cache.test.js`
|
||||
|
||||
### Design
|
||||
|
||||
```
|
||||
Cache entry = {
|
||||
mtime: number, // file modification time (ms)
|
||||
size: number, // file size in bytes
|
||||
bytesRead: number, // how far we've read into the file
|
||||
tokensByModel: {}, // accumulated token sums
|
||||
compaction: null|{}, // compaction entries found so far
|
||||
}
|
||||
|
||||
On read request:
|
||||
1. fs.statSync(path) → get mtime + size
|
||||
2. Cache hit? (same mtime + size) → return cached result
|
||||
3. File shrunk or mtime changed with smaller size? → compaction rewrite → full re-read, reset cache
|
||||
4. File grew? (size > bytesRead) → incremental read from bytesRead → parse new lines → merge into cached totals
|
||||
5. Store updated entry, return result
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Create test file with first test — cache miss triggers full read**
|
||||
|
||||
```javascript
|
||||
// server/lib/__tests__/transcript-cache.test.js
|
||||
const { describe, it, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
let tmpDir;
|
||||
let TranscriptCache;
|
||||
|
||||
function writeJsonl(filePath, entries) {
|
||||
fs.writeFileSync(filePath, entries.map((e) => JSON.stringify(e)).join("\n") + "\n");
|
||||
}
|
||||
|
||||
describe("TranscriptCache", () => {
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "tc-test-"));
|
||||
// Fresh require to reset module-level state
|
||||
delete require.cache[require.resolve("../../lib/transcript-cache")];
|
||||
TranscriptCache = require("../../lib/transcript-cache");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should extract tokens on first read (cache miss)", () => {
|
||||
const file = path.join(tmpDir, "session.jsonl");
|
||||
writeJsonl(file, [
|
||||
{ message: { model: "claude-sonnet-4-20250514", usage: { input_tokens: 100, output_tokens: 50 } } },
|
||||
{ message: { model: "claude-sonnet-4-20250514", usage: { input_tokens: 200, output_tokens: 75 } } },
|
||||
]);
|
||||
|
||||
const cache = new TranscriptCache();
|
||||
const result = cache.extract(file);
|
||||
|
||||
assert.deepStrictEqual(result.tokensByModel, {
|
||||
"claude-sonnet-4-20250514": { input: 300, output: 125, cacheRead: 0, cacheWrite: 0 },
|
||||
});
|
||||
assert.strictEqual(result.compaction, null);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test server/lib/__tests__/transcript-cache.test.js`
|
||||
Expected: FAIL — module not found
|
||||
|
||||
- [ ] **Step 3: Implement TranscriptCache with full-read path**
|
||||
|
||||
```javascript
|
||||
// server/lib/transcript-cache.js
|
||||
const fs = require("fs");
|
||||
|
||||
class TranscriptCache {
|
||||
constructor() {
|
||||
this._cache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token usage and compaction data from a JSONL transcript file.
|
||||
* Uses stat-based caching — returns cached result if file hasn't changed.
|
||||
* Returns null if file doesn't exist or has no data.
|
||||
*/
|
||||
extract(transcriptPath) {
|
||||
if (!transcriptPath) return null;
|
||||
try {
|
||||
const stat = fs.statSync(transcriptPath);
|
||||
const key = transcriptPath;
|
||||
const cached = this._cache.get(key);
|
||||
|
||||
// Cache hit: file unchanged
|
||||
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
// Full read (cache miss or file was rewritten/compacted)
|
||||
const result = this._fullRead(transcriptPath);
|
||||
this._cache.set(key, {
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
bytesRead: stat.size,
|
||||
tokensByModel: result ? { ...result.tokensByModel } : null,
|
||||
compaction: result ? result.compaction : null,
|
||||
result,
|
||||
});
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_fullRead(filePath) {
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
return this._parseContent(content);
|
||||
}
|
||||
|
||||
_parseContent(content) {
|
||||
const tokensByModel = {};
|
||||
let compaction = null;
|
||||
for (const line of content.split("\n")) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.isCompactSummary) {
|
||||
if (!compaction) compaction = { count: 0, entries: [] };
|
||||
compaction.count++;
|
||||
compaction.entries.push({
|
||||
uuid: entry.uuid || null,
|
||||
timestamp: entry.timestamp || null,
|
||||
});
|
||||
}
|
||||
const msg = entry.message || entry;
|
||||
const model = msg.model;
|
||||
if (!model || model === "<synthetic>" || !msg.usage) continue;
|
||||
if (!tokensByModel[model]) {
|
||||
tokensByModel[model] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
}
|
||||
tokensByModel[model].input += msg.usage.input_tokens || 0;
|
||||
tokensByModel[model].output += msg.usage.output_tokens || 0;
|
||||
tokensByModel[model].cacheRead += msg.usage.cache_read_input_tokens || 0;
|
||||
tokensByModel[model].cacheWrite += msg.usage.cache_creation_input_tokens || 0;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const hasTokens = Object.keys(tokensByModel).length > 0;
|
||||
if (!hasTokens && !compaction) return null;
|
||||
return { tokensByModel: hasTokens ? tokensByModel : null, compaction };
|
||||
}
|
||||
|
||||
/** Number of entries currently cached */
|
||||
get size() {
|
||||
return this._cache.size;
|
||||
}
|
||||
|
||||
/** Remove a specific path from cache (e.g. when session ends) */
|
||||
invalidate(transcriptPath) {
|
||||
this._cache.delete(transcriptPath);
|
||||
}
|
||||
|
||||
/** Clear all cached entries */
|
||||
clear() {
|
||||
this._cache.clear();
|
||||
}
|
||||
|
||||
/** Return cache stats for diagnostics */
|
||||
stats() {
|
||||
return {
|
||||
entries: this._cache.size,
|
||||
paths: [...this._cache.keys()],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TranscriptCache;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test server/lib/__tests__/transcript-cache.test.js`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/lib/transcript-cache.js server/lib/__tests__/transcript-cache.test.js
|
||||
git commit -m "feat: add TranscriptCache module with stat-based caching for JSONL reads"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add Cache Hit and Compaction Detection Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/lib/__tests__/transcript-cache.test.js`
|
||||
|
||||
- [ ] **Step 1: Add test — second read with unchanged file returns cached result**
|
||||
|
||||
```javascript
|
||||
it("should return cached result when file is unchanged", () => {
|
||||
const file = path.join(tmpDir, "session.jsonl");
|
||||
writeJsonl(file, [
|
||||
{ message: { model: "claude-sonnet-4-20250514", usage: { input_tokens: 100, output_tokens: 50 } } },
|
||||
]);
|
||||
|
||||
const cache = new TranscriptCache();
|
||||
const r1 = cache.extract(file);
|
||||
const r2 = cache.extract(file);
|
||||
|
||||
assert.deepStrictEqual(r1, r2);
|
||||
// Same object reference proves cache hit (no re-parse)
|
||||
assert.strictEqual(r1, r2);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add test — detects appended lines after file grows**
|
||||
|
||||
```javascript
|
||||
it("should detect new data when file grows", (t) => {
|
||||
const file = path.join(tmpDir, "session.jsonl");
|
||||
writeJsonl(file, [
|
||||
{ message: { model: "claude-sonnet-4-20250514", usage: { input_tokens: 100, output_tokens: 50 } } },
|
||||
]);
|
||||
|
||||
const cache = new TranscriptCache();
|
||||
const r1 = cache.extract(file);
|
||||
assert.strictEqual(r1.tokensByModel["claude-sonnet-4-20250514"].input, 100);
|
||||
|
||||
// Append more data (simulates Claude writing to transcript)
|
||||
fs.appendFileSync(
|
||||
file,
|
||||
JSON.stringify({ message: { model: "claude-sonnet-4-20250514", usage: { input_tokens: 200, output_tokens: 75 } } }) + "\n"
|
||||
);
|
||||
|
||||
const r2 = cache.extract(file);
|
||||
assert.strictEqual(r2.tokensByModel["claude-sonnet-4-20250514"].input, 300);
|
||||
assert.strictEqual(r2.tokensByModel["claude-sonnet-4-20250514"].output, 125);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add test — detects compaction (file shrinks)**
|
||||
|
||||
```javascript
|
||||
it("should do full re-read when file shrinks (compaction rewrite)", () => {
|
||||
const file = path.join(tmpDir, "session.jsonl");
|
||||
writeJsonl(file, [
|
||||
{ message: { model: "claude-sonnet-4-20250514", usage: { input_tokens: 500, output_tokens: 200 } } },
|
||||
{ message: { model: "claude-sonnet-4-20250514", usage: { input_tokens: 300, output_tokens: 100 } } },
|
||||
]);
|
||||
|
||||
const cache = new TranscriptCache();
|
||||
cache.extract(file);
|
||||
|
||||
// Simulate compaction — file is rewritten with fewer entries + summary
|
||||
writeJsonl(file, [
|
||||
{ isCompactSummary: true, uuid: "abc-123", timestamp: "2026-03-20T10:00:00Z" },
|
||||
{ message: { model: "claude-sonnet-4-20250514", usage: { input_tokens: 50, output_tokens: 20 } } },
|
||||
]);
|
||||
|
||||
const r2 = cache.extract(file);
|
||||
assert.strictEqual(r2.tokensByModel["claude-sonnet-4-20250514"].input, 50);
|
||||
assert.strictEqual(r2.compaction.count, 1);
|
||||
assert.strictEqual(r2.compaction.entries[0].uuid, "abc-123");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add test — returns null for missing file**
|
||||
|
||||
```javascript
|
||||
it("should return null for non-existent file", () => {
|
||||
const cache = new TranscriptCache();
|
||||
assert.strictEqual(cache.extract("/nonexistent/file.jsonl"), null);
|
||||
assert.strictEqual(cache.extract(null), null);
|
||||
assert.strictEqual(cache.extract(""), null);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add test — compaction-only extraction (for findCompactionsInFile replacement)**
|
||||
|
||||
```javascript
|
||||
it("should expose compaction entries via extractCompactions()", () => {
|
||||
const file = path.join(tmpDir, "session.jsonl");
|
||||
writeJsonl(file, [
|
||||
{ message: { model: "claude-sonnet-4-20250514", usage: { input_tokens: 100, output_tokens: 50 } } },
|
||||
{ isCompactSummary: true, uuid: "c1", timestamp: "2026-03-20T09:00:00Z" },
|
||||
{ message: { model: "claude-sonnet-4-20250514", usage: { input_tokens: 50, output_tokens: 20 } } },
|
||||
{ isCompactSummary: true, uuid: "c2", timestamp: "2026-03-20T10:00:00Z" },
|
||||
]);
|
||||
|
||||
const cache = new TranscriptCache();
|
||||
const compactions = cache.extractCompactions(file);
|
||||
|
||||
assert.strictEqual(compactions.length, 2);
|
||||
assert.strictEqual(compactions[0].uuid, "c1");
|
||||
assert.strictEqual(compactions[1].uuid, "c2");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run all tests**
|
||||
|
||||
Run: `node --test server/lib/__tests__/transcript-cache.test.js`
|
||||
Expected: All pass
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add server/lib/__tests__/transcript-cache.test.js
|
||||
git commit -m "test: add cache hit, compaction, and edge case tests for TranscriptCache"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Add Incremental Read (Byte-Offset Tracking)
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/lib/transcript-cache.js`
|
||||
- Modify: `server/lib/__tests__/transcript-cache.test.js`
|
||||
|
||||
This is the key optimization. JSONL files are append-only between compactions. Instead of re-reading the full file, read only the bytes appended since our last read.
|
||||
|
||||
- [ ] **Step 1: Add test — incremental read only parses new bytes**
|
||||
|
||||
```javascript
|
||||
it("should only read new bytes on incremental update (not full file)", () => {
|
||||
const file = path.join(tmpDir, "session.jsonl");
|
||||
const line1 = JSON.stringify({ message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } } }) + "\n";
|
||||
fs.writeFileSync(file, line1);
|
||||
|
||||
const cache = new TranscriptCache();
|
||||
cache.extract(file);
|
||||
|
||||
// Append a second line
|
||||
const line2 = JSON.stringify({ message: { model: "m1", usage: { input_tokens: 200, output_tokens: 75 } } }) + "\n";
|
||||
fs.appendFileSync(file, line2);
|
||||
|
||||
// Spy: check bytesRead advanced by only line2 length
|
||||
const r2 = cache.extract(file);
|
||||
assert.strictEqual(r2.tokensByModel["m1"].input, 300);
|
||||
|
||||
const entry = cache._cache.get(file);
|
||||
assert.strictEqual(entry.bytesRead, Buffer.byteLength(line1 + line2, "utf8"));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `extract()` to use incremental read path**
|
||||
|
||||
In `server/lib/transcript-cache.js`, update the `extract` method:
|
||||
|
||||
```javascript
|
||||
extract(transcriptPath) {
|
||||
if (!transcriptPath) return null;
|
||||
try {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(transcriptPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const key = transcriptPath;
|
||||
const cached = this._cache.get(key);
|
||||
|
||||
// Cache hit: file unchanged
|
||||
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
// File shrunk or was rewritten (compaction) → full re-read
|
||||
if (!cached || stat.size < cached.bytesRead) {
|
||||
const result = this._fullRead(transcriptPath);
|
||||
this._cache.set(key, {
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
bytesRead: stat.size,
|
||||
tokensByModel: result ? this._cloneTokens(result.tokensByModel) : null,
|
||||
compaction: result ? this._cloneCompaction(result.compaction) : null,
|
||||
result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// File grew → incremental read from last position
|
||||
const newBytes = this._readFrom(transcriptPath, cached.bytesRead, stat.size);
|
||||
if (newBytes) {
|
||||
const incremental = this._parseContent(newBytes);
|
||||
const merged = this._merge(cached, incremental);
|
||||
const result = {
|
||||
tokensByModel: Object.keys(merged.tokensByModel).length > 0 ? merged.tokensByModel : null,
|
||||
compaction: merged.compaction,
|
||||
};
|
||||
if (!result.tokensByModel && !result.compaction) {
|
||||
this._cache.set(key, { ...cached, mtimeMs: stat.mtimeMs, size: stat.size, bytesRead: stat.size, result: null });
|
||||
return null;
|
||||
}
|
||||
this._cache.set(key, {
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
bytesRead: stat.size,
|
||||
tokensByModel: this._cloneTokens(result.tokensByModel),
|
||||
compaction: this._cloneCompaction(result.compaction),
|
||||
result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// newBytes was empty (e.g. only newlines appended)
|
||||
this._cache.set(key, { ...cached, mtimeMs: stat.mtimeMs, size: stat.size, bytesRead: stat.size });
|
||||
return cached.result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_readFrom(filePath, offset, totalSize) {
|
||||
const len = totalSize - offset;
|
||||
if (len <= 0) return null;
|
||||
const buf = Buffer.alloc(len);
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
try {
|
||||
fs.readSync(fd, buf, 0, len, offset);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
return buf.toString("utf8");
|
||||
}
|
||||
|
||||
_merge(cached, incremental) {
|
||||
const tokensByModel = cached.tokensByModel ? { ...cached.tokensByModel } : {};
|
||||
if (incremental && incremental.tokensByModel) {
|
||||
for (const [model, tokens] of Object.entries(incremental.tokensByModel)) {
|
||||
if (!tokensByModel[model]) {
|
||||
tokensByModel[model] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
}
|
||||
tokensByModel[model].input += tokens.input;
|
||||
tokensByModel[model].output += tokens.output;
|
||||
tokensByModel[model].cacheRead += tokens.cacheRead;
|
||||
tokensByModel[model].cacheWrite += tokens.cacheWrite;
|
||||
}
|
||||
}
|
||||
|
||||
let compaction = cached.compaction ? this._cloneCompaction(cached.compaction) : null;
|
||||
if (incremental && incremental.compaction) {
|
||||
if (!compaction) compaction = { count: 0, entries: [] };
|
||||
compaction.count += incremental.compaction.count;
|
||||
compaction.entries.push(...incremental.compaction.entries);
|
||||
}
|
||||
|
||||
return { tokensByModel, compaction };
|
||||
}
|
||||
|
||||
_cloneTokens(tokensByModel) {
|
||||
if (!tokensByModel) return null;
|
||||
const clone = {};
|
||||
for (const [model, t] of Object.entries(tokensByModel)) {
|
||||
clone[model] = { ...t };
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
_cloneCompaction(compaction) {
|
||||
if (!compaction) return null;
|
||||
return { count: compaction.count, entries: compaction.entries.map((e) => ({ ...e })) };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
Run: `node --test server/lib/__tests__/transcript-cache.test.js`
|
||||
Expected: All pass
|
||||
|
||||
- [ ] **Step 4: Add `extractCompactions()` convenience method**
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Extract only compaction entries from a JSONL file (replacement for findCompactionsInFile).
|
||||
* Uses the same cache — no duplicate reads.
|
||||
*/
|
||||
extractCompactions(transcriptPath) {
|
||||
const result = this.extract(transcriptPath);
|
||||
if (!result || !result.compaction) return [];
|
||||
return result.compaction.entries;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run all tests**
|
||||
|
||||
Run: `node --test server/lib/__tests__/transcript-cache.test.js`
|
||||
Expected: All pass
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add server/lib/transcript-cache.js server/lib/__tests__/transcript-cache.test.js
|
||||
git commit -m "feat: add incremental byte-offset reads and extractCompactions to TranscriptCache"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire Cache into Hook Handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/routes/hooks.js` (lines 1-62, 353-354)
|
||||
|
||||
Replace the standalone `extractTokensFromTranscript` function with the shared `TranscriptCache` instance.
|
||||
|
||||
- [ ] **Step 1: Create shared cache instance and replace function**
|
||||
|
||||
At the top of `server/routes/hooks.js`, replace:
|
||||
|
||||
```javascript
|
||||
// OLD (lines 15-62): the entire extractTokensFromTranscript function
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```javascript
|
||||
const TranscriptCache = require("../lib/transcript-cache");
|
||||
const transcriptCache = new TranscriptCache();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the call site at line 353-354**
|
||||
|
||||
Replace:
|
||||
```javascript
|
||||
const result = extractTokensFromTranscript(data.transcript_path);
|
||||
```
|
||||
|
||||
With:
|
||||
```javascript
|
||||
const result = transcriptCache.extract(data.transcript_path);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Export the cache instance for use by periodic scanner**
|
||||
|
||||
At the bottom of hooks.js, change:
|
||||
```javascript
|
||||
module.exports = router;
|
||||
```
|
||||
To:
|
||||
```javascript
|
||||
module.exports = router;
|
||||
module.exports.transcriptCache = transcriptCache;
|
||||
```
|
||||
|
||||
Wait — that overwrites the router export. Instead, attach it to the router:
|
||||
|
||||
```javascript
|
||||
router.transcriptCache = transcriptCache;
|
||||
module.exports = router;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run existing server tests to verify no regression**
|
||||
|
||||
Run: `npm run test:server`
|
||||
Expected: All existing tests pass
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/routes/hooks.js
|
||||
git commit -m "refactor: replace extractTokensFromTranscript with TranscriptCache in hook handler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire Cache into Periodic Compaction Scanner
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/index.js` (lines 86, 104-128)
|
||||
|
||||
The 2-minute periodic scanner currently calls `findCompactionsInFile()` which does its own full synchronous read. Replace it with the shared cache from the hooks router.
|
||||
|
||||
- [ ] **Step 1: Update import and use shared cache**
|
||||
|
||||
In `server/index.js`, in the `if (!isTest)` block where the periodic scanner is set up (~line 85):
|
||||
|
||||
Replace the import:
|
||||
```javascript
|
||||
const { importCompactions, findCompactionsInFile } = require("../scripts/import-history");
|
||||
```
|
||||
|
||||
With:
|
||||
```javascript
|
||||
const { importCompactions } = require("../scripts/import-history");
|
||||
const { transcriptCache } = require("./routes/hooks");
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `findCompactionsInFile` calls with cache**
|
||||
|
||||
Replace (inside the setInterval, ~line 113):
|
||||
```javascript
|
||||
const compactions = findCompactionsInFile(row.tp);
|
||||
```
|
||||
|
||||
With:
|
||||
```javascript
|
||||
const compactions = transcriptCache.extractCompactions(row.tp);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run server tests**
|
||||
|
||||
Run: `npm run test:server`
|
||||
Expected: All pass
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add server/index.js
|
||||
git commit -m "refactor: periodic compaction scanner uses shared TranscriptCache instead of standalone file reads"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Cache Eviction for Ended Sessions
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/routes/hooks.js`
|
||||
|
||||
When a session completes, its JSONL file won't be read again. Evict it from cache to prevent unbounded memory growth.
|
||||
|
||||
- [ ] **Step 1: Add test for cache invalidation**
|
||||
|
||||
Add to `server/lib/__tests__/transcript-cache.test.js`:
|
||||
|
||||
```javascript
|
||||
it("should remove entry on invalidate()", () => {
|
||||
const file = path.join(tmpDir, "session.jsonl");
|
||||
writeJsonl(file, [
|
||||
{ message: { model: "m1", usage: { input_tokens: 100, output_tokens: 50 } } },
|
||||
]);
|
||||
|
||||
const cache = new TranscriptCache();
|
||||
cache.extract(file);
|
||||
assert.strictEqual(cache.size, 1);
|
||||
|
||||
cache.invalidate(file);
|
||||
assert.strictEqual(cache.size, 0);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test**
|
||||
|
||||
Run: `node --test server/lib/__tests__/transcript-cache.test.js`
|
||||
Expected: Pass (invalidate was already implemented in Task 1)
|
||||
|
||||
- [ ] **Step 3: Add eviction when session ends in hooks.js**
|
||||
|
||||
In `server/routes/hooks.js`, find the Stop event handler section. After the session is updated to "completed", add:
|
||||
|
||||
```javascript
|
||||
// Evict transcript from cache — session is done, no more reads expected
|
||||
if (data.transcript_path) {
|
||||
transcriptCache.invalidate(data.transcript_path);
|
||||
}
|
||||
```
|
||||
|
||||
Place this right after the `stmts.updateSession.run(...)` call for the Stop event that sets status to "completed".
|
||||
|
||||
- [ ] **Step 4: Run server tests**
|
||||
|
||||
Run: `npm run test:server`
|
||||
Expected: All pass
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add server/routes/hooks.js server/lib/__tests__/transcript-cache.test.js
|
||||
git commit -m "feat: evict transcript cache entry when session completes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Expose Cache Stats in Settings API
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/routes/settings.js`
|
||||
|
||||
Add cache stats to the `/api/settings/info` endpoint for observability.
|
||||
|
||||
- [ ] **Step 1: Import cache and add stats to info response**
|
||||
|
||||
In `server/routes/settings.js`, add to the `GET /api/settings/info` handler:
|
||||
|
||||
```javascript
|
||||
const { transcriptCache } = require("./hooks");
|
||||
```
|
||||
|
||||
In the response object, add:
|
||||
```javascript
|
||||
transcript_cache: transcriptCache.stats(),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run server tests**
|
||||
|
||||
Run: `npm run test:server`
|
||||
Expected: All pass
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/routes/settings.js
|
||||
git commit -m "feat: expose transcript cache stats in settings info endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Integration Smoke Test
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/__tests__/api.test.js`
|
||||
|
||||
Add a test that simulates the full hook event flow with transcript file reads to verify the cache integration works end-to-end.
|
||||
|
||||
- [ ] **Step 1: Add integration test for cached transcript reading**
|
||||
|
||||
Add a new describe block to `server/__tests__/api.test.js`:
|
||||
|
||||
```javascript
|
||||
describe("transcript cache integration", () => {
|
||||
it("should extract tokens from transcript file via hook event", async () => {
|
||||
// Create a temp JSONL transcript file
|
||||
const tmpTranscript = path.join(os.tmpdir(), `test-transcript-${Date.now()}.jsonl`);
|
||||
const entries = [
|
||||
JSON.stringify({ message: { model: "claude-sonnet-4-20250514", usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10, cache_creation_input_tokens: 5 } } }),
|
||||
JSON.stringify({ message: { model: "claude-sonnet-4-20250514", usage: { input_tokens: 200, output_tokens: 75, cache_read_input_tokens: 20, cache_creation_input_tokens: 10 } } }),
|
||||
];
|
||||
fs.writeFileSync(tmpTranscript, entries.join("\n") + "\n");
|
||||
|
||||
try {
|
||||
// Send hook event with transcript_path
|
||||
const sessionId = `cache-test-${Date.now()}`;
|
||||
const res = await post("/api/hooks/event", {
|
||||
hook_type: "Stop",
|
||||
data: {
|
||||
session_id: sessionId,
|
||||
transcript_path: tmpTranscript,
|
||||
cwd: "/tmp",
|
||||
},
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
// Verify tokens were stored
|
||||
const costRes = await fetch(`/api/pricing/cost/${sessionId}`);
|
||||
if (costRes.status === 200 && costRes.body.breakdown) {
|
||||
const sonnet = costRes.body.breakdown.find((b) => b.model.includes("sonnet"));
|
||||
if (sonnet) {
|
||||
assert.strictEqual(sonnet.input_tokens, 300);
|
||||
assert.strictEqual(sonnet.output_tokens, 125);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.unlinkSync(tmpTranscript);
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run full server test suite**
|
||||
|
||||
Run: `npm run test:server`
|
||||
Expected: All pass
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add server/__tests__/api.test.js
|
||||
git commit -m "test: add integration smoke test for transcript cache via hook events"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Final Build Verification
|
||||
|
||||
- [ ] **Step 1: Run all server tests**
|
||||
|
||||
Run: `npm run test:server`
|
||||
Expected: All pass
|
||||
|
||||
- [ ] **Step 2: Run client build to check nothing broke**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: Clean build, no errors
|
||||
|
||||
- [ ] **Step 3: Manual smoke test**
|
||||
|
||||
Start the dev server (`npm run dev`) and verify:
|
||||
1. Hook events still process correctly
|
||||
2. Token counts update in the UI
|
||||
3. `/api/settings/info` shows `transcript_cache` stats
|
||||
4. No errors in server console
|
||||
|
||||
- [ ] **Step 4: Final commit if any cleanup needed**
|
||||
|
||||
---
|
||||
|
||||
## Summary of Expected Impact
|
||||
|
||||
| Metric | Before | After |
|
||||
|--------|--------|-------|
|
||||
| File reads per hook event | 1 full read (every line) | 0 reads (cache hit) or partial read (new bytes only) |
|
||||
| Parse calls per hook event | N lines × JSON.parse | 0 (cache hit) or K new lines only |
|
||||
| Periodic scanner file reads | 1 full read per active session every 2min | 0 (shared cache already has data) |
|
||||
| Memory overhead | None | ~1KB per active session (tokens + metadata) |
|
||||
| Event loop blocking | Up to 120ms for large files | <1ms (stat only) on cache hit |
|
||||
|
||||
For a typical long session (20K lines, 4MB), this reduces per-event CPU cost from ~50ms to <1ms — a **50x improvement** on the hot path.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
# Stage Auto-Detection 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:** Infer a lane's stage from the hook stream the dashboard already ingests, and surface it as an explicitly-inferred amber node that can never read as done.
|
||||
|
||||
**Architecture:** Design doc: `docs/superpowers/specs/2026-07-28-stage-detection-design.md` — read it once before Task 1. Rules live in the pipeline template JSON, not in code. One pure function (`server/lib/stage-detect.js`) turns an event into a candidate node; the existing fail-safe block in `touchLaneFromHook` applies it under a forward-only, write-on-change guard; three additive columns hold the result; the client renders it dashed-amber and never green.
|
||||
|
||||
**Tech Stack:** Node 18+, Express, better-sqlite3, `node:test` (server), React 18 + TypeScript + Vitest (client).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Branch: `feat/stage-detection`, cut from the head of `feat/worktree-lanes`. 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).
|
||||
- **Detection never writes `lanes.stage`.** It writes only `detected_stage`, `detected_signal`, `detected_at`. Declared stage keeps its exact current meaning.
|
||||
- **Inference never renders `done`.** A detected node reaches `passed-no-evidence` at most.
|
||||
- **The hook path must never throw.** Everything added to `server/routes/hooks.js` lives inside the existing try/catch that already swallows lane bookkeeping errors. Claude Code waits on `POST /api/hooks/event`.
|
||||
- Schema changes are additive with one probe per column (`try { SELECT col } catch { ALTER } `), so a crash mid-migration self-heals on the next boot.
|
||||
- Preserve existing behavior: no existing route, response field, WebSocket type, or CLI command changes meaning. `lane_update` stays the only lane WS type.
|
||||
- Server CommonJS. No new npm dependencies. Server tests `node:test` + `node:assert/strict`; client tests Vitest + Testing Library. Exact-value assertions; no bare sleeps.
|
||||
- Docs move with behavior: `docs/LANES.md`, `docs/API.md`, `server/openapi-extra/lanes.js` (+ regenerate `openapi.yaml`), `server/README.md`, `ARCHITECTURE.md` as applicable.
|
||||
- The pre-commit hook runs Prettier plus both suites and takes minutes. Let it finish. NEVER `--no-verify` — on the previous branch it caught a real bug that had been dismissed as an environment quirk.
|
||||
- Baseline at branch point: 857 server tests, 297 client tests, all passing. Each task must leave `git status --short` empty.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 (B1): the rule matcher
|
||||
|
||||
Pure logic, no DB, no HTTP. Everything else depends on its shape.
|
||||
|
||||
**Files:** Create `server/lib/stage-detect.js`; create `server/__tests__/stage-detect.test.js`.
|
||||
|
||||
**Produces:**
|
||||
- `flattenInput(toolInput): string` — a searchable string from a tool's input object (concatenate string values one level deep, plus `command`, `file_path`, `skill`, `prompt` if present). Must tolerate `null`, a string, an array, and deeply nested objects without throwing.
|
||||
- `detect(pipeline, event): {nodeId, signal} | null` — `event` is `{tool_name, tool_input}`. Walks the pipeline's nodes, returns the LAST node whose `detect` rules match (later stage wins when two match, so `git push` beats `Edit`), with `signal` a short human string like `` `npm run test:server` ``. Returns null when nothing matches, when the pipeline has no rules, or when the event has no `tool_name`.
|
||||
- `compileRules(pipeline)` — internal, but exported for testing: precompiles each rule's regex ONCE per pipeline and skips (never throws on) an invalid pattern from a user-supplied template.
|
||||
|
||||
- [ ] **Step 1: write the failing tests.** Cover: `Bash` + `npm run test:server` → `tests`; `Edit` → `implement`; `Skill` + `brainstorming` → `plan`; `Bash` + `git push` → `ship`; a rule with no `match` fires on tool alone; `Read` (mentioned by no rule) → null; a template whose rule holds an invalid regex is skipped and the rest still work; `flattenInput` survives null/string/array/nested; two matching nodes → the later one wins.
|
||||
- [ ] **Step 2: run, confirm they fail** (`node --test server/__tests__/stage-detect.test.js`) — module missing.
|
||||
- [ ] **Step 3: implement.** No DB, no `require` of anything but `node:` builtins.
|
||||
- [ ] **Step 4: run, confirm they pass.**
|
||||
- [ ] **Step 5:** header audit, `npm run test:server`, commit — `feat(lanes): rule matcher for inferring a stage from a tool event`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2 (B2): rules in the template, columns in the database
|
||||
|
||||
**Files:** Modify `server/data/pipelines/default.json`; modify `server/db.js`; modify `server/lib/lanes.js`; modify `server/__tests__/lanes-lib.test.js`.
|
||||
|
||||
**Produces:**
|
||||
- `detect` arrays on the default template's nodes. Ship exactly these, and no others — every rule must be defensible:
|
||||
- `plan`: `Skill` matching `brainstorming|writing-plans`; `Write` matching `docs/.*plan.*\.md`
|
||||
- `implement`: `Edit`; `Write`
|
||||
- `tests`: `Bash` matching `\b(npm (run )?test|pytest|vitest|jest|go test|cargo test)\b`
|
||||
- `review`: `Skill` matching `code-review|requesting-code-review`; `Bash` matching `git diff|gh pr diff`
|
||||
- `ship`: `Bash` matching `git push|gh pr create`
|
||||
- `intake`, `gate`, `done`: no rules. A gate is a judgement and `done` is a claim; neither may be inferred.
|
||||
- Columns `detected_stage`, `detected_signal`, `detected_at` on `lanes`, one probe each.
|
||||
- `recordDetection(id, {nodeId, signal})` in `server/lib/lanes.js` — applies the guard and returns `{written: boolean, reason?: string}`. It writes only when ALL hold: the detection's node index is strictly greater than the current `detected_stage`'s index; and the lane's DECLARED stage index is strictly less than the detection's. Otherwise it returns `written: false` with a reason (`behind-detected`, `behind-declared`, `unknown-node`) and touches nothing.
|
||||
- `lanePayload` gains `detected_stage`, `detected_signal`, and `detected: boolean` on each entry of `pipeline_nodes` (true for the detected node and for nodes before it that carry no declaration).
|
||||
|
||||
- [ ] **Step 1: write the failing tests** in `lanes-lib.test.js`: a forward detection writes; a backward detection returns `behind-detected` and writes nothing; a detection at or behind the declared stage returns `behind-declared`; an unknown node id returns `unknown-node`; **a lane with detections and no declarations has no `done` node in `pipeline_nodes`**; the migration adds all three columns to a database holding an old-schema `lanes` row, and a simulated mid-migration crash self-heals.
|
||||
- [ ] **Step 2: run, confirm they fail.**
|
||||
- [ ] **Step 3: implement** — template rules, the three probes, `recordDetection`, the payload fields.
|
||||
- [ ] **Step 4: run, confirm they pass;** then `npm run test:server`.
|
||||
- [ ] **Step 5:** header audit, commit — `feat(lanes): detection rules in the template, detected columns on lanes`.
|
||||
|
||||
---
|
||||
|
||||
## Task 3 (B3): wire it into the hook stream
|
||||
|
||||
**Files:** Modify `server/routes/hooks.js`; modify `server/__tests__/lanes-api.test.js`.
|
||||
|
||||
**Produces:** no new exports. Inside the EXISTING `touchLaneFromHook` try/catch, after the lane is resolved: build the event from the hook payload (`data.tool_name`, `data.tool_input`), call `detect(getPipeline(lane.pipeline), event)`, and on a hit call `recordDetection`. Broadcast `lane_update` only when `recordDetection` reports `written: true` — Bash alone produced 29 470 events in a real install, so a broadcast per event is not acceptable.
|
||||
|
||||
- [ ] **Step 1: write the failing tests:** a `PostToolUse` hook carrying `Bash: npm run test:server` under a lane's cwd sets `detected_stage` to `tests`; a following `Read` event leaves it unchanged; an event for a path under no lane changes nothing; a lane whose declared stage is already `ship` ignores an `implement` detection; a hook whose `data` is malformed (`tool_input` a string, `tool_name` missing) still returns 200 and leaves the lane untouched.
|
||||
- [ ] **Step 2: run, confirm they fail.**
|
||||
- [ ] **Step 3: implement.** Nothing may be added outside the existing try/catch. Add one short comment naming the write-on-change rule and why (the event volume).
|
||||
- [ ] **Step 4: run, confirm they pass;** then `npm run test:server`.
|
||||
- [ ] **Step 5:** header audit, commit — `feat(lanes): infer a lane's stage from its hook stream`.
|
||||
|
||||
---
|
||||
|
||||
## Task 4 (B4): show it, and never as done
|
||||
|
||||
**Files:** Modify `client/src/lib/types.ts`, `client/src/components/lanes/PipelineMap.tsx`, `client/src/components/lanes/LaneCard.tsx`, `client/src/i18n/locales/{en,zh,vi,ko}/lanes.json`; modify `client/src/components/lanes/__tests__/PipelineMap.test.tsx`.
|
||||
|
||||
**Produces:** `LaneNode` gains `detected?: boolean`; `Lane` gains `detected_stage` and `detected_signal`. A node with `detected: true` renders amber with a **dashed** border, visually distinct from both green `done` and solid-amber `passed-no-evidence`. Its `title` names the signal (`tests ← npm run test:server`). `LaneCard` shows an `auto: <stage>` chip only when the detected stage is ahead of the declared one. All strings via i18n in all four locales.
|
||||
|
||||
- [ ] **Step 1: write the failing tests:** a detected node carries `data-detected="true"` and a dashed-border class token; its class differs from both the `done` and the plain `passed-no-evidence` node; the tooltip contains the signal; **no node with `detected: true` ever carries `data-state="done"`**.
|
||||
- [ ] **Step 2: run, confirm they fail** (`cd client && npx vitest run src/components/lanes/__tests__/PipelineMap.test.tsx`).
|
||||
- [ ] **Step 3: implement.**
|
||||
- [ ] **Step 4:** `npm run test:client`, `npm run build`. If the screens snapshot moves, read the diff and accept it only if it is exactly the intended change.
|
||||
- [ ] **Step 5:** header audit, commit — `feat(lanes): render inferred stages as dashed amber, never as done`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5 (B5): docs, CLI surface, and the honesty pass
|
||||
|
||||
**Files:** Modify `docs/LANES.md`, `docs/API.md`, `server/openapi-extra/lanes.js`, `openapi.yaml` (regenerated), `server/README.md`, `bin/ccam.js`, `server/__tests__/lanes-cli.test.js`.
|
||||
|
||||
**Produces:** `ccam lanes` gains a column or suffix showing the inferred stage when it leads the declared one. `docs/LANES.md` gains a Stage detection section stating: what signals are read; that rules live in the template and how to add one via `DASHBOARD_PIPELINES_DIR`; that detection is forward-only; that declared beats detected; and — prominently — **that an inferred stage never counts as evidence and never renders as done**, with the reason. `docs/API.md` and the OpenAPI fragment document the three new payload fields.
|
||||
|
||||
- [ ] **Step 1: write the failing CLI test:** `ccam lanes` prints the inferred stage for a lane whose detection leads its declaration, and does not print one when the declaration leads.
|
||||
- [ ] **Step 2: run, confirm it fails.**
|
||||
- [ ] **Step 3: implement the CLI change and write the docs.** Every rule you document must match `server/data/pipelines/default.json` exactly — read the file, do not recall it.
|
||||
- [ ] **Step 4:** `npm run test:server`, `npm run test:client`, `node scripts/generate-openapi-yaml.js` then confirm `git diff openapi.yaml` is empty.
|
||||
- [ ] **Step 5:** header audit, commit — `docs(lanes): document stage detection and its evidence boundary`.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Inferring `done` or any gate outcome.
|
||||
- Back-filling detections for existing lanes.
|
||||
- Reading `workflows.phases` as a signal — real, but it needs its own reconciliation story with the declared stage.
|
||||
- Any write to `lanes.stage` from inference.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Merged Workspace Page 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:** Merge the Lanes page and the Run page into one Workspace page at `/run` — lane strip, pipeline map, and a full Claude console for the selected lane — without losing any Run capability.
|
||||
|
||||
**Architecture:** Design doc: `docs/superpowers/specs/2026-07-28-workspace-page-design.md` — read it once before Task 1. `client/src/pages/Run.tsx` (3658 lines) is extracted into a hook and three components in three separate mechanical commits, each leaving the existing tests green with only import changes. Only then is the new page composed. Four small server pieces support it: runs start through the lane, an `ensure` endpoint, a `lane_id` on run history, and releasing the lane when a run ends.
|
||||
|
||||
**Tech Stack:** React 18 + TypeScript + Vite + Tailwind, Vitest + Testing Library (client); Node 18+, Express, better-sqlite3, `node:test` (server).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Branch: `feat/workspace-page`, cut from the head of `feat/stage-detection` (or of `feat/worktree-lanes` if B has not landed). 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).
|
||||
- **Tasks A1-A3 are pure moves.** No behaviour may change, no logic may be "improved" in passing. The existing Run tests must pass with changes to import paths ONLY. If a test needs a real edit to keep passing, stop and report it — that means the move was not pure.
|
||||
- **Extraction and composition never share a commit.** A1, A2, A3 are refactors; A5 builds the page.
|
||||
- **The console never writes a lane's stage.** No code path from the console may call `POST /:id/stage`. Only `ccam stage` declares; only detection infers.
|
||||
- Schema changes are additive with a per-column probe (`try { SELECT col } catch { ALTER }`).
|
||||
- Preserve existing behavior: `POST /api/run` and every existing WebSocket message type keep working exactly as they do — the CLI and other callers depend on them. `lane_update` stays the only lane WS type.
|
||||
- Server CommonJS; client React + TypeScript. No new npm dependencies. Exact-value assertions; bounded polling, no bare sleeps. i18n strings in all four locales (`en`, `zh`, `vi`, `ko`), genuinely translated.
|
||||
- The screens snapshot (`client/src/pages/__tests__/screens.snapshot.test.tsx`) covers `/run`. Read every snapshot diff before accepting it; never regenerate blindly.
|
||||
- The pre-commit hook runs Prettier plus both suites and takes minutes. Let it finish. NEVER `--no-verify`.
|
||||
- Baseline at branch point: 857 server tests, 297 client tests (add B's counts if B landed first). Each task leaves `git status --short` empty.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 (A1): extract `useRunStream`
|
||||
|
||||
**Files:** Create `client/src/hooks/useRunStream.ts`; create `client/src/hooks/__tests__/useRunStream.test.tsx`; modify `client/src/pages/Run.tsx`.
|
||||
|
||||
**Produces:** `useRunStream(runId: string | null)` returning `{envelopes, status, lastAck}`. It owns everything `Run.tsx` currently does with `run_stream` / `run_status` / `run_input_ack`: the envelope merge (`mergeEnvelope`, `findLastStreamingAssistant`, `findAssistantByMessageId`, `mutateAssistantAt`), the typewriter (`useTypewriterEnvelopes`), and the `eventBus.subscribe` lifecycle. Move those functions; do not rewrite them.
|
||||
|
||||
- [ ] **Step 1: write the hook's tests first** — they are new coverage for code that had none: envelopes for the subscribed run id merge in arrival order; an envelope for a different run id is ignored; a streaming assistant envelope updates in place rather than appending; a terminal `run_status` stops further merging; unmounting disposes the subscription (assert the disposer returned by `eventBus.subscribe` was called).
|
||||
- [ ] **Step 2: run, confirm they fail** — `cd client && npx vitest run src/hooks/__tests__/useRunStream.test.tsx`.
|
||||
- [ ] **Step 3: move the code.** Cut the named functions out of `Run.tsx` into the hook, export them if the tests need them, and have `Run.tsx` call the hook. Delete the now-dead copies. Change nothing else.
|
||||
- [ ] **Step 4: verify the move was pure** — `npm run test:client` (all pre-existing Run tests green, no test bodies edited), `npm run build`, and `git diff client/src/pages/__tests__/` must show no snapshot change.
|
||||
- [ ] **Step 5:** header audit, commit — `refactor(run): extract useRunStream from the Run page`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2 (A2): extract `RunConsole`
|
||||
|
||||
**Files:** Create `client/src/components/run/RunConsole.tsx`; modify `client/src/pages/Run.tsx`.
|
||||
|
||||
**Produces:** `<RunConsole runId prompt onPromptChange onSubmit onStop slashCommands busy />` rendering the envelope stream, the prompt editor with its slash autocomplete (`PromptEditor`, `detectAutocomplete`, `scoreSlashMatch`, `subsequenceMatch`, `commandSourceLabel`, `commandSourceTone`), and the token meter (`TokenMeter`, `computeTokens`, `formatNum`). It consumes `useRunStream` from A1. Props only — no direct API calls, so the same console can serve the Run page and the Workspace page.
|
||||
|
||||
- [ ] **Step 1: write the failing test** — `client/src/components/run/__tests__/RunConsole.test.tsx`: given envelopes from a mocked `useRunStream`, the assistant text renders; typing `/co` shows the matching slash command and picking one fills the prompt; the token meter shows the computed totals; `onSubmit` fires with the prompt text; `onStop` fires from the stop control.
|
||||
- [ ] **Step 2: run, confirm it fails.**
|
||||
- [ ] **Step 3: move the code.** Pure move plus the props boundary. Do not redesign the editor.
|
||||
- [ ] **Step 4:** `npm run test:client` (pre-existing Run tests green with only import changes), `npm run build`, snapshot unchanged.
|
||||
- [ ] **Step 5:** header audit, commit — `refactor(run): extract RunConsole from the Run page`.
|
||||
|
||||
---
|
||||
|
||||
## Task 3 (A3): extract `RunSetup` and `RunHistory`, leave `Run.tsx` thin
|
||||
|
||||
**Files:** Create `client/src/components/run/RunSetup.tsx`, `client/src/components/run/RunHistory.tsx`; modify `client/src/pages/Run.tsx`.
|
||||
|
||||
**Produces:** `<RunSetup>` owning mode / model / permission-mode / effort / cwd / resume-session pickers, the binary-status check and `LimitationsBanner`; `<RunHistory>` owning past runs, live runs and attach. After this task `Run.tsx` holds only page-level state and composition — report its final line count in your report.
|
||||
|
||||
- [ ] **Step 1: write the failing tests** for both components: `RunSetup` reports each selection through its callbacks and surfaces a missing-binary state; `RunHistory` lists history, marks a live run, and fires attach with the right run id.
|
||||
- [ ] **Step 2: run, confirm they fail.**
|
||||
- [ ] **Step 3: move the code.**
|
||||
- [ ] **Step 4:** `npm run test:client`, `npm run build`, snapshot unchanged.
|
||||
- [ ] **Step 5:** header audit, commit — `refactor(run): extract RunSetup and RunHistory, thin the Run page`.
|
||||
|
||||
---
|
||||
|
||||
## Task 4 (A4): the server glue
|
||||
|
||||
**Files:** Modify `server/db.js`, `server/lib/lanes.js`, `server/routes/lanes.js`, `server/lib/run-spawner.js`, `server/__tests__/lane-lifecycle.test.js`; docs as listed in the constraints.
|
||||
|
||||
**Produces:**
|
||||
- `POST /api/lanes/ensure` `{cwd, title?}` → `{lane, created: boolean}`. Returns the lane that owns `cwd` (exact match or the longest path-boundary parent, reusing `resolveLaneByCwd`), else creates an `adopted` lane. Behind the same-origin guard. Concurrent calls for the same path must yield ONE lane — rely on the `cwd` UNIQUE constraint and treat the constraint violation as "someone else created it, re-read and return it".
|
||||
- `mode` accepted by the lane `start` action and passed through to `spawnRun`, so a headless one-shot is reachable through a lane.
|
||||
- `dashboard_runs.lane_id`, one additive probe, written when a run starts through a lane; `GET /api/run/history` accepts an optional `laneId` filter.
|
||||
- **A finished run releases its lane.** When the run-spawner observes a child's real exit, clear `run_id` and set `status: "idle"` on the lane holding that `run_id`, and broadcast `lane_update`. Do this without creating a require cycle (`run-spawner` must not import a route module — read how `broadcastLane` is exported and pick the clean direction, or invert it with a callback registered at boot). State in your report which direction you chose and why.
|
||||
|
||||
- [ ] **Step 1: write the failing tests:** `ensure` returns the existing lane for an exact path, for a nested path, and creates one otherwise; two concurrent `ensure` calls for the same path create exactly one lane; a lane-started run records `lane_id` in `dashboard_runs` and `GET /api/run/history?laneId=` filters by it; **when a run ends on its own, the lane's `run_id` becomes null and its status returns to `idle`**; a cross-origin `POST /api/lanes/ensure` is refused.
|
||||
- [ ] **Step 2: run, confirm they fail.**
|
||||
- [ ] **Step 3: implement.**
|
||||
- [ ] **Step 4:** `npm run test:server`; regenerate `openapi.yaml` and confirm `git diff openapi.yaml` is empty.
|
||||
- [ ] **Step 5:** header audit, commit — `feat(lanes): ensure endpoint, run history per lane, release the lane when a run ends`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5 (A5): compose the Workspace page
|
||||
|
||||
**Files:** Create `client/src/pages/Workspace.tsx`; modify `client/src/App.tsx`, `client/src/components/Sidebar.tsx`, `client/src/lib/api.ts`, `client/src/i18n/locales/*/lanes.json`; modify `client/src/pages/__tests__/screens.snapshot.test.tsx`.
|
||||
|
||||
**Produces:** the merged page at `/run`; `/lanes` redirects to it (`<Navigate to="/run" replace />`); one sidebar entry. Layout top to bottom: lane strip (horizontal scroll, counters, Add) → `PipelineMap` for the selected lane → `RunSetup` → `RunConsole` → `RunHistory` filtered to the lane. Selecting a lane switches pipeline, console and history together. Starting a run goes through `POST /api/lanes/:id/start`; choosing a cwd that no lane owns calls `POST /api/lanes/ensure` first. `api.lanes` gains `ensure`.
|
||||
|
||||
- [ ] **Step 1: write the failing tests** — `client/src/pages/__tests__/Workspace.test.tsx`: the strip lists lanes and the counters match; selecting a lane switches the pipeline and the console's run id; starting a run posts to the LANE start endpoint (assert the URL, not just that something was called); picking an unowned cwd calls `ensure` before `start`; **after a full start-and-message cycle the lane's stage is never posted to** (assert no call to any `/stage` URL).
|
||||
- [ ] **Step 2: run, confirm they fail.**
|
||||
- [ ] **Step 3: implement.** Keep `Run.tsx`'s remaining shell only if something still needs it; if the Workspace page fully replaces it, delete it and say so.
|
||||
- [ ] **Step 4:** `npm run test:client`, `npm run build`. The screens snapshot WILL change here — read the diff, confirm it is only the merged layout, then regenerate.
|
||||
- [ ] **Step 5:** header audit, commit — `feat(lanes): merge the Lanes and Run pages into one Workspace`.
|
||||
|
||||
---
|
||||
|
||||
## Task 6 (A6): docs and the seams
|
||||
|
||||
**Files:** Modify `docs/LANES.md`, `docs/API.md`, `server/README.md`, `ARCHITECTURE.md`, `README.md`, `server/openapi-extra/lanes.js` (+ regenerate `openapi.yaml`), `CLAUDE.md`.
|
||||
|
||||
**Produces:** documentation of the merged page and the new seams: that `/lanes` redirects to `/run`; that the UI starts runs through the lane while `POST /api/run` remains for the CLI; the `ensure` endpoint and when the UI calls it; `dashboard_runs.lane_id`; that a finished run releases its lane. `CLAUDE.md` gains the rule: **the console never writes a lane's stage — declared comes from `ccam stage`, inferred from detection.** Every path and command you print must exist; verify each.
|
||||
|
||||
- [ ] **Step 1:** write the docs.
|
||||
- [ ] **Step 2:** verify every referenced file, route and command exists (`ls`, `grep`, or run it).
|
||||
- [ ] **Step 3:** `npm run test:server`, `npm run test:client`, `node scripts/generate-openapi-yaml.js` then `git diff openapi.yaml` empty.
|
||||
- [ ] **Step 4:** header audit, commit — `docs(lanes): document the merged Workspace page and its seams`.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any redesign of the prompt editor, the envelope renderer, or the token meter. A1-A3 move them unchanged.
|
||||
- Multiple concurrent runs per lane. `start` already 409s when one is live.
|
||||
- Per-lane dependency bootstrap for a fresh worktree (still sub-project D if ever wanted).
|
||||
- Stage inference — that is sub-project B, and the console must not do it either way.
|
||||
@@ -0,0 +1,580 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,273 @@
|
||||
# Workspace UI Rebuild 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:** Rebuild the Workspace page to the reference screen's legibility — card grid, large pipeline, collapsible console — and fill the two data gaps that make lanes look emptier than they are (git facts, expiring detection).
|
||||
|
||||
**Architecture:** Design doc: `docs/superpowers/specs/2026-07-29-workspace-ui-design.md` — read it once before Task 1. Two server tasks land first because the client renders what they produce: detection expiry in `recordDetection`, and a read-only `GET /api/lanes/:id/git` reusing `worktree.js`'s existing `git()` and `statusCounts()`. Then the card is rebuilt, then the page shell around it.
|
||||
|
||||
**Tech Stack:** Node 18+, Express, better-sqlite3, `node:test` (server); React 18 + TypeScript + Vite + Tailwind, Vitest + Testing Library (client).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Branch: `feat/workspace-ui`, cut from the head of `feat/workspace-page`. Never work on `master`.
|
||||
- Every `.js/.ts/.tsx` 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).
|
||||
- **Detection never writes `lanes.stage`**, and **an inferred node never renders `done`.** Both are load-bearing invariants from sub-project B; a change that lets either slip is a failed task regardless of what else it achieves.
|
||||
- **No git command may be built as a shell string.** `execFile` with an argv array only, through the existing `git()` wrapper in `server/lib/worktree.js` — it scrubs the inherited `GIT_*` environment, and that scrub exists because a real bug was traced to it.
|
||||
- The destroy guard (`assertDestroyable`) and the preflight/`expect` echo are not touched by this plan.
|
||||
- `GET /api/lanes` stays free of git subprocesses. Git facts are their own endpoint.
|
||||
- Schema changes are additive with a per-column probe (`try { SELECT col } catch { ALTER }`).
|
||||
- Server CommonJS. No new npm dependencies. Server tests `node:test` + `node:assert/strict`; client tests Vitest + Testing Library. Exact-value assertions, no bare sleeps.
|
||||
- i18n strings in all four locales (`en`, `zh`, `vi`, `ko`), genuinely translated — no English copied into the other three.
|
||||
- **Node 24 is required to run the suites.** Node 25 breaks 20 client tests (global `localStorage`) and 6 server tests (better-sqlite3 ABI). Run with `PATH="$HOME/.nvm/versions/node/v24.14.1/bin:$PATH"`.
|
||||
- The pre-commit hook runs Prettier plus both suites and takes minutes. Let it finish. NEVER `--no-verify`.
|
||||
- Baseline at branch point: 906 server tests, 347 client tests, all passing. Each task leaves `git status --short` empty.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 (D1): detection expires
|
||||
|
||||
**Files:** Modify `server/lib/lanes.js`, `server/__tests__/lanes-lib.test.js`.
|
||||
|
||||
**Produces:** `recordDetection` gains a staleness window. When the lane's
|
||||
`detected_at` is older than `DETECTION_TTL_MS` (read from `process.env`, default
|
||||
`1_800_000`), the forward-only comparison against `detected_stage` is skipped
|
||||
entirely and a fresh detection is accepted even if it sits behind. Inside the
|
||||
window, behaviour is byte-for-byte what it is today.
|
||||
|
||||
The declared-wins rule is NOT affected by the window: a lane whose declared
|
||||
stage leads still refuses the detection, stale or not. Only the
|
||||
detected-vs-detected comparison expires.
|
||||
|
||||
A lane with `detected_stage` set but `detected_at` NULL (rows written before
|
||||
this column was populated) is treated as stale — an unknown age cannot be
|
||||
proven fresh.
|
||||
|
||||
- [ ] **Step 1: write the failing tests** in `lanes-lib.test.js`: a backward detection inside the window still returns `behind-detected` and writes nothing; the same backward detection with `detected_at` set beyond the TTL is written and returns `{written: true}`; a stale detection that is behind the DECLARED stage still returns `behind-declared`; a lane with `detected_stage` set and `detected_at` NULL accepts a backward detection; the TTL reads from `DETECTION_TTL_MS`. Set `detected_at` by writing the column directly in the fixture — do not sleep.
|
||||
- [ ] **Step 2: run, confirm they fail** — `node --test server/__tests__/lanes-lib.test.js`.
|
||||
- [ ] **Step 3: implement.** One added branch in `recordDetection`. Do not touch `withDetected`, `clearLane`, or the payload shape.
|
||||
- [ ] **Step 4: run, confirm they pass;** then the full server suite.
|
||||
- [ ] **Step 5:** header audit, commit — `feat(lanes): expire a stale detection so a lane can move backwards between sessions`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2 (D2): the detected signal says what matched
|
||||
|
||||
**Files:** Modify `server/lib/stage-detect.js`, `server/__tests__/stage-detect.test.js`.
|
||||
|
||||
**Produces:** `detect()` returns a `signal` built from the span the rule's regex
|
||||
actually matched plus surrounding context, instead of the whole flattened input.
|
||||
A rule with no `match` (it fired on the tool name alone) keeps today's behaviour:
|
||||
the flattened input, capped. The existing `capSignal` cap (120 chars, whitespace
|
||||
collapsed, ellipsis) still applies last.
|
||||
|
||||
Concretely: `Bash` with
|
||||
`cd /very/long/path && npm run test:server 2>&1 | tail -5` currently yields the
|
||||
whole string; it must yield a signal containing `npm run test:server` and not the
|
||||
`cd` prefix.
|
||||
|
||||
`detect()` must remain total — it never throws on any input.
|
||||
|
||||
- [ ] **Step 1: write the failing tests:** the Bash example above yields a signal containing `npm run test:server` and not `/very/long/path`; a rule with no `match` still yields the flattened input; a signal longer than the cap is still capped with the ellipsis; a matched span at the very start and at the very end of the input both survive; `detect` still returns null for an unmentioned tool.
|
||||
- [ ] **Step 2: run, confirm they fail.**
|
||||
- [ ] **Step 3: implement.**
|
||||
- [ ] **Step 4: run, confirm they pass;** then the full server suite.
|
||||
- [ ] **Step 5:** header audit, commit — `feat(lanes): report the matched span as the detection signal`.
|
||||
|
||||
---
|
||||
|
||||
## Task 3 (D3): `gitFacts()` in the worktree library
|
||||
|
||||
**Files:** Modify `server/lib/worktree.js`; modify `server/__tests__/worktree.test.js`.
|
||||
|
||||
**Produces:** `gitFacts(dir)` returning `{branch, head, subject, dirty, untracked}`.
|
||||
It reuses the EXISTING `git()` wrapper and `statusCounts(dir)` in the same file —
|
||||
do NOT add a second subprocess helper and do NOT build any command as a shell
|
||||
string. `branch` comes from `rev-parse --abbrev-ref HEAD`, `subject` from
|
||||
`log -1 --format=%s`, and `head`/`dirty`/`untracked` come from `statusCounts`.
|
||||
It throws nothing the caller must catch beyond what `git()` already throws; the
|
||||
route in D4 decides what a failure means.
|
||||
|
||||
No route, no HTTP, no OpenAPI in this task.
|
||||
|
||||
- [ ] **Step 1: write the failing tests** against a real temporary git repo fixture (`git init`, one commit, then one modified tracked file and one untracked file): the branch name, the short head matching `rev-parse --short HEAD`, the exact commit subject, `dirty: 1`, `untracked: 1`. Also: a detached HEAD yields a `branch` of `HEAD` (assert the exact value the command returns, do not invent one); a repo whose only commit has a subject containing spaces returns it whole.
|
||||
- [ ] **Step 2: run, confirm they fail.**
|
||||
- [ ] **Step 3: implement.**
|
||||
- [ ] **Step 4:** full server suite.
|
||||
- [ ] **Step 5:** header audit, commit — `feat(lanes): read branch, head, subject and working-tree counts from a worktree`.
|
||||
|
||||
---
|
||||
|
||||
## Task 4 (D4): the `GET /api/lanes/:id/git` route
|
||||
|
||||
**Files:** Modify `server/routes/lanes.js`, `server/openapi-extra/lanes.js` (+ regenerate `openapi.yaml`); modify `server/__tests__/lanes-api.test.js`.
|
||||
|
||||
**Produces:** `GET /api/lanes/:id/git` → `200 {available: true, ...facts}` for a git
|
||||
worktree; `200 {available: false}` when the lane's `cwd` is missing, is not a git
|
||||
repo, or git fails for any reason. A missing lane is `404`. It is a READ endpoint:
|
||||
no same-origin guard (that guard is for the destructive actions), and it must
|
||||
never mutate a lane.
|
||||
|
||||
Register the route **before** the `/:id/:action` catch-all, the same way
|
||||
`/ensure` had to be — otherwise `git` is swallowed as an action name. State in
|
||||
your report that you checked the ordering and how.
|
||||
|
||||
- [ ] **Step 1: write the failing tests:** a lane pointing at a real temporary git repo returns the branch, short head, subject, `dirty` and `untracked`; a lane whose `cwd` is a plain directory returns `{available: false}` with HTTP 200; a lane whose `cwd` does not exist returns `{available: false}`; an unknown lane id returns 404; the route is NOT shadowed by `/:id/:action` — assert the response body shape, not merely the status.
|
||||
- [ ] **Step 2: run, confirm they fail.**
|
||||
- [ ] **Step 3: implement.**
|
||||
- [ ] **Step 4:** full server suite; `node scripts/generate-openapi-yaml.js` then confirm `git diff openapi.yaml` shows only the added path.
|
||||
- [ ] **Step 5:** header audit, commit — `feat(lanes): expose a lane's git facts over the API`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5 (D5): client API + types for git facts
|
||||
|
||||
**Files:** Modify `client/src/lib/api.ts`, `client/src/lib/types.ts`; modify the matching api test if one exists, else add the assertion to `client/src/lib/__tests__/`.
|
||||
|
||||
**Produces:** `api.lanes.git(id)` calling `GET /api/lanes/:id/git`, and a
|
||||
`LaneGitFacts` type (`{available: true, branch, head, subject, dirty, untracked} | {available: false}`)
|
||||
exported from `client/src/lib/types.ts`. Nothing renders it yet.
|
||||
|
||||
Keep the discriminated union — a caller must be forced to check `available`
|
||||
before reading `branch`. Do not make the fields optional on one flat type.
|
||||
|
||||
- [ ] **Step 1: write the failing test:** `api.lanes.git(3)` requests exactly `/api/lanes/3/git` with method GET, and returns the parsed body.
|
||||
- [ ] **Step 2: run, confirm it fails.**
|
||||
- [ ] **Step 3: implement.**
|
||||
- [ ] **Step 4:** `npm run test:client`, `npm run build`.
|
||||
- [ ] **Step 5:** header audit, commit — `feat(lanes): client binding for a lane's git facts`.
|
||||
|
||||
---
|
||||
|
||||
## Task 6 (D6): rebuild the lane card's own fields
|
||||
|
||||
**Files:** Modify `client/src/components/lanes/LaneCard.tsx`, `client/src/i18n/locales/{en,zh,vi,ko}/lanes.json`; create `client/src/components/lanes/__tests__/LaneCard.test.tsx`.
|
||||
|
||||
**Produces:** the card laid out per the design doc's table, using only fields the
|
||||
lane payload ALREADY carries: header row (`LANE <id>`, liveness dot, status),
|
||||
title, declared-stage chip with progress bar / `%` / time-on-stage, the
|
||||
dashed-amber `auto: <stage>` chip carrying `detected_signal` as its tooltip, the
|
||||
kind and CI tags, the needs-you banner, and the action row.
|
||||
|
||||
**No git block in this task** — that is D7. Do not call `api.lanes.git` here.
|
||||
|
||||
The existing action wiring and `DestructiveLaneModal` usage are preserved
|
||||
exactly: `reset` and `remove` keep their preflight and `expect` echo. Every
|
||||
string goes through i18n in all four locales, genuinely translated.
|
||||
|
||||
The card shows a chip, never a node state — it must not render a detected stage
|
||||
as done.
|
||||
|
||||
- [ ] **Step 1: write the failing tests** in `LaneCard.test.tsx`: every field of a fully-populated fixture lane renders with its exact value; the `auto` chip appears only when the detected stage leads the declared one, and its `title` contains the signal; a lane whose detected stage equals or trails the declared one shows NO auto chip; clicking `reset` opens the destructive modal rather than firing the action directly; the plain action callbacks fire with the right action name.
|
||||
- [ ] **Step 2: run, confirm they fail** — `cd client && npx vitest run src/components/lanes/__tests__/LaneCard.test.tsx`.
|
||||
- [ ] **Step 3: implement.**
|
||||
- [ ] **Step 4:** `npm run test:client`, `npm run build`.
|
||||
- [ ] **Step 5:** header audit, commit — `feat(lanes): rebuild the lane card for legibility`.
|
||||
|
||||
---
|
||||
|
||||
## Task 7 (D7): the card's git block
|
||||
|
||||
**Files:** Modify `client/src/components/lanes/LaneCard.tsx`, `client/src/i18n/locales/{en,zh,vi,ko}/lanes.json`; modify `client/src/components/lanes/__tests__/LaneCard.test.tsx`.
|
||||
|
||||
**Produces:** the card fetches its own facts through `api.lanes.git(lane.id)` on
|
||||
mount and every 30s, and renders a git row — branch, short head, commit subject,
|
||||
and the dirty/untracked counts. It renders the rest of the card unchanged while
|
||||
the facts are still loading and whenever `available` is false. A failed request
|
||||
is silent: no error banner, no retry storm.
|
||||
|
||||
The interval must be cleared on unmount.
|
||||
|
||||
- [ ] **Step 1: write the failing tests:** the git row renders each fact from a mocked `available: true` response; an `available: false` response renders the card with NO git row and no error; a rejected request renders the card with no git row and no error; unmounting clears the interval (assert the timer count, or that no further request is made after unmount).
|
||||
- [ ] **Step 2: run, confirm they fail.**
|
||||
- [ ] **Step 3: implement.**
|
||||
- [ ] **Step 4:** `npm run test:client`, `npm run build`.
|
||||
- [ ] **Step 5:** header audit, commit — `feat(lanes): show a lane's branch and working-tree state on its card`.
|
||||
|
||||
---
|
||||
|
||||
## Task 8 (D8): the page header and the card grid
|
||||
|
||||
**Files:** Modify `client/src/pages/Workspace.tsx`, `client/src/i18n/locales/{en,zh,vi,ko}/lanes.json`; modify `client/src/pages/__tests__/Workspace.test.tsx`.
|
||||
|
||||
**Produces:** the header bar — page title, the four counters (`lanes`, `running`,
|
||||
`needs you`, `dead`) from the API's `counts`, and the Add-lane control — and the
|
||||
responsive card grid (1 column, 2 at `md`, 3 at `xl`) replacing today's
|
||||
horizontal lane strip. Selecting a card still drives the same `selectedLaneId`
|
||||
state it does now.
|
||||
|
||||
Do NOT touch the console or the pipeline panel in this task; leave them exactly
|
||||
where they are, below the grid, however they currently render.
|
||||
|
||||
- [ ] **Step 1: write the failing tests:** the four counters render the exact values from the API's `counts`; every lane in the response gets a card; clicking a card sets it selected (assert an observable consequence, e.g. the pipeline panel's lane, not an internal state variable).
|
||||
- [ ] **Step 2: run, confirm they fail.**
|
||||
- [ ] **Step 3: implement.**
|
||||
- [ ] **Step 4:** `npm run test:client`, `npm run build`. The screens snapshot WILL change — read the diff, confirm it is only the header and grid, then regenerate.
|
||||
- [ ] **Step 5:** header audit, commit — `feat(lanes): lane grid and counters in the Workspace header`.
|
||||
|
||||
---
|
||||
|
||||
## Task 9 (D9): the selected-lane detail panel
|
||||
|
||||
**Files:** Modify `client/src/pages/Workspace.tsx`, `client/src/i18n/locales/{en,zh,vi,ko}/lanes.json`; modify `client/src/pages/__tests__/Workspace.test.tsx`.
|
||||
|
||||
**Produces:** the detail panel between the header and the grid: the selected
|
||||
lane's title, its declared stage and — when detection leads — the inferred one,
|
||||
a large `PipelineMap`, and the legend naming the five node states plus the
|
||||
dashed-amber inferred treatment.
|
||||
|
||||
**Do not change `PipelineMap` itself** — not its node-state logic, not its props.
|
||||
This task places and sizes it.
|
||||
|
||||
- [ ] **Step 1: write the failing tests:** the panel shows the selected lane's title and declared stage; selecting a different card switches the panel's pipeline; **no node rendered in the panel carries both `data-detected="true"` and `data-state="done"`** (the sub-project B premise guard, re-asserted at the new layout); the legend names each of the five states.
|
||||
- [ ] **Step 2: run, confirm they fail.**
|
||||
- [ ] **Step 3: implement.**
|
||||
- [ ] **Step 4:** `npm run test:client`, `npm run build`, snapshot diff read.
|
||||
- [ ] **Step 5:** header audit, commit — `feat(lanes): a full-width pipeline panel for the selected lane`.
|
||||
|
||||
---
|
||||
|
||||
## Task 10 (D10): collapse the console
|
||||
|
||||
**Files:** Modify `client/src/pages/Workspace.tsx`, `client/src/i18n/locales/{en,zh,vi,ko}/run.json`; modify `client/src/pages/__tests__/Workspace.test.tsx`.
|
||||
|
||||
**Produces:** `RunSetup` + `RunConsole` + `RunHistory` wrapped in a disclosure
|
||||
that starts collapsed and expands on click, with the console section moved above
|
||||
the card grid so an expanded console sits beside the lane it belongs to.
|
||||
|
||||
**The subscription must stay mounted while collapsed.** Collapse the visual
|
||||
container with CSS; do NOT conditionally unmount `RunConsole` — unmounting
|
||||
disposes `useRunStream`'s subscription and a live run's envelopes are lost.
|
||||
State in your report which mechanism you used and how you proved the
|
||||
subscription survived.
|
||||
|
||||
No prop of `RunSetup`, `RunConsole` or `RunHistory` changes. The console still
|
||||
never posts a stage.
|
||||
|
||||
- [ ] **Step 1: write the failing tests:** the console is collapsed on first render and expands on click; **an envelope delivered through the mocked event bus while the console is collapsed is present in the DOM once it is expanded**; after a full start-and-message cycle no request is made to any `/stage` URL.
|
||||
- [ ] **Step 2: run, confirm they fail.**
|
||||
- [ ] **Step 3: implement.**
|
||||
- [ ] **Step 4:** `npm run test:client`, `npm run build`, snapshot diff read then regenerated.
|
||||
- [ ] **Step 5:** header audit, commit — `feat(lanes): collapse the console without dropping its stream`.
|
||||
|
||||
---
|
||||
|
||||
## Task 11 (D11): docs
|
||||
|
||||
**Files:** Modify `docs/LANES.md`, `docs/API.md`, `README.md`, `ARCHITECTURE.md`, `CLAUDE.md`.
|
||||
|
||||
**Produces:** the new layout described where the old one was; `GET /api/lanes/:id/git` documented with its `available: false` contract and the reason it is not folded into `GET /api/lanes`; the detection TTL documented with `DETECTION_TTL_MS`, its default, and the explicit note that expiry does NOT weaken declared-wins or let inference render `done`. Every path, route and command printed must exist — verify each.
|
||||
|
||||
- [ ] **Step 1:** write the docs.
|
||||
- [ ] **Step 2:** verify every referenced file, route and command exists (`ls`, `grep`, or run it).
|
||||
- [ ] **Step 3:** full server suite, full client suite, `node scripts/generate-openapi-yaml.js` then `git diff openapi.yaml` empty.
|
||||
- [ ] **Step 4:** header audit, commit — `docs(lanes): document the rebuilt Workspace, git facts and detection expiry`.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Tickets, preview-port links, per-lane credentials, and the `agents`/`creds` buttons from the reference screen — CCAM has no data behind any of them.
|
||||
- Any change to `PipelineMap`'s node-state logic, the destroy guard, or the preflight contract.
|
||||
- Inferring `done` or a gate outcome. Still forbidden, TTL or not.
|
||||
- Re-styling any page other than `/run`.
|
||||
Reference in New Issue
Block a user