2b8f83d2dd
The plugin stops shipping its workflow layer: - skills/ship-feature and skills/ship-feature-lane, the 18 stage/ship slash commands (plugin.json keeps agenthub-doctor/update/open), and all four agents/ (qc-local, senior-gate-reviewer, backend-reviewer, frontend-reviewer). - CLI `agenthub skills install`, `agenthub lanes agents install`, `agenthub ship-full|ship-quick`; routes POST /api/skills/install and POST /api/lanes/:id/agents/install; the Settings button, the lane Tools menu item and the Add-lane setup step that called them. - The Superpowers auto-install: server/lib/skill-plugins.js, the bootstrap's autoInstallSkillPlugins / skillPluginsSettled gate and the doctor row. Pipelines are kept but dormant: server/data/pipelines/ is deleted, so no template ships. `default` resolves to EMPTY_PIPELINE (no nodes, progress 0), createLane still accepts it, POST /api/lanes/:id/stage answers 400 ENOPIPELINE, and the Workspace hides the stage timer, pickers, PipelineMap and strip stage chip for a lane with no nodes. A template dropped into DASHBOARD_PIPELINES_DIR turns it all back on. The server suite loads its templates from server/__tests__/fixtures/pipelines/. Docs: CLAUDE.md (marker, retired rules), README(.vi), INSTALL, ARCHITECTURE, docs/LANES|PLUGINS|CLI|API, agenthub-introduction.html.
165 lines
6.1 KiB
JavaScript
165 lines
6.1 KiB
JavaScript
/**
|
|
* @file Pipeline templates for lanes. A template is a plain JSON list of nodes
|
|
* dropped in `DASHBOARD_PIPELINES_DIR` (`server/data/pipelines/` is read too,
|
|
* but AgentHub ships NO template any more — the pipeline feature is dormant
|
|
* until one is installed); this module resolves a lane's declared stage onto a
|
|
* node through per-node `aliases`, and derives the five render states the
|
|
* pipeline map draws. With no template, every lane is on an EMPTY `default`
|
|
* pipeline: no nodes, no states, progress 0. Pure functions — no DB, no I/O
|
|
* beyond the one-time template load, so it stays trivially testable.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
const DEFAULT_PIPELINE_ID = "default";
|
|
const BUILTIN_DIR = path.join(__dirname, "..", "data", "pipelines");
|
|
|
|
/** What every lane is on while no template is installed. */
|
|
const EMPTY_PIPELINE = Object.freeze({
|
|
id: DEFAULT_PIPELINE_ID,
|
|
name: "",
|
|
skill: "",
|
|
nodes: Object.freeze([]),
|
|
});
|
|
|
|
/** Load every template once. A malformed file is skipped, never fatal. */
|
|
function loadAll() {
|
|
const dirs = [BUILTIN_DIR];
|
|
if (process.env.DASHBOARD_PIPELINES_DIR) dirs.push(process.env.DASHBOARD_PIPELINES_DIR);
|
|
const out = new Map();
|
|
for (const dir of dirs) {
|
|
let files = [];
|
|
try {
|
|
files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
} catch {
|
|
continue; // dir absent — fine
|
|
}
|
|
for (const f of files) {
|
|
try {
|
|
const doc = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
if (!doc.id || !Array.isArray(doc.nodes) || !doc.nodes.length) continue;
|
|
// The slash command that drives this pipeline. The Run console
|
|
// pre-fills its prompt with it, so a lane on the 16-node template
|
|
// starts the 16-step skill and one on the 8-node template the 8-step
|
|
// one. Never executed by the dashboard — it is text in a prompt box.
|
|
doc.skill = typeof doc.skill === "string" ? doc.skill : "";
|
|
doc.nodes = doc.nodes.map((n) => ({
|
|
id: n.id,
|
|
label: n.label || n.id,
|
|
icon: n.icon || "",
|
|
gate: !!n.gate,
|
|
// The one command that drives THIS node — a slash command
|
|
// (`/superpowers:writing-plans`) or a shell one (`agenthub lanes hook
|
|
// ci-gate`), mirroring whatever the node's own `detect` rules watch
|
|
// for. `doc.skill` above names the driver for the whole pipeline;
|
|
// this narrows it to the step, so a reader of the pipeline map can
|
|
// see what actually moves each node instead of inferring it from a
|
|
// regex. Empty for a node with no single driver (`done`, or a gate
|
|
// another agent declares). Never executed by the dashboard — it is
|
|
// display text, same contract as `doc.skill`.
|
|
command: typeof n.command === "string" ? n.command : "",
|
|
aliases: Array.isArray(n.aliases) ? n.aliases : [],
|
|
detect: Array.isArray(n.detect) ? n.detect : [],
|
|
}));
|
|
out.set(doc.id, doc); // later dir wins — user override beats builtin
|
|
} catch {
|
|
/* skip malformed template */
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
let cache = null;
|
|
function templates() {
|
|
if (!cache) cache = loadAll();
|
|
return cache;
|
|
}
|
|
|
|
/** Test/dev helper: forget the cached templates so a new file is picked up. */
|
|
function reload() {
|
|
cache = null;
|
|
}
|
|
|
|
function listPipelines() {
|
|
return [...templates().values()];
|
|
}
|
|
|
|
/** Never throws: an unknown id yields the default template, else the empty one. */
|
|
function getPipeline(id) {
|
|
const t = templates();
|
|
return t.get(id) || t.get(DEFAULT_PIPELINE_ID) || EMPTY_PIPELINE;
|
|
}
|
|
|
|
/** Index of the node matching `stage` by id or alias; -1 when unknown. */
|
|
function phaseIdx(pipeline, stage) {
|
|
if (!stage) return -1;
|
|
const s = String(stage).toLowerCase();
|
|
return pipeline.nodes.findIndex(
|
|
(n) => n.id.toLowerCase() === s || n.aliases.some((a) => a.toLowerCase() === s)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Index every recorded stage by the node it resolves to, so a stage declared
|
|
* under an ALIAS keeps its record — and therefore its evidence. Keying the
|
|
* records by node id alone (what `lane.stages` is keyed by, verbatim from the
|
|
* declaration) silently drops `--evidence` the moment an agent says `e2e`
|
|
* instead of `e2e-feature`, which is exactly what aliases exist to allow.
|
|
* A record stored under the node's own id always wins over an alias record for
|
|
* the same node; a key matching no node is skipped.
|
|
*/
|
|
function stageRecords(pipeline, stages) {
|
|
const byIdx = new Map();
|
|
for (const [key, rec] of Object.entries(stages || {})) {
|
|
const i = phaseIdx(pipeline, key);
|
|
if (i === -1) continue;
|
|
const isCanonical = key.toLowerCase() === pipeline.nodes[i].id.toLowerCase();
|
|
if (byIdx.has(i) && !isCanonical) continue;
|
|
byIdx.set(i, rec);
|
|
}
|
|
return byIdx;
|
|
}
|
|
|
|
/**
|
|
* Render state per node:
|
|
* failed — the stage recorded result "fail"
|
|
* current — the lane's current stage
|
|
* done — recorded AND carries evidence (an artifact, not a claim)
|
|
* passed-no-evidence — recorded without evidence, or implicitly skipped past
|
|
* pending — not reached
|
|
*/
|
|
function nodeStates(pipeline, lane) {
|
|
const records = stageRecords(pipeline, lane.stages);
|
|
const cur = phaseIdx(pipeline, lane.stage);
|
|
return pipeline.nodes.map((n, i) => {
|
|
const rec = records.get(i);
|
|
let state;
|
|
if (rec && rec.result === "fail") state = "failed";
|
|
else if (i === cur) state = "current";
|
|
else if (rec) state = rec.evidence ? "done" : "passed-no-evidence";
|
|
else if (cur > -1 && i < cur) state = "passed-no-evidence";
|
|
else state = "pending";
|
|
return { id: n.id, label: n.label, icon: n.icon, gate: n.gate, command: n.command, state };
|
|
});
|
|
}
|
|
|
|
function progressPct(pipeline, lane) {
|
|
const i = phaseIdx(pipeline, lane.stage);
|
|
if (i < 0 || pipeline.nodes.length < 2) return 0;
|
|
return Math.round((i / (pipeline.nodes.length - 1)) * 100);
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_PIPELINE_ID,
|
|
listPipelines,
|
|
getPipeline,
|
|
phaseIdx,
|
|
stageRecords,
|
|
nodeStates,
|
|
progressPct,
|
|
reload,
|
|
};
|