/** * @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, };