feat(lanes): expose GET/POST /api/lanes/:id/features over lane-features.js (B)

This commit is contained in:
2026-08-04 14:28:25 +07:00
parent aa01d77ce6
commit 5bc4d03740
2 changed files with 189 additions and 1 deletions
+51 -1
View File
@@ -13,7 +13,8 @@ const fs = require("node:fs");
const path = require("node:path");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const { listPipelines } = require("../lib/pipelines");
const { listPipelines, getPipeline, nodeStates, progressPct } = require("../lib/pipelines");
const laneFeatures = require("../lib/lane-features");
const { broadcast } = require("../websocket");
const runs = require("../lib/run-spawner");
const { sameOriginGuard } = require("./run");
@@ -64,6 +65,18 @@ function payload(lane) {
return lanesLib.lanePayload(lane, lastEventAge(lane));
}
/** A feature row's pipeline view, computed the same way payload() computes
* it for a live lane — lets the client render an archived feature with the
* exact same PipelineMap component, no special-casing on the frontend. */
function featurePayload(feature) {
const pipeline = getPipeline(feature.pipeline);
return {
...feature,
pipeline_name: pipeline.name,
pipeline_nodes: nodeStates(pipeline, feature),
progress: progressPct(pipeline, feature),
};
}
/** Push the current state of one lane to every connected client. */
function broadcastLane(id) {
const lane = lanesLib.getLane(id);
@@ -240,6 +253,43 @@ router.get("/:id/preflight", async (req, res) => {
* POST and Express matches on method as well as path. Verified by moving this
* registration after it: the suite stayed green.
*/
router.get("/:id/features", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
res.json({ features: laneFeatures.listFeatures(lane.id).map(featurePayload) });
});
router.get("/:id/features/:slug", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
const feature = laneFeatures.getFeature(lane.id, req.params.slug);
if (!feature) {
return res.status(404).json({ error: { code: "ENOFEATURE", message: "no such feature" } });
}
res.json({ feature: featurePayload(feature) });
});
router.post("/:id/features/activate", sameOriginGuard, (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
const slug = typeof req.body?.slug === "string" ? req.body.slug : "";
if (!slug) {
return res.status(400).json({ error: { code: "EBADSLUG", message: "slug is required" } });
}
try {
const { lane: updated, feature } = laneFeatures.activateFeature(lane.id, slug, {
title: req.body?.title,
});
broadcastLane(updated.id);
res.json({ lane: payload(updated), feature: featurePayload(feature) });
} catch (err) {
if (err.code === "EBADSLUG") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.status(500).json({ error: { code: err.code, message: err.message } });
}
});
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" } });