diff --git a/server/__tests__/lane-features.test.js b/server/__tests__/lane-features.test.js new file mode 100644 index 0000000..1b610fd --- /dev/null +++ b/server/__tests__/lane-features.test.js @@ -0,0 +1,133 @@ +/** + * @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ĩ + */ + +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); + }); +}); diff --git a/server/db.js b/server/db.js index 7f67d1b..a209222 100644 --- a/server/db.js +++ b/server/db.js @@ -432,6 +432,31 @@ db.exec(` updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ); + 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); + CREATE INDEX IF NOT EXISTS idx_lanes_session ON lanes(session_id); `); @@ -519,6 +544,22 @@ db.prepare( "CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_slot ON lanes(slot) WHERE slot IS NOT NULL" ).run(); +// 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(); +} + // Migrate: link agent rows to a workflow run. Workflow inner-agents are already // ingested as subagents (same subagents/ dir); these columns add the grouping + // phase that the run journal provides. Additive, safe on existing DBs. diff --git a/server/lib/lane-features.js b/server/lib/lane-features.js new file mode 100644 index 0000000..8d77b1a --- /dev/null +++ b/server/lib/lane-features.js @@ -0,0 +1,199 @@ +/** + * @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ĩ + */ + +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, id 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, +};