fix(lanes): stop archive/activate from clobbering feature title and links

archiveActiveFeature was overwriting a feature's own title with the
LIVE lane's title on every clear/switch — a --title set on activation
silently disappeared. activateFeature restored stage/status/gate/CI/
stages/notes onto the live lane when switching back to a past feature,
but never links, so they vanished on reactivation. Both are fixed, with
a regression test for each (full activate/archive/reactivate round trip
for links).

Also fixes server/routes/lanes.js: the doc comment explaining GET
/:id/git's rationale had been left sitting above the newly-inserted
/:id/features routes instead of its own route.

docs/API.md's Lane features section described fields and behavior that
don't exist in the real routes (an "active" boolean, a POST response
containing "archivedPrevious", a "409 ESTALE" concurrency response) —
rewritten to match the actual request/response shapes exactly.
This commit is contained in:
2026-08-04 15:17:14 +07:00
parent 917f0794d5
commit fcc7a8f2f3
4 changed files with 84 additions and 62 deletions
+20
View File
@@ -112,6 +112,26 @@ describe("activateFeature / archiveActiveFeature", () => {
const { feature } = features.activateFeature(lane.id, "feat/Weird Input/");
assert.equal(feature.slug, "Weird-Input");
});
it("a feature's own title survives being archived (never clobbered by the lane's title)", () => {
const lane = makeLane();
features.activateFeature(lane.id, "one", { title: "Custom title" });
features.activateFeature(lane.id, "two"); // archives "one"
assert.equal(features.getFeature(lane.id, "one").title, "Custom title");
});
it("links survive a full activate -> archive -> reactivate round trip", () => {
const lane = makeLane();
features.activateFeature(lane.id, "one");
lanesLib.updateLane(lane.id, { links: { pr: "https://example.com/pr/1" } });
features.activateFeature(lane.id, "two"); // archives "one" with its links
const archived = features.getFeature(lane.id, "one");
assert.deepEqual(archived.links, { pr: "https://example.com/pr/1" });
const { lane: reactivated } = features.activateFeature(lane.id, "one");
assert.deepEqual(reactivated.links, { pr: "https://example.com/pr/1" });
});
});
describe("listFeatures / getFeature", () => {
+3 -3
View File
@@ -99,12 +99,11 @@ function archiveActiveFeature(laneId) {
db.prepare(
`UPDATE lane_features SET
title = ?, branch = ?, pipeline = ?, stage = ?, stage_since = ?, status = ?,
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,
@@ -172,7 +171,7 @@ function activateFeature(laneId, rawSlug, options = {}) {
db.prepare(
`UPDATE lanes SET
stage = ?, stage_since = ?, status = ?, gate_decision = ?, ci_status = ?,
stages = ?, notes = ?, active_feature_id = ?, updated_at = ?
stages = ?, links = ?, notes = ?, active_feature_id = ?, updated_at = ?
WHERE id = ?`
).run(
target.stage,
@@ -181,6 +180,7 @@ function activateFeature(laneId, rawSlug, options = {}) {
target.gate_decision,
target.ci_status,
JSON.stringify(target.stages || {}),
JSON.stringify(target.links || {}),
target.notes,
target.id,
nowIso(),
+20 -13
View File
@@ -239,19 +239,11 @@ router.get("/:id/preflight", async (req, res) => {
});
/**
* A lane's working-copy facts: branch, short HEAD, that commit's subject, and
* the uncommitted counts. Read-only, so no same-origin guard — that guard
* exists for the destructive actions.
*
* Deliberately NOT part of `GET /api/lanes`: this shells out to git three
* times, and that payload is polled and re-broadcast on every hook-driven
* lane_update. Any failure — no such directory, not a repo, git itself
* erroring — is reported as `available: false` rather than a 500, because a
* lane pointing at a plain directory is a normal state, not a fault.
*
* The `/:id/:action` catch-all below cannot shadow this one — that route is a
* POST and Express matches on method as well as path. Verified by moving this
* registration after it: the suite stayed green.
* Per-feature state and archive (B) — `lib/lane-features.js`. `GET
* /:id/features` lists every feature the lane has ever activated (archived
* or live); `GET /:id/features/:slug` shows one, including an archived
* one's saved pipeline; `POST /:id/features/activate` switches the live
* lane to a feature by slug, archiving whichever one was active first.
*/
router.get("/:id/features", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
@@ -290,6 +282,21 @@ router.post("/:id/features/activate", sameOriginGuard, (req, res) => {
}
});
/**
* A lane's working-copy facts: branch, short HEAD, that commit's subject, and
* the uncommitted counts. Read-only, so no same-origin guard — that guard
* exists for the destructive actions.
*
* Deliberately NOT part of `GET /api/lanes`: this shells out to git three
* times, and that payload is polled and re-broadcast on every hook-driven
* lane_update. Any failure — no such directory, not a repo, git itself
* erroring — is reported as `available: false` rather than a 500, because a
* lane pointing at a plain directory is a normal state, not a fault.
*
* The `/:id/:action` catch-all below cannot shadow this one — that route is a
* POST and Express matches on method as well as path. Verified by moving this
* registration after it: the suite stayed green.
*/
router.get("/:id/git", async (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });