2103dde889
A pipeline template may now declare the slash command that drives it (`skill`), so the choice of pipeline is the choice of workflow rather than just a picture on the dashboard: - `ccam ship-full "<req>"` runs the 16-node `ship-feature` template's skill (`/ccam:ship-feature-lane`) — through gates, QC and the senior gate to a published PR, watched until a human merges it. - `ccam ship-quick "<req>"` runs the 8-node `default` template's skill (`/ccam:ship-feature`) — in-repo feature work that stops at the gate. Each command puts the lane on its own template before spawning, so the dashboard renders the right node count from the first stage instead of after the skill self-corrects. `--print` shows the command, switches nothing and runs nothing. The prompt reaches `claude` as one argv element, never through a shell. The same field feeds the Workspace run console, which pre-fills its prompt with the lane's driving skill and never overwrites a prompt the user has already edited.
145 lines
4.9 KiB
JavaScript
145 lines
4.9 KiB
JavaScript
/**
|
|
* @file Pipeline templates for lanes. A template is a plain JSON list of nodes
|
|
* (`server/data/pipelines/*.json` plus any override dropped in
|
|
* `DASHBOARD_PIPELINES_DIR`); 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. 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");
|
|
|
|
/** 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,
|
|
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. */
|
|
function getPipeline(id) {
|
|
const t = templates();
|
|
return t.get(id) || t.get(DEFAULT_PIPELINE_ID);
|
|
}
|
|
|
|
/** 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, state };
|
|
});
|
|
}
|
|
|
|
function progressPct(pipeline, lane) {
|
|
const i = phaseIdx(pipeline, lane.stage);
|
|
if (i < 0) return 0;
|
|
return Math.round((i / (pipeline.nodes.length - 1)) * 100);
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_PIPELINE_ID,
|
|
listPipelines,
|
|
getPipeline,
|
|
phaseIdx,
|
|
stageRecords,
|
|
nodeStates,
|
|
progressPct,
|
|
reload,
|
|
};
|