Files
AgentHub/server/lib/pipelines.js
T
nntrivi2001 8546748759 chore: maintenance — dependency majors, rename step 4, member Claude config, OpenAPI coverage
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
  SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
  rule for sendFile/download, alerts ack route without inline regex),
  better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
  schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
  DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
  TypeScript 7, i18next/react-i18next/codemirror minors.

Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
  the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.

Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
  and gates the router by allowlist; /profiles* and /plugins/update-all stay
  admin-only. Member writes go through write-as, CLI calls through sudo as the
  member (never -y), reads through openSafe; cc_config_changed reaches only
  its owner. Not verified live on an S3 install yet.

Other
- Chat: an HTML file is an artifact only when written whole and not a project
  page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
2026-10-26 14:22:41 +07:00

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 workspace 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,
};