feat(lanes): add per-feature state + archive core (lane_features) (B)

This commit is contained in:
2026-08-04 14:16:48 +07:00
parent 465dca35e5
commit 181e0f77ff
3 changed files with 373 additions and 0 deletions
+199
View File
@@ -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,
};