e9193ae4be
B and D plans were written and executed but never staged. C is new, not yet implemented.
1176 lines
50 KiB
Markdown
1176 lines
50 KiB
Markdown
# Per-Feature State and Archive (B) 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:** a lane's history survives switching features. Today `clearLane` erases the live row's bookkeeping; this makes `clearLane` archive it first into a new `lane_features` table, and adds `activate`/`list`/`show` so a lane can carry many features across its lifetime, each independently browsable after the lane has moved on.
|
|
|
|
**Architecture:** One new table (`lane_features`) plus one new nullable column on `lanes` (`active_feature_id`). One new library module, `server/lib/lane-features.js`, owning slug canonicalization, archive-on-switch, and read access — the `lanes` row itself stays the single live view (nothing downstream that already reads a lane's `stage`/`status`/etc. needs to change). Routes are added inline to the existing `server/routes/lanes.js` (matching how `/:id/git`, `/:id/preflight`, `/:id/runtime` are already sub-resources of that same file, not separate routers). CLI gains `ccam feature list|activate|show`. The Workspace UI gains a read-only feature picker that swaps the detail panel to an archived snapshot — it never mutates anything, matching the standing rule that the console never writes a lane's stage.
|
|
|
|
**Tech Stack:** better-sqlite3 (existing), Express (existing), the existing `server/lib/pipelines.js` node-state renderer reused verbatim for archived snapshots.
|
|
|
|
## Global Constraints
|
|
|
|
- Every applicable source file MUST start with the project's authorship header — verify with `bash .claude/skills/file-headers/scripts/check-headers.sh`.
|
|
- **Slug canonicalization is a separate function from `worktree.js:slugify`.** That function lowercases and replaces every non-alphanumeric run (including `.` and `_`) with a dash — it exists for git branch names. This plan's slug keeps `[A-Za-z0-9._-]`, strips a leading `feat/`, and turns `/`/whitespace into `-`, **without lowercasing**. The two must never be conflated or one silently swapped for the other.
|
|
- **The canonicalized slug is always echoed back** by every endpoint/CLI command that accepts one, so a caller stores what the server actually stored, never what it typed.
|
|
- **The `lanes` row stays the live view.** Nothing that already reads `lane.stage`/`lane.status`/etc. changes shape or meaning. `lane_features` is purely additive.
|
|
- **Archiving only happens when there is an active feature to archive.** A lane that never calls `activate` keeps `clearLane`'s exact pre-existing behavior (reset, no archive row) — this feature is opt-in, not a breaking change to every lane's `clear` action.
|
|
- **The UI feature viewer is read-only.** It calls `GET /:id/features` and `GET /:id/features/:slug` only, never `POST /:id/features/activate` — the console never writes a lane's stage, and viewing an archived feature must not be able to switch the live one.
|
|
- Run `npm run test:server` (full suite) and `npm run test:client` (when a task touches `client/`) plus `bash .claude/skills/file-headers/scripts/check-headers.sh` before every commit. This repo's pre-commit hook already enforces both suites; a clean run here avoids a blocked commit.
|
|
- Never use `git add -A`. Stage exactly the files each task names.
|
|
|
|
---
|
|
|
|
### Task 1: Schema + `server/lib/lane-features.js` core
|
|
|
|
**Files:**
|
|
- Modify: `server/db.js` (migration)
|
|
- Create: `server/lib/lane-features.js`
|
|
- Test: `server/__tests__/lane-features.test.js`
|
|
|
|
**Interfaces:**
|
|
- Produces: `canonicalizeSlug(input)` → `string`, throws `Object.assign(new Error(...), {code: "EBADSLUG"})` on an empty result; `listFeatures(laneId)` → `Array<FeatureRow>` (most recently touched first); `getFeature(laneId, slug)` → `FeatureRow | null`; `activateFeature(laneId, slug, {title} = {})` → `{lane: LaneRow, feature: FeatureRow}`; `archiveActiveFeature(laneId)` → `FeatureRow | null` (the archived row, or `null` when there was no active feature — used by Task 2's `clearLane` change).
|
|
- `FeatureRow` shape (hydrated, matching the `lanes` row hydration convention): `{id, lane_id, slug, title, branch, pipeline, stage, stage_since, status, gate_decision, ci_status, qc_dev, stages: object, links: object, notes, archived_at, created_at, updated_at}`.
|
|
|
|
**Schema** (add to `server/db.js`, following the file's existing migration convention — `CREATE TABLE IF NOT EXISTS` inside the main `db.exec` block near the `lanes` table definition, since this is a new table with no legacy rows to migrate):
|
|
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS lane_features (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
lane_id INTEGER NOT NULL,
|
|
slug TEXT NOT NULL,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
branch TEXT,
|
|
pipeline TEXT NOT NULL DEFAULT 'default',
|
|
stage TEXT NOT NULL DEFAULT 'idle',
|
|
stage_since TEXT,
|
|
status TEXT NOT NULL DEFAULT 'idle',
|
|
gate_decision TEXT,
|
|
ci_status TEXT,
|
|
qc_dev TEXT,
|
|
stages TEXT NOT NULL DEFAULT '{}',
|
|
links TEXT NOT NULL DEFAULT '{}',
|
|
notes TEXT,
|
|
archived_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
UNIQUE (lane_id, slug),
|
|
FOREIGN KEY (lane_id) REFERENCES lanes(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_lane_features_lane ON lane_features(lane_id);
|
|
```
|
|
|
|
Then, as a **separate, additive migration block** right after the existing lane-runtime migration block (search for `idx_lanes_slot` — the A1 slot/ports migration — and add this immediately after it, following the exact same "probe one column, ALTER if missing" pattern every other lanes-table migration in this file already uses):
|
|
|
|
```js
|
|
// Migrate: per-feature state (B). `active_feature_id` points at the
|
|
// lane_features row currently "live" (unarchived) for this lane — null for a
|
|
// lane that has never called `ccam feature activate`, which is why this
|
|
// column is nullable and every downstream reader of a lane row is unaffected
|
|
// by its addition. ON DELETE SET NULL, not CASCADE: deleting the ACTIVE
|
|
// feature row (which normally only happens via cascade when the LANE itself
|
|
// is deleted, at which point this column is moot anyway) must never leave a
|
|
// dangling id on a lane row that still exists.
|
|
try {
|
|
db.prepare("SELECT active_feature_id FROM lanes LIMIT 1").get();
|
|
} catch {
|
|
db.prepare(
|
|
"ALTER TABLE lanes ADD COLUMN active_feature_id INTEGER REFERENCES lane_features(id) ON DELETE SET NULL"
|
|
).run();
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `server/__tests__/lane-features.test.js`:
|
|
|
|
```js
|
|
/**
|
|
* @file Tests for server/lib/lane-features.js: slug canonicalization,
|
|
* activate/archive semantics, and read access to a lane's feature history.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const fs = require("node:fs");
|
|
|
|
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-lane-features-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
|
|
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
|
|
|
|
const { describe, it, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
const lanesLib = require("../lib/lanes");
|
|
const features = require("../lib/lane-features");
|
|
|
|
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
|
|
|
|
let laneSeq = 0;
|
|
function makeLane() {
|
|
laneSeq += 1;
|
|
const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`);
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed" });
|
|
}
|
|
|
|
describe("canonicalizeSlug", () => {
|
|
it("drops a leading feat/ prefix", () => {
|
|
assert.equal(features.canonicalizeSlug("feat/my-thing"), "my-thing");
|
|
});
|
|
|
|
it("turns slashes and spaces into a single flat dash-separated segment", () => {
|
|
assert.equal(features.canonicalizeSlug("feat/some thing/ nested"), "some-thing-nested");
|
|
});
|
|
|
|
it("keeps dots, underscores, and case as-is (unlike worktree.js:slugify)", () => {
|
|
assert.equal(features.canonicalizeSlug("My_Feature.v2"), "My_Feature.v2");
|
|
});
|
|
|
|
it("collapses repeated separators and trims leading/trailing dashes", () => {
|
|
assert.equal(features.canonicalizeSlug("feat//too many///slashes/"), "too-many-slashes");
|
|
});
|
|
|
|
it("refuses an empty result", () => {
|
|
assert.throws(() => features.canonicalizeSlug("feat/"), (err) => err.code === "EBADSLUG");
|
|
assert.throws(() => features.canonicalizeSlug(" "), (err) => err.code === "EBADSLUG");
|
|
});
|
|
});
|
|
|
|
describe("activateFeature / archiveActiveFeature", () => {
|
|
it("activating a brand-new slug creates a live (unarchived) feature row and points the lane at it", () => {
|
|
const lane = makeLane();
|
|
const { lane: updated, feature } = features.activateFeature(lane.id, "feat/one");
|
|
assert.equal(feature.slug, "one");
|
|
assert.equal(feature.archived_at, null);
|
|
assert.equal(updated.active_feature_id, feature.id);
|
|
});
|
|
|
|
it("activating a second slug archives the first with its final stage intact", () => {
|
|
const lane = makeLane();
|
|
features.activateFeature(lane.id, "one");
|
|
lanesLib.setStage(lane.id, { stage: "review", evidence: "looks good" });
|
|
|
|
const { feature: second } = features.activateFeature(lane.id, "two");
|
|
assert.equal(second.slug, "two");
|
|
assert.equal(second.archived_at, null);
|
|
|
|
const first = features.getFeature(lane.id, "one");
|
|
assert.notEqual(first.archived_at, null);
|
|
assert.equal(first.stage, "review");
|
|
assert.deepEqual(first.stages.review.evidence, "looks good");
|
|
});
|
|
|
|
it("re-activating an archived slug restores its saved stage onto the live lane row", () => {
|
|
const lane = makeLane();
|
|
features.activateFeature(lane.id, "one");
|
|
lanesLib.setStage(lane.id, { stage: "implement" });
|
|
features.activateFeature(lane.id, "two"); // archives "one" at stage=implement
|
|
|
|
const { lane: reactivated } = features.activateFeature(lane.id, "one");
|
|
assert.equal(reactivated.stage, "implement");
|
|
assert.equal(features.getFeature(lane.id, "one").archived_at, null);
|
|
assert.notEqual(features.getFeature(lane.id, "two").archived_at, null);
|
|
});
|
|
|
|
it("re-activating the CURRENTLY active slug is a no-op, not a self-archive", () => {
|
|
const lane = makeLane();
|
|
features.activateFeature(lane.id, "one");
|
|
lanesLib.setStage(lane.id, { stage: "review" });
|
|
const { lane: updated } = features.activateFeature(lane.id, "one");
|
|
assert.equal(updated.stage, "review");
|
|
assert.equal(features.getFeature(lane.id, "one").archived_at, null);
|
|
});
|
|
|
|
it("archiveActiveFeature returns null and touches nothing when no feature is active", () => {
|
|
const lane = makeLane();
|
|
assert.equal(features.archiveActiveFeature(lane.id), null);
|
|
});
|
|
|
|
it("echoes back the canonicalized slug, not the caller's raw input", () => {
|
|
const lane = makeLane();
|
|
const { feature } = features.activateFeature(lane.id, "feat/Weird Input/");
|
|
assert.equal(feature.slug, "Weird-Input");
|
|
});
|
|
});
|
|
|
|
describe("listFeatures / getFeature", () => {
|
|
it("lists every feature for a lane, most recently touched first", () => {
|
|
const lane = makeLane();
|
|
features.activateFeature(lane.id, "one");
|
|
features.activateFeature(lane.id, "two");
|
|
const list = features.listFeatures(lane.id);
|
|
assert.deepEqual(list.map((f) => f.slug), ["two", "one"]);
|
|
});
|
|
|
|
it("getFeature returns null for an unknown slug", () => {
|
|
const lane = makeLane();
|
|
assert.equal(features.getFeature(lane.id, "never-activated"), null);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test server/__tests__/lane-features.test.js`
|
|
Expected: FAIL — `Cannot find module '../lib/lane-features'`
|
|
|
|
- [ ] **Step 3: Apply the schema migration**
|
|
|
|
Make the two schema edits to `server/db.js` shown above (the `CREATE TABLE`/`CREATE INDEX` inside the main `db.exec(...)` block near the `lanes` table, and the separate `active_feature_id` ALTER-probe block right after the A1 slot/ports migration).
|
|
|
|
- [ ] **Step 4: Write `server/lib/lane-features.js`**
|
|
|
|
```js
|
|
/**
|
|
* @file Per-feature state and archive (B). A lane's `stage`/`status`/etc. is
|
|
* the LIVE view of whichever feature it's currently working on; this module
|
|
* lets a lane carry many features across its lifetime by snapshotting the
|
|
* live row into `lane_features` whenever the lane switches (or is cleared),
|
|
* and restoring a feature's saved state when it's switched back to.
|
|
*
|
|
* The `lanes` row itself never changes shape — every existing reader of a
|
|
* lane keeps working unmodified. Only `lanes.active_feature_id` (nullable)
|
|
* is new there, pointing at the currently-live (unarchived) feature row, or
|
|
* null for a lane that has never called `activate`.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { db } = require("../db");
|
|
const lanesLib = require("./lanes");
|
|
|
|
const nowIso = () => new Date().toISOString();
|
|
|
|
/**
|
|
* Canonicalize a feature slug: drop a leading `feat/`, turn `/` and
|
|
* whitespace runs into a single `-`, keep only `[A-Za-z0-9._-]`, collapse
|
|
* repeated `-`, trim leading/trailing `-`. Deliberately does NOT lowercase —
|
|
* a separate function from `worktree.js:slugify` (that one exists for git
|
|
* branch names and lowercases everything), never reused here, never let the
|
|
* two drift onto the same rule by accident.
|
|
*
|
|
* @param {string} input
|
|
* @returns {string}
|
|
* @throws {Error} EBADSLUG when the result is empty.
|
|
*/
|
|
function canonicalizeSlug(input) {
|
|
const withoutPrefix = String(input || "").replace(/^feat\//, "");
|
|
const result = withoutPrefix
|
|
.replace(/[\s/]+/g, "-")
|
|
.replace(/[^A-Za-z0-9._-]/g, "")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
if (!result) {
|
|
throw Object.assign(new Error("slug is empty"), { code: "EBADSLUG" });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function hydrate(row) {
|
|
if (!row) return null;
|
|
let stages = {};
|
|
let links = {};
|
|
try {
|
|
stages = JSON.parse(row.stages || "{}");
|
|
} catch {
|
|
/* corrupt blob -> empty */
|
|
}
|
|
try {
|
|
links = JSON.parse(row.links || "{}");
|
|
} catch {
|
|
/* corrupt blob -> empty */
|
|
}
|
|
return { ...row, stages, links };
|
|
}
|
|
|
|
/** Every feature a lane has ever activated, most recently touched first. */
|
|
function listFeatures(laneId) {
|
|
return db
|
|
.prepare("SELECT * FROM lane_features WHERE lane_id = ? ORDER BY updated_at DESC")
|
|
.all(laneId)
|
|
.map(hydrate);
|
|
}
|
|
|
|
/** One feature by slug, or null. */
|
|
function getFeature(laneId, slug) {
|
|
return hydrate(
|
|
db.prepare("SELECT * FROM lane_features WHERE lane_id = ? AND slug = ?").get(laneId, slug)
|
|
);
|
|
}
|
|
|
|
function getFeatureById(id) {
|
|
return hydrate(db.prepare("SELECT * FROM lane_features WHERE id = ?").get(id));
|
|
}
|
|
|
|
/**
|
|
* Snapshot a lane's CURRENT live bookkeeping into its active feature row
|
|
* (if it has one) and mark that row archived. Returns the archived row, or
|
|
* null when the lane has no active feature — archiving is opt-in, so a lane
|
|
* that never called `activate` is untouched.
|
|
*
|
|
* Does NOT reset the live `lanes` row — that stays the caller's job
|
|
* (`clearLane` resets after archiving; `activateFeature` overwrites the live
|
|
* row with the newly-activated feature's saved state instead of resetting).
|
|
*
|
|
* @param {number} laneId
|
|
* @returns {object|null} The archived feature row.
|
|
*/
|
|
function archiveActiveFeature(laneId) {
|
|
const lane = lanesLib.getLane(laneId);
|
|
if (!lane || !lane.active_feature_id) return null;
|
|
const active = getFeatureById(lane.active_feature_id);
|
|
if (!active) return null;
|
|
|
|
db.prepare(
|
|
`UPDATE lane_features SET
|
|
title = ?, branch = ?, pipeline = ?, stage = ?, stage_since = ?, status = ?,
|
|
gate_decision = ?, ci_status = ?, qc_dev = ?, stages = ?, links = ?, notes = ?,
|
|
archived_at = ?, updated_at = ?
|
|
WHERE id = ?`
|
|
).run(
|
|
lane.title,
|
|
lane.branch,
|
|
lane.pipeline,
|
|
lane.stage,
|
|
lane.stage_since,
|
|
lane.status,
|
|
lane.gate_decision,
|
|
lane.ci_status,
|
|
active.qc_dev, // qc_dev has no equivalent on `lanes` — carried over from the feature row itself, untouched by the live lane
|
|
JSON.stringify(lane.stages || {}),
|
|
JSON.stringify(lane.links || {}),
|
|
lane.notes,
|
|
nowIso(),
|
|
nowIso(),
|
|
active.id
|
|
);
|
|
return getFeatureById(active.id);
|
|
}
|
|
|
|
/**
|
|
* Activate a feature by slug: archive the currently-active feature (if any,
|
|
* and if it isn't this same slug), find-or-create the target feature row,
|
|
* copy ITS saved bookkeeping onto the live `lanes` row (so switching back to
|
|
* a past feature resumes where it left off — a brand-new slug copies in
|
|
* fresh defaults), and point `lanes.active_feature_id` at it.
|
|
*
|
|
* Re-activating the CURRENTLY active slug is a no-op on the archive step —
|
|
* the live row already IS that feature's state, so there's nothing to
|
|
* restore and nothing to archive.
|
|
*
|
|
* @param {number} laneId
|
|
* @param {string} rawSlug - Canonicalized internally; the caller's raw input is never stored.
|
|
* @param {{title?: string}} [options]
|
|
* @returns {{lane: object, feature: object}}
|
|
*/
|
|
function activateFeature(laneId, rawSlug, options = {}) {
|
|
const slug = canonicalizeSlug(rawSlug);
|
|
const lane = lanesLib.getLane(laneId);
|
|
if (!lane) throw Object.assign(new Error(`no lane ${laneId}`), { code: "ENOLANE" });
|
|
|
|
const current = lane.active_feature_id ? getFeatureById(lane.active_feature_id) : null;
|
|
if (current && current.slug === slug) {
|
|
return { lane, feature: current };
|
|
}
|
|
|
|
if (current) archiveActiveFeature(laneId);
|
|
|
|
let target = getFeature(laneId, slug);
|
|
if (!target) {
|
|
const info = db
|
|
.prepare(
|
|
`INSERT INTO lane_features (lane_id, slug, title, branch, pipeline, stage, stage_since, status, stages, links, notes)
|
|
VALUES (?, ?, ?, ?, ?, 'idle', ?, 'idle', '{}', '{}', NULL)`
|
|
)
|
|
.run(laneId, slug, options.title || slug, lane.branch, lane.pipeline, nowIso());
|
|
target = getFeatureById(info.lastInsertRowid);
|
|
} else {
|
|
// Un-archive it — it's about to become the live view again.
|
|
db.prepare("UPDATE lane_features SET archived_at = NULL, updated_at = ? WHERE id = ?").run(
|
|
nowIso(),
|
|
target.id
|
|
);
|
|
target = getFeatureById(target.id);
|
|
}
|
|
|
|
db.prepare(
|
|
`UPDATE lanes SET
|
|
stage = ?, stage_since = ?, status = ?, gate_decision = ?, ci_status = ?,
|
|
stages = ?, notes = ?, active_feature_id = ?, updated_at = ?
|
|
WHERE id = ?`
|
|
).run(
|
|
target.stage,
|
|
target.stage_since,
|
|
target.status,
|
|
target.gate_decision,
|
|
target.ci_status,
|
|
JSON.stringify(target.stages || {}),
|
|
target.notes,
|
|
target.id,
|
|
nowIso(),
|
|
laneId
|
|
);
|
|
|
|
return { lane: lanesLib.getLane(laneId), feature: getFeatureById(target.id) };
|
|
}
|
|
|
|
module.exports = {
|
|
canonicalizeSlug,
|
|
listFeatures,
|
|
getFeature,
|
|
activateFeature,
|
|
archiveActiveFeature,
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 5: Run test to verify it passes**
|
|
|
|
Run: `node --test server/__tests__/lane-features.test.js`
|
|
Expected: PASS (14 tests)
|
|
|
|
- [ ] **Step 6: Run the full suite and header audit**
|
|
|
|
Run: `npm run test:server && bash .claude/skills/file-headers/scripts/check-headers.sh`
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add server/db.js server/lib/lane-features.js server/__tests__/lane-features.test.js
|
|
git commit -m "feat(lanes): add per-feature state + archive core (lane_features) (B)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Wire archiving into `clearLane`
|
|
|
|
**Files:**
|
|
- Modify: `server/lib/lanes.js`
|
|
- Test: `server/__tests__/lanes.test.js` (this repo already has lane tests under this or a similarly-named file — search `server/__tests__/` for the existing `clearLane` test with `grep -rn "clearLane" server/__tests__/*.test.js` and add to that same file; do not create a new one if `clearLane` is already covered somewhere)
|
|
|
|
**Interfaces:**
|
|
- Consumes: `server/lib/lane-features.js`'s `archiveActiveFeature` (Task 1, already committed).
|
|
- Modifies: `lanesLib.clearLane(id)` — same signature and return value (`getLane(id)`) as before; behavior changes ONLY for a lane with `active_feature_id` set.
|
|
|
|
- [ ] **Step 1: Find `clearLane` and the existing test(s) covering it**
|
|
|
|
Run: `grep -n "function clearLane" server/lib/lanes.js` and `grep -rln "clearLane" server/__tests__/*.test.js`
|
|
|
|
Read the current `clearLane` implementation and its existing test coverage before changing anything — this task must not remove or weaken any existing assertion about what `clearLane` resets.
|
|
|
|
- [ ] **Step 2: Write the failing test**
|
|
|
|
Add to whichever existing test file covers `clearLane` (or `server/__tests__/lanes.test.js` if `clearLane` has no dedicated test yet):
|
|
|
|
```js
|
|
describe("clearLane archives the active feature first", () => {
|
|
it("archives the active feature with its final stage before resetting the live row", () => {
|
|
const lanesLib = require("../lib/lanes");
|
|
const features = require("../lib/lane-features");
|
|
const lane = lanesLib.createLane({ title: "t", cwd: makeLaneCwd(), kind: "managed" }); // use this file's existing lane-fixture helper
|
|
features.activateFeature(lane.id, "one");
|
|
lanesLib.setStage(lane.id, { stage: "review", evidence: "e" });
|
|
|
|
lanesLib.clearLane(lane.id);
|
|
|
|
const archived = features.getFeature(lane.id, "one");
|
|
assert.notEqual(archived.archived_at, null);
|
|
assert.equal(archived.stage, "review");
|
|
const cleared = lanesLib.getLane(lane.id);
|
|
assert.equal(cleared.stage, "idle");
|
|
assert.equal(cleared.active_feature_id, null);
|
|
});
|
|
|
|
it("is unchanged for a lane that never activated a feature (no archive row created)", () => {
|
|
const lanesLib = require("../lib/lanes");
|
|
const lane = lanesLib.createLane({ title: "t2", cwd: makeLaneCwd(), kind: "managed" });
|
|
lanesLib.setStage(lane.id, { stage: "review" });
|
|
lanesLib.clearLane(lane.id);
|
|
const cleared = lanesLib.getLane(lane.id);
|
|
assert.equal(cleared.stage, "idle");
|
|
});
|
|
});
|
|
```
|
|
|
|
Adapt the lane-creation calls to whatever cwd-fixture helper the target test file already uses (do not invent a new one — read the file first).
|
|
|
|
- [ ] **Step 3: Run test to verify it fails**
|
|
|
|
Run: `node --test <the test file>`
|
|
Expected: FAIL — the active feature is never archived (still shows `archived_at: null`).
|
|
|
|
- [ ] **Step 4: Modify `clearLane`**
|
|
|
|
In `server/lib/lanes.js`, find `function clearLane(id) {` and add the archive step immediately before the existing `UPDATE lanes SET stage = 'idle', ...` statement, and add `active_feature_id = NULL` to that same UPDATE's column list:
|
|
|
|
```js
|
|
function clearLane(id) {
|
|
// Opt-in: only a lane that has activated a feature has anything to archive.
|
|
// Requiring the module here (not at file top) avoids a require cycle —
|
|
// lane-features.js itself requires this file for lanesLib.getLane/setStage.
|
|
require("./lane-features").archiveActiveFeature(id);
|
|
|
|
db.prepare(
|
|
`UPDATE lanes SET stage = 'idle', stage_since = ?, status = 'idle', gate_decision = NULL,
|
|
ci_status = NULL, needs_action = NULL, stages = '{}', notes = NULL, run_id = NULL,
|
|
detected_stage = NULL, detected_signal = NULL, detected_at = NULL,
|
|
active_feature_id = NULL,
|
|
updated_at = ? WHERE id = ?`
|
|
).run(nowIso(), nowIso(), id);
|
|
return getLane(id);
|
|
}
|
|
```
|
|
|
|
(Read the exact current SQL text first with `grep -n -A6 "function clearLane" server/lib/lanes.js` — the snippet above must be merged into whatever that statement's exact current column list is, not overwrite unrelated columns.)
|
|
|
|
- [ ] **Step 5: Run test to verify it passes**
|
|
|
|
Run: `node --test <the test file>`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 6: Run the full suite and header audit**
|
|
|
|
Run: `npm run test:server && bash .claude/skills/file-headers/scripts/check-headers.sh`
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add server/lib/lanes.js <the test file you modified>
|
|
git commit -m "feat(lanes): clearLane archives the active feature before resetting (B)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: `GET/POST /api/lanes/:id/features…` routes
|
|
|
|
**Files:**
|
|
- Modify: `server/routes/lanes.js`
|
|
- Test: `server/__tests__/lane-features-api.test.js`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `server/lib/lane-features.js`'s `listFeatures`, `getFeature`, `activateFeature` (Task 1, already committed).
|
|
- Consumes: `server/lib/pipelines.js`'s `getPipeline`, `nodeStates`, `progressPct` (already exported — reused to compute a `pipeline_nodes`/`progress` view on each feature row, the same shape `lanePayload()` already computes for the live lane, so the client's `PipelineMap` component can render an archived feature identically to a live one).
|
|
|
|
Routes (register in the same file, same style, near the other `/:id/*` sub-resources — search for `router.get("/:id/git"` and add these nearby, **before** the `/:id/:action` catch-all so `features` is never swallowed as an unknown action, same reasoning already documented above that catch-all for `up`/`down`/etc.):
|
|
|
|
```
|
|
GET /api/lanes/:id/features -> { features: [FeaturePayload, ...] }
|
|
GET /api/lanes/:id/features/:slug -> { feature: FeaturePayload } (404 ENOFEATURE if absent)
|
|
POST /api/lanes/:id/features/activate -> { lane: <same shape GET /:id returns>, feature: FeaturePayload }
|
|
body: { slug: string, title?: string }
|
|
```
|
|
|
|
`FeaturePayload` = the hydrated `lane_features` row plus `pipeline_nodes` and `progress`, computed the same way `lanePayload()` computes them for a live lane (`getPipeline(feature.pipeline)`, then `nodeStates`/`progressPct` against `{stage: feature.stage, stages: feature.stages}`).
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `server/__tests__/lane-features-api.test.js`:
|
|
|
|
```js
|
|
/**
|
|
* @file Tests for GET/POST /api/lanes/:id/features… — the HTTP surface over
|
|
* server/lib/lane-features.js.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const fs = require("node:fs");
|
|
const http = require("node:http");
|
|
|
|
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-features-api-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
|
|
process.env.LANES_ROOT = path.join(SUITE_ROOT, "lanes");
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
const { createApp } = require("../index");
|
|
const lanesLib = require("../lib/lanes");
|
|
|
|
let server;
|
|
let PORT;
|
|
|
|
before(async () => {
|
|
const app = createApp();
|
|
server = http.createServer(app);
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
PORT = server.address().port;
|
|
});
|
|
after(async () => {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
fs.rmSync(SUITE_ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
function request(method, urlPath, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const data = body ? JSON.stringify(body) : null;
|
|
const req = http.request(
|
|
{
|
|
method,
|
|
hostname: "127.0.0.1",
|
|
port: PORT,
|
|
path: urlPath,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(data ? { "Content-Length": Buffer.byteLength(data) } : {}),
|
|
},
|
|
},
|
|
(res) => {
|
|
let raw = "";
|
|
res.on("data", (chunk) => (raw += chunk));
|
|
res.on("end", () => {
|
|
let json = null;
|
|
try {
|
|
json = JSON.parse(raw);
|
|
} catch {
|
|
/* empty body ok */
|
|
}
|
|
resolve({ status: res.statusCode, body: json });
|
|
});
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
if (data) req.write(data);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
let laneSeq = 0;
|
|
function makeLane() {
|
|
laneSeq += 1;
|
|
const cwd = path.join(SUITE_ROOT, `lane-cwd-${laneSeq}`);
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
return lanesLib.createLane({ title: `lane ${laneSeq}`, cwd, kind: "managed" });
|
|
}
|
|
|
|
describe("POST /api/lanes/:id/features/activate", () => {
|
|
it("activates a new feature and echoes the canonicalized slug", async () => {
|
|
const lane = makeLane();
|
|
const res = await request("POST", `/api/lanes/${lane.id}/features/activate`, {
|
|
slug: "feat/My Thing",
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.feature.slug, "My-Thing");
|
|
assert.equal(res.body.lane.active_feature_id, res.body.feature.id);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/lanes/:id/features", () => {
|
|
it("lists every feature with computed pipeline_nodes", async () => {
|
|
const lane = makeLane();
|
|
await request("POST", `/api/lanes/${lane.id}/features/activate`, { slug: "one" });
|
|
const res = await request("GET", `/api/lanes/${lane.id}/features`);
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.features.length, 1);
|
|
assert.ok(Array.isArray(res.body.features[0].pipeline_nodes));
|
|
});
|
|
});
|
|
|
|
describe("GET /api/lanes/:id/features/:slug", () => {
|
|
it("returns one feature by slug", async () => {
|
|
const lane = makeLane();
|
|
await request("POST", `/api/lanes/${lane.id}/features/activate`, { slug: "one" });
|
|
const res = await request("GET", `/api/lanes/${lane.id}/features/one`);
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.feature.slug, "one");
|
|
});
|
|
|
|
it("404s ENOFEATURE for an unknown slug", async () => {
|
|
const lane = makeLane();
|
|
const res = await request("GET", `/api/lanes/${lane.id}/features/never`);
|
|
assert.equal(res.status, 404);
|
|
assert.equal(res.body.error.code, "ENOFEATURE");
|
|
});
|
|
|
|
it("shows an archived feature's final stage after the lane moves on", async () => {
|
|
const lane = makeLane();
|
|
await request("POST", `/api/lanes/${lane.id}/features/activate`, { slug: "one" });
|
|
await request("POST", `/api/lanes/${lane.id}/stage`, { stage: "review" });
|
|
await request("POST", `/api/lanes/${lane.id}/features/activate`, { slug: "two" });
|
|
|
|
const res = await request("GET", `/api/lanes/${lane.id}/features/one`);
|
|
assert.equal(res.body.feature.stage, "review");
|
|
assert.notEqual(res.body.feature.archived_at, null);
|
|
});
|
|
});
|
|
|
|
describe("DELETE /api/lanes/:id cascades to its features", () => {
|
|
it("removes every lane_features row for a deleted lane", async () => {
|
|
const lane = makeLane();
|
|
await request("POST", `/api/lanes/${lane.id}/features/activate`, { slug: "one" });
|
|
await request("DELETE", `/api/lanes/${lane.id}`);
|
|
const { db } = require("../db");
|
|
const rows = db.prepare("SELECT * FROM lane_features WHERE lane_id = ?").all(lane.id);
|
|
assert.equal(rows.length, 0);
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
Run: `node --test server/__tests__/lane-features-api.test.js`
|
|
Expected: FAIL — 404s across the board (routes not registered).
|
|
|
|
- [ ] **Step 3: Add the routes**
|
|
|
|
In `server/routes/lanes.js`, add near the top imports:
|
|
|
|
```js
|
|
const laneFeatures = require("../lib/lane-features");
|
|
const { getPipeline, nodeStates, progressPct } = require("../lib/pipelines");
|
|
```
|
|
|
|
(If `getPipeline`/`nodeStates`/`progressPct` are already imported under different names in this file, e.g. via `require("../lib/pipelines")` as a namespace — check the top of the file first with `grep -n "require(\"../lib/pipelines\")" server/routes/lanes.js` — reuse the existing import instead of adding a second one.)
|
|
|
|
Add this helper near `payload()` (search for `function payload(lane)`):
|
|
|
|
```js
|
|
/** A feature row's pipeline view, computed the same way payload() computes
|
|
* it for a live lane — lets the client render an archived feature with the
|
|
* exact same PipelineMap component, no special-casing on the frontend. */
|
|
function featurePayload(feature) {
|
|
const pipeline = getPipeline(feature.pipeline);
|
|
return {
|
|
...feature,
|
|
pipeline_name: pipeline.name,
|
|
pipeline_nodes: nodeStates(pipeline, feature),
|
|
progress: progressPct(pipeline, feature),
|
|
};
|
|
}
|
|
```
|
|
|
|
Add the routes right before the `/:id/git` route (search for `router.get("/:id/git"` — these must land **before** it is fine since Express matches literal-then-param paths in registration order and `/:id/features` vs `/:id/git` don't collide, but placing them together keeps every `/:id/*` read sub-resource grouped):
|
|
|
|
```js
|
|
router.get("/:id/features", (req, res) => {
|
|
const lane = lanesLib.getLane(req.params.id);
|
|
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
|
res.json({ features: laneFeatures.listFeatures(lane.id).map(featurePayload) });
|
|
});
|
|
|
|
router.get("/:id/features/:slug", (req, res) => {
|
|
const lane = lanesLib.getLane(req.params.id);
|
|
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
|
const feature = laneFeatures.getFeature(lane.id, req.params.slug);
|
|
if (!feature) {
|
|
return res.status(404).json({ error: { code: "ENOFEATURE", message: "no such feature" } });
|
|
}
|
|
res.json({ feature: featurePayload(feature) });
|
|
});
|
|
|
|
router.post("/:id/features/activate", sameOriginGuard, (req, res) => {
|
|
const lane = lanesLib.getLane(req.params.id);
|
|
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
|
|
const slug = typeof req.body?.slug === "string" ? req.body.slug : "";
|
|
if (!slug) {
|
|
return res.status(400).json({ error: { code: "EBADSLUG", message: "slug is required" } });
|
|
}
|
|
try {
|
|
const { lane: updated, feature } = laneFeatures.activateFeature(lane.id, slug, {
|
|
title: req.body?.title,
|
|
});
|
|
broadcastLane(updated.id);
|
|
res.json({ lane: payload(updated), feature: featurePayload(feature) });
|
|
} catch (err) {
|
|
if (err.code === "EBADSLUG") {
|
|
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
|
}
|
|
res.status(500).json({ error: { code: err.code, message: err.message } });
|
|
}
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
Run: `node --test server/__tests__/lane-features-api.test.js`
|
|
Expected: PASS (7 tests). The `DELETE /:id` cascade test relies on the `FOREIGN KEY ... ON DELETE CASCADE` from Task 1's migration and `PRAGMA foreign_keys = ON` (already set globally in `server/db.js` — confirm with `grep -n "foreign_keys" server/db.js` rather than assuming).
|
|
|
|
- [ ] **Step 5: Run the full suite and header audit**
|
|
|
|
Run: `npm run test:server && bash .claude/skills/file-headers/scripts/check-headers.sh`
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add server/routes/lanes.js server/__tests__/lane-features-api.test.js
|
|
git commit -m "feat(lanes): expose GET/POST /api/lanes/:id/features over lane-features.js (B)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: `ccam feature list|activate|show` CLI
|
|
|
|
**Files:**
|
|
- Modify: `bin/ccam.js`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `GET /api/lanes/:id/features`, `GET /api/lanes/:id/features/:slug`, `POST /api/lanes/:id/features/activate` (Task 3, already committed) via the existing `get`/`post` helpers.
|
|
- Consumes: `resolveLaneArg` (already defined in `bin/ccam.js`) to resolve which lane a command targets.
|
|
|
|
- [ ] **Step 1: Add the command implementations**
|
|
|
|
In `bin/ccam.js`, near `cmdStage` (search for that function), add:
|
|
|
|
```js
|
|
function fmtFeatureRow(f) {
|
|
const marker = f.archived_at ? " " : "▶ ";
|
|
return `${marker}${f.slug.padEnd(24)} ${String(f.stage).padEnd(12)} ${f.progress}%${
|
|
f.archived_at ? ` (archived ${fmtTime(f.archived_at)})` : ""
|
|
}`;
|
|
}
|
|
|
|
/** `ccam feature list [<id>] [--cwd path]` — every feature this lane has activated. */
|
|
async function cmdFeatureList(args) {
|
|
const resolved = await resolveLaneArg(args);
|
|
if (!resolved) return;
|
|
const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
|
|
if (!features.length) {
|
|
console.log("no features activated yet — start one with: ccam feature activate <slug>");
|
|
return;
|
|
}
|
|
for (const f of features) console.log(fmtFeatureRow(f));
|
|
}
|
|
|
|
/** `ccam feature activate <slug> [--title X] [<id>] [--cwd path]`. */
|
|
async function cmdFeatureActivate(args) {
|
|
const slug = args.find((arg) => !arg.startsWith("--"));
|
|
if (!slug) {
|
|
console.error("usage: ccam feature activate <slug> [--title text]");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const flag = (name) => {
|
|
const i = args.indexOf(`--${name}`);
|
|
return i > -1 ? args[i + 1] : undefined;
|
|
};
|
|
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
|
if (!resolved) return;
|
|
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, {
|
|
slug,
|
|
title: flag("title"),
|
|
});
|
|
console.log(
|
|
`${c.green("✔")} lane #${lane.id} now on feature "${feature.slug}" (stage: ${feature.stage}, ${feature.progress}%)`
|
|
);
|
|
}
|
|
|
|
/** `ccam feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
|
|
async function cmdFeatureShow(args) {
|
|
const slug = args.find((arg) => !arg.startsWith("--"));
|
|
if (!slug) {
|
|
console.error("usage: ccam feature show <slug>");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
|
if (!resolved) return;
|
|
const result = await get(
|
|
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
|
undefined,
|
|
{ allowError: true }
|
|
);
|
|
if (result.status) {
|
|
console.error(`✖ feature "${slug}" → ${result.data?.error?.message || result.status}`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const f = result.feature;
|
|
console.log(`${f.slug} ${f.archived_at ? "(archived)" : "(active)"}`);
|
|
console.log(` stage: ${f.stage} status: ${f.status} progress: ${f.progress}%`);
|
|
for (const node of f.pipeline_nodes) console.log(` ${node.state.padEnd(18)} ${node.label}`);
|
|
}
|
|
```
|
|
|
|
Check whether `get()` in this file already supports a third `options` argument (`{allowError: true}`) the way `post()` does — search `function get\b` / `const get =`. If it doesn't, extend it the same way `post`/`api` already handle `allowError` (read `async function api(method, pathname, body, options = {})` first — it already accepts `options` uniformly for every verb, so `get` likely just needs its own thin wrapper updated to pass a third argument through, matching how `const post = (p, b, options) => api("POST", p, b, options);` already does).
|
|
|
|
- [ ] **Step 2: Wire the subcommand dispatch**
|
|
|
|
In `bin/ccam.js`'s `runCommand` switch, add a new case (placement: anywhere among the other top-level cases, e.g. right after the `case "lock":` block added in the previous plan):
|
|
|
|
```js
|
|
case "feature": {
|
|
const sub = rest[0];
|
|
if (sub === "list") return cmdFeatureList(rest.slice(1));
|
|
if (sub === "activate") return cmdFeatureActivate(rest.slice(1));
|
|
if (sub === "show") return cmdFeatureShow(rest.slice(1));
|
|
console.error("usage: ccam feature list | ccam feature activate <slug> [--title text] | ccam feature show <slug>");
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add the help-table entries**
|
|
|
|
In `bin/ccam.js`'s `COMMAND_GROUPS`, in the `"Lanes"` group, add after the `stage <stage> [flags]` row:
|
|
|
|
```js
|
|
["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
|
|
[
|
|
"feature activate",
|
|
"<slug> [--title text] [<id>]",
|
|
"Switch to a feature by slug, archiving the current one first (echoes the canonicalized slug)",
|
|
],
|
|
["feature show", "<slug> [<id>]", "Show one feature's saved pipeline (works on an archived one too)"],
|
|
```
|
|
|
|
- [ ] **Step 4: Manual smoke test**
|
|
|
|
```bash
|
|
ccam lanes add --cwd $(pwd) --title "smoke"
|
|
ccam feature activate one --title "First thing"
|
|
ccam stage review --evidence "looks fine"
|
|
ccam feature activate two --title "Second thing"
|
|
ccam feature list
|
|
ccam feature show one
|
|
```
|
|
|
|
Expected: `feature list` shows `two` marked active (`▶`) and `one` marked archived with a timestamp; `feature show one` prints `stage: review` and its saved pipeline nodes, not `two`'s.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add bin/ccam.js
|
|
git commit -m "feat(lanes): add ccam feature list/activate/show CLI (B)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Workspace UI — read-only feature picker
|
|
|
|
**Files:**
|
|
- Modify: `client/src/lib/api.ts`
|
|
- Modify: `client/src/lib/types.ts`
|
|
- Modify: `client/src/pages/Workspace.tsx`
|
|
- Modify: `client/src/i18n/locales/en/lanes.json`, `client/src/i18n/locales/vi/lanes.json`
|
|
- Test: `client/src/pages/__tests__/Workspace.test.tsx`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `GET /api/lanes/:id/features` and `GET /api/lanes/:id/features/:slug` (Task 3, already committed) — **never** `POST .../activate`. The UI is read-only: viewing an archived feature must never be able to switch the live one, matching the standing rule that the console never writes a lane's stage.
|
|
|
|
**Read `client/src/lib/api.ts`, `client/src/lib/types.ts`, and the "lane-detail" section of `client/src/pages/Workspace.tsx` FIRST** (the section rendering `currentLane`'s header, `LaneCard`, and `PipelineMap` — search for `data-testid="lane-detail"`) to match this codebase's real conventions before writing anything below. The sketches here show the SHAPE of what's needed, not necessarily exact tokens (fetch helper name, CSS classes, i18n key style) — verify each against the real files.
|
|
|
|
- [ ] **Step 1: Add types and API client methods**
|
|
|
|
In `client/src/lib/types.ts`, add near the other lane payload types:
|
|
|
|
```ts
|
|
export interface LaneFeature {
|
|
id: number;
|
|
lane_id: number;
|
|
slug: string;
|
|
title: string;
|
|
stage: string;
|
|
status: string;
|
|
archived_at: string | null;
|
|
pipeline_nodes: PipelineNode[]; // reuse whatever the existing lane payload's node type is called
|
|
progress: number;
|
|
}
|
|
```
|
|
|
|
(`PipelineNode` — or whatever this codebase actually calls the shape `pipeline_nodes` elements already have on the live lane type — reuse that type, don't redefine it.)
|
|
|
|
In `client/src/lib/api.ts`, add to the `lanes` API object (same object `runtime`/`up`/`down` live on):
|
|
|
|
```ts
|
|
features: {
|
|
list: (laneId: number): Promise<{ features: LaneFeature[] }> =>
|
|
/* the real fetch helper */(`/api/lanes/${laneId}/features`),
|
|
show: (laneId: number, slug: string): Promise<{ feature: LaneFeature }> =>
|
|
/* the real fetch helper */(`/api/lanes/${laneId}/features/${encodeURIComponent(slug)}`),
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 2: Add the picker and archived-snapshot view to `Workspace.tsx`**
|
|
|
|
Inside the `Workspace` component, near `currentLane` (search for `const currentLane =`), add:
|
|
|
|
```tsx
|
|
const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null);
|
|
const [features, setFeatures] = useState<LaneFeature[]>([]);
|
|
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
|
|
|
// Feature list follows the selected lane, resets the viewer on lane switch.
|
|
useEffect(() => {
|
|
setViewedFeatureSlug(null);
|
|
setViewedFeature(null);
|
|
if (currentLane === null || currentLane === undefined) {
|
|
setFeatures([]);
|
|
return;
|
|
}
|
|
api.lanes.features
|
|
.list(currentLane.id)
|
|
.then((data) => setFeatures(data.features))
|
|
.catch(() => setFeatures([]));
|
|
}, [currentLane?.id]);
|
|
|
|
// Fetch the archived snapshot when the picker selects one — read-only, never
|
|
// touches the live lane.
|
|
useEffect(() => {
|
|
if (!currentLane || !viewedFeatureSlug) {
|
|
setViewedFeature(null);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
api.lanes.features
|
|
.show(currentLane.id, viewedFeatureSlug)
|
|
.then((data) => {
|
|
if (!cancelled) setViewedFeature(data.feature);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setViewedFeature(null);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [currentLane?.id, viewedFeatureSlug]);
|
|
```
|
|
|
|
Then, in the `lane-detail` section's header row (next to the existing `pipeline_name`/stage badges), add a picker that only renders when there's more than the trivial one-feature case:
|
|
|
|
```tsx
|
|
{features.length > 0 && (
|
|
<select
|
|
data-testid="feature-picker"
|
|
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
|
value={viewedFeatureSlug ?? ""}
|
|
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
|
|
>
|
|
<option value="">{tLanes("features.live")}</option>
|
|
{features.map((f) => (
|
|
<option key={f.slug} value={f.slug}>
|
|
{f.slug}
|
|
{f.archived_at ? ` (${tLanes("features.archived")})` : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
```
|
|
|
|
And where `PipelineMap` currently renders (search for `<PipelineMap`), swap its `nodes`/`detectedSignal` props to the viewed feature's when one is selected:
|
|
|
|
```tsx
|
|
<PipelineMap
|
|
nodes={viewedFeature ? viewedFeature.pipeline_nodes : currentLane.pipeline_nodes}
|
|
detectedSignal={viewedFeature ? undefined : currentLane.detected_signal}
|
|
/>
|
|
{viewedFeature && (
|
|
<p data-testid="feature-viewer-banner" className="mb-2 text-xs text-fg-muted">
|
|
{tLanes("features.viewingArchived", { slug: viewedFeature.slug })}
|
|
</p>
|
|
)}
|
|
```
|
|
|
|
Match this file's real conditional-rendering and prop-naming conventions — read the surrounding JSX first rather than transcribing this verbatim if it doesn't fit.
|
|
|
|
- [ ] **Step 3: Add i18n strings**
|
|
|
|
Add to both `client/src/i18n/locales/en/lanes.json` and `vi/lanes.json`, under whatever key grouping convention this file already uses (check an existing small group like `"runtime"` for the pattern):
|
|
|
|
```json
|
|
"features": {
|
|
"live": "Live",
|
|
"archived": "archived",
|
|
"viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot."
|
|
}
|
|
```
|
|
|
|
(Vietnamese translation for the third string, matching this repo's existing tone in `vi/lanes.json`.)
|
|
|
|
- [ ] **Step 4: Write a test**
|
|
|
|
In `client/src/pages/__tests__/Workspace.test.tsx`, find how this file already mocks `api.lanes.*` calls for the selected-lane detail panel and follow the same pattern to mock `api.lanes.features.list`/`.show`. Add:
|
|
|
|
```tsx
|
|
it("shows a feature picker and swaps the pipeline map to an archived snapshot without touching the live lane", async () => {
|
|
vi.mocked(api.lanes.features.list).mockResolvedValue({
|
|
features: [
|
|
{ id: 1, lane_id: 1, slug: "one", title: "One", stage: "review", status: "idle", archived_at: "2026-01-01T00:00:00Z", pipeline_nodes: [], progress: 60 },
|
|
{ id: 2, lane_id: 1, slug: "two", title: "Two", stage: "plan", status: "idle", archived_at: null, pipeline_nodes: [], progress: 10 },
|
|
],
|
|
});
|
|
vi.mocked(api.lanes.features.show).mockResolvedValue({
|
|
feature: { id: 1, lane_id: 1, slug: "one", title: "One", stage: "review", status: "idle", archived_at: "2026-01-01T00:00:00Z", pipeline_nodes: [], progress: 60 },
|
|
});
|
|
// ... render, select the lane, then select "one" from the feature picker ...
|
|
// assert screen.getByTestId("feature-viewer-banner") appears
|
|
// assert api.lanes.action / any mutating lane call was NEVER called as a result of the selection
|
|
});
|
|
```
|
|
|
|
Read this file's existing render/selection helpers first and mirror them rather than guessing at the render setup.
|
|
|
|
- [ ] **Step 5: Run the client test suite**
|
|
|
|
Run: `npm run test:client`
|
|
Expected: green, including the new test.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add client/src/lib/api.ts client/src/lib/types.ts client/src/pages/Workspace.tsx client/src/i18n/locales/en/lanes.json client/src/i18n/locales/vi/lanes.json client/src/pages/__tests__/Workspace.test.tsx
|
|
git commit -m "feat(lanes): add a read-only feature picker to the Workspace page (B)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Documentation
|
|
|
|
**Files:**
|
|
- Modify: `docs/LANES.md`
|
|
- Modify: `docs/CLI.md`
|
|
- Modify: `docs/API.md`
|
|
- Modify: `ARCHITECTURE.md`
|
|
- Modify: `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md` (mark B done)
|
|
|
|
**Interfaces:** none — documentation only.
|
|
|
|
- [ ] **Step 1: `docs/LANES.md`**
|
|
|
|
Add a new top-level section (after "Pipeline stages and the five node states", before "Stage detection" — search for both headings) titled `## Per-feature state and archive`, covering:
|
|
- Why: `clearLane` used to erase; now a lane can carry many features across its lifetime.
|
|
- The opt-in model: nothing changes for a lane that never calls `ccam feature activate` — `clearLane` behaves exactly as before.
|
|
- Slug canonicalization rule, stated exactly: drops a leading `feat/`, turns `/` and whitespace into `-`, keeps `[A-Za-z0-9._-]`, does **not** lowercase — a deliberately different rule from `worktree.js:slugify`'s branch-name slugification, and every endpoint/CLI command echoes back the canonicalized form.
|
|
- `activate` semantics: archives the current active feature (if any and if different), restores the target's saved stage onto the live lane row (so switching back to a past feature resumes where it left off), creates a fresh feature row for a never-seen slug.
|
|
- The CLI: `ccam feature list|activate|show`.
|
|
- The Workspace picker is **read-only** — selecting an archived feature shows its saved pipeline; it never changes the live lane, matching the standing "console never writes a lane's stage" rule.
|
|
|
|
- [ ] **Step 2: `docs/CLI.md`**
|
|
|
|
Add to the `### Lanes` table, after the `stage <stage> [flags]` row:
|
|
|
|
```markdown
|
|
| `ccam feature list [<id>]` | List every feature this lane has activated, archived or live |
|
|
| `ccam feature activate <slug> [--title text] [<id>]` | Switch to a feature by slug (echoes the canonicalized slug), archiving the current one first |
|
|
| `ccam feature show <slug> [<id>]` | Show one feature's saved pipeline — works on an archived one too |
|
|
```
|
|
|
|
- [ ] **Step 3: `docs/API.md`**
|
|
|
|
Add a `### Lane features` section documenting `GET /api/lanes/:id/features`, `GET /api/lanes/:id/features/:slug`, `POST /api/lanes/:id/features/activate` — request/response bodies and status codes exactly as specified in Task 3. Place it as its own subsection under the existing `### Lanes` section (search for where `#### Read a lane's runtime` lives and add after the lane lifecycle routes, before `### Sessions`) — **do not** split an existing heading and its content the way a prior task in this same session accidentally did; read the surrounding structure first and confirm the insertion point with `grep -n "^### \|^#### " docs/API.md` before writing.
|
|
|
|
- [ ] **Step 4: `ARCHITECTURE.md`**
|
|
|
|
Add a new row to the module responsibility table, near the other `lib/lane-*` rows:
|
|
|
|
```markdown
|
|
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
|
|
```
|
|
|
|
- [ ] **Step 5: Mark B done in the parent plan**
|
|
|
|
In `docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md`, update the status table row for `**B**` to `✅ **done** <today's date>`, and update the `## Order` diagram/prose (search for `| **B** |` and the `## Order` section) the same way A1/A2/A3/D were marked done. Note that **C** (Proof gallery) depends on B and can now move from "planned" to whatever its own next step is — do not mark C done, just confirm its dependency line still reads correctly.
|
|
|
|
- [ ] **Step 6: Verify and commit**
|
|
|
|
```bash
|
|
bash .claude/skills/file-headers/scripts/check-headers.sh
|
|
npm run test:server
|
|
```
|
|
|
|
```bash
|
|
git add docs/LANES.md docs/CLI.md docs/API.md ARCHITECTURE.md docs/superpowers/plans/2026-08-03-shipyard-parity-lanes.md
|
|
git commit -m "docs(lanes): document per-feature state and archive (B)"
|
|
```
|