8a812a174b
Three defects let a lane's pipeline map claim progress that was not this
pass's work, all of them on the "going back" path a new request takes:
- `setStage` cleared the detection columns on a real transition but never
pruned the `stages` records AHEAD of the new stage. A lane that ran through
`done` and was then declared back at `plan` rendered `plan` as `current`
while `tests`/`review`/`done` still read `done`, carrying the previous
pass's evidence, and contradicting `progress` (computed from the declared
index alone). A backwards declaration now drops every record it can prove
is ahead; a key matching no node is unplaceable and left alone.
- `activateFeature` rewrote the live lane row for the incoming feature but
carried the outgoing one's `detected_*` across. A never-seen slug starts at
`stage='idle'`/`stages={}`, exactly the shape `inferredDisplayStage` renders
from detection, so the new feature opened painted with the old one's
progress — and forward-only rejected every real detection for it until the
carried-over one aged past DETECTION_TTL_MS. Now cleared, like every other
real transition; deliberately not archived, since detection is live-only.
- `codex-ingest` classified a rollout journal by raw `now - mtime`, which goes
negative for a future mtime (clock stepped back, NTP correction, a restore
that preserved timestamps) and read as "written milliseconds ago". A
finished thread's journal could replay as live work; a file claiming to be
from the future is now idle.
Also fixes `check-headers.sh`, which audited via `find` and so reported local
gitignored runtime state (a lane's own `.ccam/profile/hooks/*.sh`) that is not
this repo's source and can never be made compliant here. It now lists files
through git (tracked + untracked-but-not-ignored), so a file an agent just
wrote is still caught before it is staged while ignored state is not.
Tests: 1359 pass (4 new). Header audit exits 0.
210 lines
7.3 KiB
JavaScript
210 lines
7.3 KiB
JavaScript
/**
|
|
* @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
|
|
branch = ?, pipeline = ?, stage = ?, stage_since = ?, status = ?,
|
|
gate_decision = ?, ci_status = ?, qc_dev = ?, stages = ?, links = ?, notes = ?,
|
|
archived_at = ?, updated_at = ?
|
|
WHERE id = ?`
|
|
).run(
|
|
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.
|
|
*
|
|
* A real switch also clears `detected_stage`/`detected_signal`/`detected_at`,
|
|
* for the same reason `setStage` and `clearLane` do: a detection belongs to the
|
|
* feature that produced it. Carried across a switch it would both paint the new
|
|
* feature's map from the old one's activity (a fresh slug has `stage='idle'` and
|
|
* `stages={}`, so `inferredDisplayStage` renders that detection verbatim) AND —
|
|
* because `recordDetection` is forward-only — reject every real detection for
|
|
* the new feature until the old one ages past DETECTION_TTL_MS. Detection is
|
|
* live-only state, so it is deliberately NOT archived onto the outgoing row.
|
|
*
|
|
* @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 = ?, links = ?, notes = ?, active_feature_id = ?, updated_at = ?,
|
|
detected_stage = NULL, detected_signal = NULL, detected_at = NULL
|
|
WHERE id = ?`
|
|
).run(
|
|
target.stage,
|
|
target.stage_since,
|
|
target.status,
|
|
target.gate_decision,
|
|
target.ci_status,
|
|
JSON.stringify(target.stages || {}),
|
|
JSON.stringify(target.links || {}),
|
|
target.notes,
|
|
target.id,
|
|
nowIso(),
|
|
laneId
|
|
);
|
|
|
|
return { lane: lanesLib.getLane(laneId), feature: getFeatureById(target.id) };
|
|
}
|
|
|
|
module.exports = {
|
|
canonicalizeSlug,
|
|
listFeatures,
|
|
getFeature,
|
|
activateFeature,
|
|
archiveActiveFeature,
|
|
};
|