From dbdceb6a7f0197a5fcac2cae65029a13a0882567 Mon Sep 17 00:00:00 2001 From: Cameron Pope Date: Thu, 30 Apr 2026 14:09:10 -0600 Subject: [PATCH 1/7] docs: add pipelines & steps plan (Plan 5b) --- .../plans/2026-04-30-admin-pipelines-steps.md | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-30-admin-pipelines-steps.md diff --git a/docs/superpowers/plans/2026-04-30-admin-pipelines-steps.md b/docs/superpowers/plans/2026-04-30-admin-pipelines-steps.md new file mode 100644 index 0000000..3adb749 --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-admin-pipelines-steps.md @@ -0,0 +1,264 @@ +# Pipelines & Steps Page — Plan 5b + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. + +**Goal:** Replace the `pipelines` (Configure-group) route stub with a read-only browser of pipeline YAML definitions and step modules. + +**Architecture:** Add `inspectPipelinesAndSteps()` server-only helper that scans `pipelines/`, `custom/pipelines/`, `src/pipeline/steps/`, and `custom/steps/`; parses YAMLs to extract their `id` and step list; cross-references step modules. Add `GET /api/pipelines-steps`. Add `src/admin-ui/pages/pipelines-and-steps.ts`. Remove the stub. + +**Out of scope:** +- Editing YAML / step files in the UI. +- Per-step input/output schema visualization. +- Live execution view (that's Pipelines/Jobs page already). + +**Branching:** `admin-overhaul-5b-pipelines-steps` off `admin-overhaul`. + +--- + +## Endpoint contract + +`GET /api/pipelines-steps` (auth-protected). Response 200: + +```ts +{ + pipelines: Array<{ + id: string; // from yaml + file: string; // 'pipelines/autonomous.yml' or 'custom/pipelines/...' + isOverride: boolean; // true when source is custom/ + steps: Array<{ + id: string; + type: string; + moduleId: string; // resolves from yaml; falls back to step.type + hasCustomOverride: boolean; + }>; + error: string | null; // YAML parse error, if any (file is included regardless) + }>; + steps: Array<{ // all known step modules in src/pipeline/steps/ + custom/steps/ + id: string; // base name without extension + builtinPath: string | null; // 'src/pipeline/steps/.ts' if exists + customPath: string | null; // 'custom/steps/.ts' if exists + hasCustomOverride: boolean; // both present and custom takes precedence + }>; +} +``` + +Sort pipelines by file, steps by id. + +--- + +## File Structure + +``` +src/inspect-pipeline-graph.ts — NEW. inspectPipelinesAndSteps() helper. +src/__tests__/inspect-pipeline-graph.test.ts — NEW. +src/admin.ts — MODIFIED. Route. +src/__tests__/admin.test.ts — MODIFIED. 401 + 200-shape tests. +src/admin-ui/pages/pipelines-and-steps.ts — NEW. +src/admin-ui/pages/stubs.ts — MODIFIED. Remove "pipelines" entry (the Configure-group one — distinct from `jobs`). +src/admin-ui/index.ts — MODIFIED. Inject + script. +src/admin-ui/__tests__/pipelines-and-steps.test.ts — NEW. +``` + +--- + +## Task 1: `inspectPipelinesAndSteps()` helper + +**File:** `src/inspect-pipeline-graph.ts` + +```ts +import fs from "node:fs"; +import path from "node:path"; +import { parse as parseYaml } from "yaml"; + +export interface PipelineStepEntry { + id: string; + type: string; + moduleId: string; + hasCustomOverride: boolean; +} + +export interface PipelineEntry { + id: string; + file: string; + isOverride: boolean; + steps: PipelineStepEntry[]; + error: string | null; +} + +export interface StepModuleEntry { + id: string; + builtinPath: string | null; + customPath: string | null; + hasCustomOverride: boolean; +} + +const STEP_EXTS = [".ts", ".js", ".mjs"]; + +function listYamls(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).filter((n) => n.endsWith(".yml") || n.endsWith(".yaml")); +} + +function listStepModules(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir) + .filter((n) => STEP_EXTS.some((e) => n.endsWith(e))) + .map((n) => n.replace(/\.(ts|js|mjs)$/, "")); +} + +export function inspectPipelinesAndSteps(opts?: { cwd?: string }): { + pipelines: PipelineEntry[]; + steps: StepModuleEntry[]; +} { + const cwd = opts?.cwd ?? process.cwd(); + + const builtinPipelinesDir = path.join(cwd, "pipelines"); + const customPipelinesDir = path.join(cwd, "custom/pipelines"); + const builtinStepsDir = path.join(cwd, "src/pipeline/steps"); + const customStepsDir = path.join(cwd, "custom/steps"); + + const customSteps = new Set(listStepModules(customStepsDir)); + const builtinSteps = new Set(listStepModules(builtinStepsDir)); + const stepIds = new Set([...builtinSteps, ...customSteps]); + + function parsePipelineFile(filePath: string, file: string, isOverride: boolean): PipelineEntry { + const placeholder: PipelineEntry = { id: file, file, isOverride, steps: [], error: null }; + let raw: string; + try { + raw = fs.readFileSync(filePath, "utf-8"); + } catch (e) { + return { ...placeholder, error: `Read error: ${(e as Error).message}` }; + } + let doc: unknown; + try { + doc = parseYaml(raw); + } catch (e) { + return { ...placeholder, error: `YAML parse error: ${(e as Error).message}` }; + } + if (!doc || typeof doc !== "object") { + return { ...placeholder, error: "Pipeline YAML must be an object" }; + } + const { id, steps } = doc as { id?: unknown; steps?: unknown }; + if (typeof id !== "string" || !id) { + return { ...placeholder, error: "Pipeline YAML missing 'id'" }; + } + if (!Array.isArray(steps)) { + return { ...placeholder, id, error: "Pipeline YAML 'steps' must be an array" }; + } + const stepEntries: PipelineStepEntry[] = steps.map((s, i) => { + const stepObj = (s ?? {}) as { id?: string; type?: string; moduleId?: string }; + const stepId = stepObj.id ?? `step-${i}`; + const stepType = stepObj.type ?? "unknown"; + const moduleId = stepObj.moduleId ?? stepType; + return { + id: stepId, + type: stepType, + moduleId, + hasCustomOverride: customSteps.has(moduleId), + }; + }); + return { id, file, isOverride, steps: stepEntries, error: null }; + } + + const pipelines: PipelineEntry[] = []; + for (const name of listYamls(builtinPipelinesDir)) { + const customSibling = path.join(customPipelinesDir, name); + const useCustom = fs.existsSync(customSibling); + if (useCustom) { + pipelines.push(parsePipelineFile(customSibling, `custom/pipelines/${name}`, true)); + } else { + pipelines.push(parsePipelineFile(path.join(builtinPipelinesDir, name), `pipelines/${name}`, false)); + } + } + // Custom pipelines without a built-in counterpart (additive) + for (const name of listYamls(customPipelinesDir)) { + const builtinSibling = path.join(builtinPipelinesDir, name); + if (fs.existsSync(builtinSibling)) continue; // already handled above + pipelines.push(parsePipelineFile(path.join(customPipelinesDir, name), `custom/pipelines/${name}`, true)); + } + pipelines.sort((a, b) => a.file.localeCompare(b.file)); + + const stepEntries: StepModuleEntry[] = Array.from(stepIds) + .sort() + .map((id) => { + const builtinFiles = STEP_EXTS.map((e) => `src/pipeline/steps/${id}${e}`); + const customFiles = STEP_EXTS.map((e) => `custom/steps/${id}${e}`); + const builtinPath = builtinFiles.find((p) => fs.existsSync(path.join(cwd, p))) ?? null; + const customPath = customFiles.find((p) => fs.existsSync(path.join(cwd, p))) ?? null; + return { + id, + builtinPath, + customPath, + hasCustomOverride: customPath !== null, + }; + }); + + return { pipelines, steps: stepEntries }; +} +``` + +### Tests + +In a sandbox tmpdir (4 tests): +1. **Empty cwd** — no `pipelines/`, no `src/pipeline/steps/`, no `custom/`. Returns `{ pipelines: [], steps: [] }`. +2. **Built-in pipeline parses** — write `tmp/pipelines/autonomous.yml` with id and 2 steps. Result has `pipelines[0].id === 'autonomous-loop'`, 2 steps, `error: null`, `isOverride: false`. +3. **Custom pipeline overrides built-in** — both `pipelines/autonomous.yml` and `custom/pipelines/autonomous.yml` exist. Result: one entry with `file: 'custom/pipelines/autonomous.yml'`, `isOverride: true`. +4. **YAML parse error captured** — invalid YAML in a pipelines file. Result: entry with `error: /YAML parse error/`. +5. **Step override detected** — `src/pipeline/steps/foo.ts` and `custom/steps/foo.ts` both exist. `steps` array has `{ id: 'foo', hasCustomOverride: true }`. +6. **Pipeline step `hasCustomOverride`** — pipeline references step `bar` in YAML AND `custom/steps/bar.ts` exists. The step entry inside the pipeline (`pipelines[0].steps[N].hasCustomOverride`) is true. + +Commit: `feat(pipeline): add inspectPipelinesAndSteps helper`. + +--- + +## Task 2: `/api/pipelines-steps` endpoint + +```ts +if (url === "/api/pipelines-steps" && method === "GET") { + return json(res, 200, inspectPipelinesAndSteps()); +} +``` + +Tests: 401, 200-shape (array body keys present). + +Commit: `feat(admin): add /api/pipelines-steps endpoint`. + +--- + +## Task 3: Page module + +**File:** `src/admin-ui/pages/pipelines-and-steps.ts` + +Two cards: +1. **Pipeline definitions** — one collapsible block per pipeline (use `
` for browser-native expand). Header shows id, file path (mono), isOverride badge ("Override" warn) if true, error text in red if any. Body lists steps in a small `` with columns Id / Type / Module / Override. +2. **Step modules** — single table with columns Id / Built-in path / Custom override path / Status. Status: badge "Override" warn if hasCustomOverride; badge "Built-in" neutral if only builtin; badge "Additive" info if only custom. + +Page subtitle: `${N} pipeline(s) · ${M} step modules`. + +Standard plumbing: error/empty/refresh/auto-60s. `window.loadPipelinesAndSteps`. Route key `pipelines`. + +Commit: `feat(admin): add pipelines-and-steps page module`. + +--- + +## Task 4: Wire + remove stub + +Inject in `index.ts`. Remove the `pipelines` (Configure-group) entry from `stubs.ts`. Keep all others. + +Commit: `feat(admin): wire pipelines-and-steps page, remove its stub`. + +--- + +## Task 5: Structural tests + +Standard 5-test suite (ids, register/expose, endpoint string, no bare api/esc, no var). Endpoint: `/api/pipelines-steps`. Window symbol: `loadPipelinesAndSteps`. Required ids: `ps-subtitle`, `ps-error`, `ps-pipelines-body`, `ps-pipelines-empty`, `ps-steps-body`, `ps-steps-empty`. + +Commit: `test(admin): structural tests for pipelines-and-steps page module`. + +--- + +## Risks + +- **YAML deps:** the helper uses `yaml` package, already in deps via `pipeline-loader.ts`. No new dependency. +- **YAML parse failures:** captured per-pipeline so a single bad file doesn't 500 the whole endpoint. +- **Large step list:** ~14 step modules in the repo today; trivial. From d61e8509970964e48ffd71b19ea05ff7f8bcadcd Mon Sep 17 00:00:00 2001 From: Cameron Pope Date: Thu, 30 Apr 2026 14:11:41 -0600 Subject: [PATCH 2/7] feat(pipeline): add inspectPipelinesAndSteps helper Co-Authored-By: Claude Sonnet 4.6 --- src/__tests__/inspect-pipeline-graph.test.ts | 94 ++++++++++++++ src/inspect-pipeline-graph.ts | 128 +++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 src/__tests__/inspect-pipeline-graph.test.ts create mode 100644 src/inspect-pipeline-graph.ts diff --git a/src/__tests__/inspect-pipeline-graph.test.ts b/src/__tests__/inspect-pipeline-graph.test.ts new file mode 100644 index 0000000..3287645 --- /dev/null +++ b/src/__tests__/inspect-pipeline-graph.test.ts @@ -0,0 +1,94 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { inspectPipelinesAndSteps } from "../inspect-pipeline-graph.js"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "inspect-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function writeFile(rel: string, content: string): void { + const full = path.join(tmpDir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content, "utf-8"); +} + +describe("inspectPipelinesAndSteps", () => { + it("empty cwd → empty arrays", () => { + const result = inspectPipelinesAndSteps({ cwd: tmpDir }); + expect(result.pipelines).toEqual([]); + expect(result.steps).toEqual([]); + }); + + it("built-in pipeline parses correctly", () => { + writeFile( + "pipelines/autonomous.yml", + `id: autonomous-loop\nsteps:\n - id: clone\n type: clone\n - id: install\n type: install\n`, + ); + const result = inspectPipelinesAndSteps({ cwd: tmpDir }); + expect(result.pipelines).toHaveLength(1); + const p = result.pipelines[0]; + expect(p.id).toBe("autonomous-loop"); + expect(p.steps).toHaveLength(2); + expect(p.error).toBeNull(); + expect(p.isOverride).toBe(false); + expect(p.file).toBe("pipelines/autonomous.yml"); + }); + + it("custom pipeline overrides built-in", () => { + writeFile( + "pipelines/autonomous.yml", + `id: autonomous-loop\nsteps:\n - id: clone\n type: clone\n`, + ); + writeFile( + "custom/pipelines/autonomous.yml", + `id: autonomous-loop-custom\nsteps:\n - id: clone\n type: clone\n`, + ); + const result = inspectPipelinesAndSteps({ cwd: tmpDir }); + expect(result.pipelines).toHaveLength(1); + const p = result.pipelines[0]; + expect(p.file).toBe("custom/pipelines/autonomous.yml"); + expect(p.isOverride).toBe(true); + }); + + it("YAML parse error captured in entry", () => { + writeFile("pipelines/bad.yml", "id: [unclosed"); + const result = inspectPipelinesAndSteps({ cwd: tmpDir }); + expect(result.pipelines).toHaveLength(1); + const p = result.pipelines[0]; + expect(p.error).toMatch(/YAML parse error/); + }); + + it("step override detected at top level", () => { + writeFile("src/pipeline/steps/foo.ts", "export default {}"); + writeFile("custom/steps/foo.ts", "export default {}"); + const result = inspectPipelinesAndSteps({ cwd: tmpDir }); + const step = result.steps.find((s) => s.id === "foo"); + expect(step).toBeDefined(); + expect(step!.builtinPath).toBe("src/pipeline/steps/foo.ts"); + expect(step!.customPath).toBe("custom/steps/foo.ts"); + expect(step!.hasCustomOverride).toBe(true); + }); + + it("pipeline step hasCustomOverride cross-references custom steps", () => { + writeFile("custom/steps/bar.ts", "export default {}"); + writeFile( + "pipelines/mypipeline.yml", + `id: mypipeline\nsteps:\n - id: do-bar\n type: custom\n moduleId: bar\n`, + ); + const result = inspectPipelinesAndSteps({ cwd: tmpDir }); + expect(result.pipelines).toHaveLength(1); + const p = result.pipelines[0]; + expect(p.error).toBeNull(); + const stepEntry = p.steps.find((s) => s.id === "do-bar"); + expect(stepEntry).toBeDefined(); + expect(stepEntry!.hasCustomOverride).toBe(true); + }); +}); diff --git a/src/inspect-pipeline-graph.ts b/src/inspect-pipeline-graph.ts new file mode 100644 index 0000000..b185d3e --- /dev/null +++ b/src/inspect-pipeline-graph.ts @@ -0,0 +1,128 @@ +import fs from "node:fs"; +import path from "node:path"; +import { parse as parseYaml } from "yaml"; + +export interface PipelineStepEntry { + id: string; + type: string; + moduleId: string; + hasCustomOverride: boolean; +} + +export interface PipelineEntry { + id: string; + file: string; + isOverride: boolean; + steps: PipelineStepEntry[]; + error: string | null; +} + +export interface StepModuleEntry { + id: string; + builtinPath: string | null; + customPath: string | null; + hasCustomOverride: boolean; +} + +const STEP_EXTS = [".ts", ".js", ".mjs"]; + +function listYamls(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).filter((n) => n.endsWith(".yml") || n.endsWith(".yaml")); +} + +function listStepModules(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((n) => STEP_EXTS.some((e) => n.endsWith(e))) + .map((n) => n.replace(/\.(ts|js|mjs)$/, "")); +} + +export function inspectPipelinesAndSteps(opts?: { cwd?: string }): { + pipelines: PipelineEntry[]; + steps: StepModuleEntry[]; +} { + const cwd = opts?.cwd ?? process.cwd(); + const builtinPipelinesDir = path.join(cwd, "pipelines"); + const customPipelinesDir = path.join(cwd, "custom/pipelines"); + const builtinStepsDir = path.join(cwd, "src/pipeline/steps"); + const customStepsDir = path.join(cwd, "custom/steps"); + + const customSteps = new Set(listStepModules(customStepsDir)); + const builtinSteps = new Set(listStepModules(builtinStepsDir)); + const stepIds = new Set([...builtinSteps, ...customSteps]); + + function parsePipelineFile(filePath: string, file: string, isOverride: boolean): PipelineEntry { + const placeholder: PipelineEntry = { id: file, file, isOverride, steps: [], error: null }; + let raw: string; + try { + raw = fs.readFileSync(filePath, "utf-8"); + } catch (e) { + return { ...placeholder, error: `Read error: ${(e as Error).message}` }; + } + let doc: unknown; + try { + doc = parseYaml(raw); + } catch (e) { + return { ...placeholder, error: `YAML parse error: ${(e as Error).message}` }; + } + if (!doc || typeof doc !== "object") { + return { ...placeholder, error: "Pipeline YAML must be an object" }; + } + const { id, steps } = doc as { id?: unknown; steps?: unknown }; + if (typeof id !== "string" || !id) { + return { ...placeholder, error: "Pipeline YAML missing 'id'" }; + } + if (!Array.isArray(steps)) { + return { ...placeholder, id, error: "Pipeline YAML 'steps' must be an array" }; + } + const stepEntries: PipelineStepEntry[] = steps.map((s, i) => { + const stepObj = (s ?? {}) as { id?: string; type?: string; moduleId?: string }; + const stepId = stepObj.id ?? `step-${i}`; + const stepType = stepObj.type ?? "unknown"; + const moduleId = stepObj.moduleId ?? stepType; + return { + id: stepId, + type: stepType, + moduleId, + hasCustomOverride: customSteps.has(moduleId), + }; + }); + return { id, file, isOverride, steps: stepEntries, error: null }; + } + + const pipelines: PipelineEntry[] = []; + for (const name of listYamls(builtinPipelinesDir)) { + const customSibling = path.join(customPipelinesDir, name); + const useCustom = fs.existsSync(customSibling); + pipelines.push( + useCustom + ? parsePipelineFile(customSibling, `custom/pipelines/${name}`, true) + : parsePipelineFile(path.join(builtinPipelinesDir, name), `pipelines/${name}`, false), + ); + } + for (const name of listYamls(customPipelinesDir)) { + if (fs.existsSync(path.join(builtinPipelinesDir, name))) continue; + pipelines.push( + parsePipelineFile(path.join(customPipelinesDir, name), `custom/pipelines/${name}`, true), + ); + } + pipelines.sort((a, b) => a.file.localeCompare(b.file)); + + const stepEntries: StepModuleEntry[] = Array.from(stepIds) + .sort() + .map((id) => { + const builtinPath = + STEP_EXTS.map((e) => `src/pipeline/steps/${id}${e}`).find((p) => + fs.existsSync(path.join(cwd, p)), + ) ?? null; + const customPath = + STEP_EXTS.map((e) => `custom/steps/${id}${e}`).find((p) => + fs.existsSync(path.join(cwd, p)), + ) ?? null; + return { id, builtinPath, customPath, hasCustomOverride: customPath !== null }; + }); + + return { pipelines, steps: stepEntries }; +} From 97526e441214e0fbd1fea2450125e62aecaddd7e Mon Sep 17 00:00:00 2001 From: Cameron Pope Date: Thu, 30 Apr 2026 14:12:22 -0600 Subject: [PATCH 3/7] feat(admin): add /api/pipelines-steps endpoint Co-Authored-By: Claude Sonnet 4.6 --- src/__tests__/admin.test.ts | 16 ++++++++++++++++ src/admin.ts | 6 ++++++ 2 files changed, 22 insertions(+) diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index 10d6b87..f8da618 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -1124,3 +1124,19 @@ describe("admin customizations endpoint", () => { expect(Array.isArray(body.customizations)).toBe(true); }); }); + +describe("admin pipelines-steps endpoint", () => { + it("returns 401 without auth token", async () => { + const res = await request("/api/pipelines-steps", "GET", "secret"); + expect(res.statusCode).toBe(401); + }); + + it("returns 200 with pipelines and steps arrays", async () => { + const token = await login("secret"); + const res = await request("/api/pipelines-steps", "GET", "secret", undefined, token); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(Array.isArray(body.pipelines)).toBe(true); + expect(Array.isArray(body.steps)).toBe(true); + }); +}); diff --git a/src/admin.ts b/src/admin.ts index 8de3275..4422947 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -33,6 +33,7 @@ import { selectBlockers } from "./poll-selection.js"; import { adminHtml } from "./admin-html.js"; import { getOrchestratorSettings, setOrchestratorSetting } from "./orchestrator-settings.js"; import { listCustomizations } from "./customizations.js"; +import { inspectPipelinesAndSteps } from "./inspect-pipeline-graph.js"; const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -280,6 +281,11 @@ export function handleAdminRequest( return true; } + if (url === "/api/pipelines-steps" && method === "GET") { + json(res, 200, inspectPipelinesAndSteps()); + return true; + } + json(res, 404, { error: "Not found" }); return true; } From 8c56577336f7b46ce53708873ef003ea6458016e Mon Sep 17 00:00:00 2001 From: Cameron Pope Date: Thu, 30 Apr 2026 14:12:57 -0600 Subject: [PATCH 4/7] feat(admin): add pipelines-and-steps page module Co-Authored-By: Claude Sonnet 4.6 --- src/admin-ui/pages/pipelines-and-steps.ts | 135 ++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/admin-ui/pages/pipelines-and-steps.ts diff --git a/src/admin-ui/pages/pipelines-and-steps.ts b/src/admin-ui/pages/pipelines-and-steps.ts new file mode 100644 index 0000000..95390ac --- /dev/null +++ b/src/admin-ui/pages/pipelines-and-steps.ts @@ -0,0 +1,135 @@ +export const pipelinesAndStepsHtml = ` +
+ + +
IdBuilt-inCustom overrideStatus
+ + + + + +`; + +export const pipelinesAndStepsScript = `(function () { + async function loadPipelinesAndSteps() { + const errorEl = document.getElementById('ps-error'); + const pipelinesBody = document.getElementById('ps-pipelines-body'); + const stepsBody = document.getElementById('ps-steps-body'); + const subtitle = document.getElementById('ps-subtitle'); + let data; + try { + data = await window.api('/api/pipelines-steps'); + } catch (err) { + if (errorEl) { errorEl.textContent = String(err); errorEl.hidden = false; } + if (pipelinesBody) pipelinesBody.innerHTML = ''; + if (stepsBody) stepsBody.innerHTML = ''; + if (subtitle) subtitle.textContent = '—'; + const pe = document.getElementById('ps-pipelines-empty'); + const se = document.getElementById('ps-steps-empty'); + if (pe) pe.classList.add('hidden'); + if (se) se.classList.add('hidden'); + return; + } + if (errorEl) { errorEl.textContent = ''; errorEl.hidden = true; } + renderPipelines(data.pipelines || []); + renderSteps(data.steps || []); + renderSubtitle(data.pipelines || [], data.steps || []); + } + + function renderPipelines(pipelines) { + const body = document.getElementById('ps-pipelines-body'); + const emptyEl = document.getElementById('ps-pipelines-empty'); + if (!body) return; + if (!pipelines.length) { + body.innerHTML = ''; + if (emptyEl) emptyEl.classList.remove('hidden'); + return; + } + if (emptyEl) emptyEl.classList.add('hidden'); + body.innerHTML = pipelines.map(function (p) { + const overrideBadge = p.isOverride + ? 'Override' + : ''; + const errorBadge = p.error ? 'Error' : ''; + let inner; + if (p.error) { + inner = '
' + window.esc(p.error) + '
'; + } else { + const stepRows = (p.steps || []).map(function (s) { + const overrideCell = s.hasCustomOverride + ? 'Override' + : ''; + return '' + window.esc(s.id) + '' + window.esc(s.type) + '' + window.esc(s.moduleId) + '' + overrideCell + ''; + }).join(''); + inner = '' + stepRows + '
StepTypeModuleOverride
'; + } + return '
' + window.esc(p.id) + '' + window.esc(p.file) + '' + overrideBadge + errorBadge + '
' + inner + '
'; + }).join(''); + } + + function renderSteps(steps) { + const body = document.getElementById('ps-steps-body'); + const emptyEl = document.getElementById('ps-steps-empty'); + if (!body) return; + if (!steps.length) { + body.innerHTML = ''; + if (emptyEl) emptyEl.classList.remove('hidden'); + return; + } + if (emptyEl) emptyEl.classList.add('hidden'); + body.innerHTML = steps.map(function (s) { + let statusBadge; + if (s.hasCustomOverride) { + statusBadge = 'Override'; + } else if (s.customPath && !s.builtinPath) { + statusBadge = 'Additive'; + } else { + statusBadge = 'Built-in'; + } + const builtinCell = s.builtinPath ? '' + window.esc(s.builtinPath) + '' : '—'; + const customCell = s.customPath ? '' + window.esc(s.customPath) + '' : '—'; + return '' + window.esc(s.id) + '' + builtinCell + '' + customCell + '' + statusBadge + ''; + }).join(''); + } + + function renderSubtitle(pipelines, steps) { + const el = document.getElementById('ps-subtitle'); + if (!el) return; + el.textContent = pipelines.length + ' pipeline' + (pipelines.length === 1 ? '' : 's') + ' · ' + steps.length + ' step module' + (steps.length === 1 ? '' : 's'); + } + + window.loadPipelinesAndSteps = loadPipelinesAndSteps; + + window.registerPage('pipelines', function () { + loadPipelinesAndSteps(); + setInterval(loadPipelinesAndSteps, 60000); + }); +})();`; From aae803c9372877cc9de216e3f8e9d5eac38821ad Mon Sep 17 00:00:00 2001 From: Cameron Pope Date: Thu, 30 Apr 2026 14:13:44 -0600 Subject: [PATCH 5/7] feat(admin): wire pipelines-and-steps page, remove its stub Co-Authored-By: Claude Sonnet 4.6 --- src/admin-ui/index.ts | 4 +++- src/admin-ui/pages/stubs.ts | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/admin-ui/index.ts b/src/admin-ui/index.ts index 4c8ce7e..f607e68 100644 --- a/src/admin-ui/index.ts +++ b/src/admin-ui/index.ts @@ -15,6 +15,7 @@ import { issuesHtml, issuesScript } from "./pages/issues.js"; import { pullsHtml, pullsScript } from "./pages/pulls.js"; import { blockersHtml, blockersScript } from "./pages/blockers.js"; import { customizationsHtml, customizationsScript } from "./pages/customizations.js"; +import { pipelinesAndStepsHtml, pipelinesAndStepsScript } from "./pages/pipelines-and-steps.js"; import { stubsHtml } from "./pages/stubs.js"; import { drawerHtml, drawerScript } from "./drawer.js"; import { stepperHtml, stepperScript } from "./stepper.js"; @@ -45,6 +46,7 @@ const shell = ``; @@ -61,7 +63,7 @@ const body = ` ${shell} ${drawerHtml} ${stepperHtml} - + `; export const adminHtml = head + body; diff --git a/src/admin-ui/pages/stubs.ts b/src/admin-ui/pages/stubs.ts index 15b3dce..29b422c 100644 --- a/src/admin-ui/pages/stubs.ts +++ b/src/admin-ui/pages/stubs.ts @@ -19,7 +19,6 @@ function stubPage(route: string, title: string, subtitle: string, phase: string, } export const stubsHtml = [ - stubPage("pipelines", "Pipelines & steps", "Composable step library + pipeline definitions", "Plan 5", "List + edit pipeline YAMLs, step modules registered in src/pipeline/."), stubPage("models", "Models & providers", "Per-step models, provider failover, runner profiles", "Plan 5", "Configure provider chains and per-step model IDs."), stubPage("channels", "Triggers & channels", "Input triggers + output notifications", "Plan 5", "Linear, webhook, MCP triggers; Slack, Teams, GitHub PR comment channels."), stubPage("policies", "Policies & risk", "Auto-merge thresholds, risk rubric, CI gates", "Plan 5", "Edge vs stable channels, risk dimensions."), From b8e7fe568ee1508539bf2871ad322a7f75f9a01b Mon Sep 17 00:00:00 2001 From: Cameron Pope Date: Thu, 30 Apr 2026 14:14:18 -0600 Subject: [PATCH 6/7] test(admin): structural tests for pipelines-and-steps page module Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/pipelines-and-steps.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/admin-ui/__tests__/pipelines-and-steps.test.ts diff --git a/src/admin-ui/__tests__/pipelines-and-steps.test.ts b/src/admin-ui/__tests__/pipelines-and-steps.test.ts new file mode 100644 index 0000000..1fea6aa --- /dev/null +++ b/src/admin-ui/__tests__/pipelines-and-steps.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { pipelinesAndStepsHtml, pipelinesAndStepsScript } from "../pages/pipelines-and-steps.js"; + +describe("pipelines-and-steps page", () => { + it("declares the expected ids", () => { + for (const id of [ + "ps-subtitle", + "ps-error", + "ps-pipelines-body", + "ps-pipelines-empty", + "ps-steps-body", + "ps-steps-empty", + ]) { + expect(pipelinesAndStepsHtml).toContain(`id="${id}"`); + } + }); + + it("registers route + exposes loadPipelinesAndSteps", () => { + expect(pipelinesAndStepsScript).toContain("window.registerPage('pipelines'"); + expect(pipelinesAndStepsScript).toContain("window.loadPipelinesAndSteps = loadPipelinesAndSteps"); + }); + + it("calls /api/pipelines-steps", () => { + expect(pipelinesAndStepsScript).toContain("/api/pipelines-steps"); + }); + + it("uses window.api/window.esc only", () => { + const stripped = pipelinesAndStepsScript + .replace(/window\.api\(/g, "") + .replace(/window\.esc\(/g, ""); + expect(stripped).not.toMatch(/\bapi\(/); + expect(stripped).not.toMatch(/\besc\(/); + }); + + it("uses const/let, not var", () => { + expect(pipelinesAndStepsScript).not.toMatch(/\bvar\s+\w/); + }); +}); From 020673750df96e2462b21ef2e1cc48967d5dd3ad Mon Sep 17 00:00:00 2001 From: Cameron Pope Date: Thu, 30 Apr 2026 14:19:12 -0600 Subject: [PATCH 7/7] fix(pipeline): hasCustomOverride means shadow, not "custom exists" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A step that exists only under custom/steps/ is additive — there's no built-in for it to shadow. The previous logic flagged every custom step as hasCustomOverride: true, making the "Additive" branch in the Pipelines & Steps page unreachable. Add a 7th unit test covering the additive case. Co-Authored-By: Claude Opus 4.7 --- src/__tests__/inspect-pipeline-graph.test.ts | 10 ++++++++++ src/inspect-pipeline-graph.ts | 9 ++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/__tests__/inspect-pipeline-graph.test.ts b/src/__tests__/inspect-pipeline-graph.test.ts index 3287645..10bcb14 100644 --- a/src/__tests__/inspect-pipeline-graph.test.ts +++ b/src/__tests__/inspect-pipeline-graph.test.ts @@ -77,6 +77,16 @@ describe("inspectPipelinesAndSteps", () => { expect(step!.hasCustomOverride).toBe(true); }); + it("additive step (only custom, no built-in) has hasCustomOverride: false", () => { + writeFile("custom/steps/extra.ts", "export default {}"); + const result = inspectPipelinesAndSteps({ cwd: tmpDir }); + const step = result.steps.find((s) => s.id === "extra"); + expect(step).toBeDefined(); + expect(step!.builtinPath).toBeNull(); + expect(step!.customPath).toBe("custom/steps/extra.ts"); + expect(step!.hasCustomOverride).toBe(false); + }); + it("pipeline step hasCustomOverride cross-references custom steps", () => { writeFile("custom/steps/bar.ts", "export default {}"); writeFile( diff --git a/src/inspect-pipeline-graph.ts b/src/inspect-pipeline-graph.ts index b185d3e..00ccca9 100644 --- a/src/inspect-pipeline-graph.ts +++ b/src/inspect-pipeline-graph.ts @@ -121,7 +121,14 @@ export function inspectPipelinesAndSteps(opts?: { cwd?: string }): { STEP_EXTS.map((e) => `custom/steps/${id}${e}`).find((p) => fs.existsSync(path.join(cwd, p)), ) ?? null; - return { id, builtinPath, customPath, hasCustomOverride: customPath !== null }; + return { + id, + builtinPath, + customPath, + // Override = custom shadows a built-in. A custom-only file is additive, + // not an override. + hasCustomOverride: customPath !== null && builtinPath !== null, + }; }); return { pipelines, steps: stepEntries };