Files
Claude-Code-Monitor/server/lib/pipelines.js
T
nntrivi2001 57dc91585d feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
2026-07-30 14:39:03 +07:00

118 lines
3.6 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;
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)
);
}
/**
* 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 stages = lane.stages || {};
const cur = phaseIdx(pipeline, lane.stage);
return pipeline.nodes.map((n, i) => {
const rec = stages[n.id];
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,
nodeStates,
progressPct,
reload,
};