feat(lanes): add per-feature state + archive core (lane_features) (B)
This commit is contained in:
@@ -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ĩ <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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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ĩ <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, 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,
|
||||
};
|
||||
Reference in New Issue
Block a user