diff --git a/changelog.d/added-workflowmodel-workflows-tab.md b/changelog.d/added-workflowmodel-workflows-tab.md
new file mode 100644
index 0000000000..a0a7f73539
--- /dev/null
+++ b/changelog.d/added-workflowmodel-workflows-tab.md
@@ -0,0 +1 @@
+- Settings gains a **Workflows** tab (RFC phase 3b): a library of every workflow model the hive knows — built-in `hive-default`, locally saved models and drafts ingested from repository pipelines — with provenance and compile badges, bound-repository counts, View / Duplicate / Export / Delete, a read-only detail view that draws the compiled columns and states as an SVG diagram with transitions, diagnostics and citations, and an Ingest form that turns a repository's pipeline files into a draft model through `POST /api/workflows/ingest`. The Activity Monitor model badge deep-links into the library entry. Binding a repository stays on Settings → Repos; this tab never binds ([#17620](https://github.com/hivecommons/hive/issues/17620)).
diff --git a/src/docs/design/workflow-models.md b/src/docs/design/workflow-models.md
index c98e896da6..1cbe3dae53 100644
--- a/src/docs/design/workflow-models.md
+++ b/src/docs/design/workflow-models.md
@@ -155,3 +155,31 @@ result at the next integer version under `name` (default: the repository slug).
`cmd/workflow-survey` now compiles every corpus repository's lifecycle model (`lifecycle.json`
alongside `model.json`) and reports pass/refusal counts by diagnostic code.
+
+## Workflows tab (RFC phase 3b)
+
+Settings → **Workflows** is the spoke's home for models (`static-src/js/config-workflows-library.js`,
+`css/workflows.css`). It has two sub-views and a detail page, all built from `GET /api/workflows`
+and `GET /api/workflows/{name}/{version}`:
+
+- **Library** — one row per `name@version`: provenance badge (`built-in` for the virtual
+ hive-default and any read-only entry, else `meta.provenance`), compile badge (`compiles`,
+ `compiles · N warnings`, `does not compile`, with the diagnostics as the tooltip), source,
+ states / columns, and the repositories bound to it. Built-in rows sort first. Owners get
+ Duplicate (saves a copy under a new name at version 1, provenance `local`) and Delete; Delete is
+ withheld while anything is bound (the API returns 409 anyway) and the row says `bound` instead.
+ Export shows the model JSON in-app (copy to clipboard) for everyone.
+- **Detail** — facts, the diagram, diagnostics, citations and the raw model/profile JSON.
+ `workflowDiagramSvg(model, profile)` is a pure function: the compiled profile's columns are lanes
+ in display order, each state is a chip carrying its padded stage number, transitions are bezier
+ edges (same-lane edges bow to the right, backward edges are styled `back`), inferred states are
+ dashed, the initial state and terminal column are marked. Duplicate and dangling transitions are
+ dropped rather than drawn.
+- **Ingest** — pick a configured repository (or type `owner/repo`), optionally a name (default: the
+ slug), then Preview or Ingest-and-save; both call `POST /api/workflows/ingest`. The result shows
+ the compile outcome, files read, pipelines found, and opens the saved entry.
+
+The tab never binds: binding stays on Settings → Repos so the per-repository decision sits next
+to the repository's other policy. The Activity Monitor's model badge deep-links here via
+`openWorkflowLibraryEntry(name, version)`. Every HTML builder is a pure function of its inputs so
+`workflows_library_ui_test.go` runs them under node without a DOM. Editing is phase 4.
diff --git a/src/pkg/dashboard/static-src/css/workflows.css b/src/pkg/dashboard/static-src/css/workflows.css
new file mode 100644
index 0000000000..8dde110de8
--- /dev/null
+++ b/src/pkg/dashboard/static-src/css/workflows.css
@@ -0,0 +1,48 @@
+ /* Settings → Workflows (#17620 phase 3): library, detail, diagram, ingest */
+ .wf-nav { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-5); border-bottom: var(--line-width) solid var(--border); }
+ .wf-nav-item { background: none; border: none; border-bottom: 2px solid transparent; color: var(--muted); font: inherit; font-size: var(--fs-sm); font-weight: 700; padding: var(--sp-2) var(--sp-4); cursor: pointer; }
+ .wf-nav-item.active { color: var(--text); border-bottom-color: var(--brand); }
+ .wf-table td.wf-num { font-family: var(--font-mono); white-space: nowrap; }
+ .wf-key { color: var(--text); text-decoration: none; font-weight: 700; }
+ .wf-key:hover { text-decoration: underline; }
+ .wf-desc { font-size: var(--fs-xs); margin-top: var(--sp-1); max-width: 36em; }
+ .wf-prov, .wf-compiled { display: inline-block; border-radius: var(--r-pill); padding: var(--sp-1) var(--sp-3); font-size: var(--fs-xs); font-weight: 700; white-space: nowrap; border: var(--line-width) solid var(--border); color: var(--muted); }
+ .wf-prov-built-in { color: var(--status-info); border-color: color-mix(in srgb, var(--status-info) 40%, transparent); background: color-mix(in srgb, var(--status-info) 10%, transparent); }
+ .wf-prov-curated { color: var(--status-ok); border-color: color-mix(in srgb, var(--status-ok) 40%, transparent); background: color-mix(in srgb, var(--status-ok) 10%, transparent); }
+ .wf-prov-ingested-unreviewed { color: var(--status-warn); border-color: color-mix(in srgb, var(--status-warn) 40%, transparent); background: color-mix(in srgb, var(--status-warn) 10%, transparent); }
+ .wf-compiled-ok { color: var(--status-ok); }
+ .wf-compiled-warn { color: var(--status-warn); }
+ .wf-compiled-no { color: var(--status-error); border-color: color-mix(in srgb, var(--status-error) 40%, transparent); }
+ .wf-detail-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4); flex-wrap: wrap; }
+ .wf-detail-head h3 { margin: var(--sp-0); }
+ .wf-detail h4 { margin: var(--sp-6) var(--sp-0) var(--sp-2); font-size: var(--fs-base); }
+ .wf-facts { display: grid; grid-template-columns: max-content 1fr; gap: var(--sp-2) var(--sp-5); margin: var(--sp-4) var(--sp-0); font-size: var(--fs-sm); }
+ .wf-facts dt { color: var(--muted); font-weight: 700; }
+ .wf-facts dd { margin: var(--sp-0); min-width: 0; overflow-wrap: anywhere; }
+ .wf-diagram-wrap { overflow-x: auto; padding: var(--sp-2) var(--sp-0); }
+ .wf-diagram { display: block; max-width: none; font-family: var(--font-ui); }
+ .wf-dg-lane { fill: color-mix(in srgb, var(--panel) 70%, var(--bg)); stroke: var(--border); stroke-width: 1; }
+ .wf-dg-col.terminal .wf-dg-lane { stroke-dasharray: 4 3; }
+ .wf-dg-colname { fill: var(--muted); font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
+ .wf-dg-chip { fill: var(--panel-strong); stroke: var(--line-strong); stroke-width: 1; }
+ .wf-dg-state.initial .wf-dg-chip { stroke: var(--status-info); stroke-width: 1.5; }
+ .wf-dg-state.terminal .wf-dg-chip { stroke-width: 2; }
+ .wf-dg-state.inferred .wf-dg-chip { stroke-dasharray: 4 3; stroke: var(--status-warn); }
+ .wf-dg-label { fill: var(--text); font-size: var(--fs-sm); }
+ .wf-dg-num { fill: var(--muted); font-size: var(--fs-2xs); }
+ .wf-dg-edge { fill: none; stroke: var(--muted); stroke-width: 1.25; opacity: .8; }
+ .wf-dg-edge.back { stroke-dasharray: 3 3; }
+ .wf-dg-edge:hover { stroke: var(--text); stroke-width: 2; opacity: 1; }
+ .wf-dg-arrowhead { fill: var(--muted); }
+ .wf-diags, .wf-citations, .wf-pipelines { margin: var(--sp-0); padding-left: var(--sp-6); font-size: var(--fs-sm); }
+ .wf-diag-code { color: var(--muted); margin-right: var(--sp-2); }
+ .wf-diag-error .wf-diag-code { color: var(--status-error); }
+ .wf-diag-warning .wf-diag-code { color: var(--status-warn); }
+ .wf-json pre { max-height: 24rem; overflow: auto; font-size: var(--fs-xs); background: var(--bg-soft); padding: var(--sp-4); border-radius: var(--r-sm); }
+ .wf-ingest-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: var(--sp-4); align-items: end; }
+ .wf-ingest-form .config-field { margin-bottom: var(--sp-0); }
+ .wf-ingest-form .kc-actions { grid-column: 1 / -1; }
+ .wf-ingest-error { color: var(--status-error); font-size: var(--fs-sm); margin-top: var(--sp-4); }
+ .wf-ingest-result { margin-top: var(--sp-5); border-top: var(--line-width) solid var(--border); padding-top: var(--sp-4); }
+ .wf-ingest-result h4 { margin: var(--sp-5) var(--sp-0) var(--sp-2); font-size: var(--fs-base); }
+ .wf-ingest-summary { font-size: var(--fs-sm); display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
diff --git a/src/pkg/dashboard/static-src/js/config-state.js b/src/pkg/dashboard/static-src/js/config-state.js
index 3c84095974..95017b5b64 100644
--- a/src/pkg/dashboard/static-src/js/config-state.js
+++ b/src/pkg/dashboard/static-src/js/config-state.js
@@ -23,7 +23,7 @@
// which is read-only hub-synced user/role data. It sits next to Model
// Gateways because both are backend configuration.
const GOVERNOR_BOB_TAB = 'Bob';
- const GOVERNOR_CONFIG_TABS = ['General', 'Appearance', 'Dashboard', 'Agents', 'Thresholds', 'Labels', 'Repos', 'Budget', 'Notifications', 'Health', 'Sensing', 'Logging', 'Features', 'Knowledge', 'Project Observability', 'Advisory', 'Work Source', 'Hub', 'Forge App', 'Model Gateways', GOVERNOR_BOB_TAB, 'Variables', 'Security', 'Compliance'];
+ const GOVERNOR_CONFIG_TABS = ['General', 'Appearance', 'Dashboard', 'Agents', 'Thresholds', 'Labels', 'Repos', 'Workflows', 'Budget', 'Notifications', 'Health', 'Sensing', 'Logging', 'Features', 'Knowledge', 'Project Observability', 'Advisory', 'Work Source', 'Hub', 'Forge App', 'Model Gateways', GOVERNOR_BOB_TAB, 'Variables', 'Security', 'Compliance'];
const PIPELINE_STAGES = ['resolve-beads', 'track-prs', 'stale-check', 'repo-scan', 'coverage-gate', 'prompt-compose', 'budget-check', 'api-collect', 'final-compose'];
const PIPELINE_STAGE_TIPS = {
'resolve-beads': 'Fetch and inject knowledge beads (facts, patterns, gotchas) relevant to the agent\'s current task context.',
diff --git a/src/pkg/dashboard/static-src/js/config-tabs.js b/src/pkg/dashboard/static-src/js/config-tabs.js
index 2e98efb8a0..858f7df9df 100644
--- a/src/pkg/dashboard/static-src/js/config-tabs.js
+++ b/src/pkg/dashboard/static-src/js/config-tabs.js
@@ -252,6 +252,7 @@
case 'Thresholds': return renderGovThresholds(d) + renderGovSLATargets(d);
case 'Labels': return renderGovLabels(d.labels || [], d.holdLabels || [], d.requireLabels || []);
case 'Repos': return renderGovRepos(d.repos || []);
+ case 'Workflows': return renderGovWorkflows();
case 'Budget': return renderGovBudget(d.budget || {}, d.health || {});
case 'Notifications': return renderGovNotifications(d.notifications || {});
case 'Health': return renderGovHealth(d.health || {});
diff --git a/src/pkg/dashboard/static-src/js/config-workflows-library.js b/src/pkg/dashboard/static-src/js/config-workflows-library.js
new file mode 100644
index 0000000000..7f97f30781
--- /dev/null
+++ b/src/pkg/dashboard/static-src/js/config-workflows-library.js
@@ -0,0 +1,419 @@
+// Settings → Workflows (hivecommons/hive#17620, RFC phase 3).
+ //
+ // The spoke's home for workflow models: a Library of every stored model
+ // (built-in hive-default, locally saved, ingested), a read-only detail
+ // view with the compiled column/state diagram, diagnostics and bound
+ // repositories, and an Ingest form that turns a repository's pipeline
+ // files into a draft lifecycle model through POST /api/workflows/ingest.
+ // Binding a repository to a model stays on Settings → Repos
+ // (config-workflows.js); this tab never binds. Editing is phase 4.
+ //
+ // All HTML builders are pure functions of their inputs so the node
+ // harness (workflows_library_ui_test.go) can run them without a DOM.
+ const WF_LIB_VIEWS = ['library', 'detail', 'ingest'];
+ const WF_PROVENANCE_LABELS = {
+ 'built-in': 'built-in',
+ 'curated': 'curated',
+ 'local': 'local',
+ 'ingested-unreviewed': 'ingested · unreviewed',
+ };
+ const WF_DIAGRAM = { colW: 172, colGap: 28, chipH: 30, chipGap: 10, headH: 30, pad: 14, textPad: 10, maxLabel: 22 };
+ const _wfLib = { view: 'library', selected: null, detail: null, detailError: '', loadingKey: '', pending: null,
+ ingest: { repo: '', name: '', busy: false, result: null, error: '' } };
+
+ function wfProvenanceOf(entry) {
+ if (!entry) return 'local';
+ if (entry.readOnly || entry.name === 'hive-default') return 'built-in';
+ return entry.provenance || 'local';
+ }
+
+ function wfProvenanceBadge(entry) {
+ const p = wfProvenanceOf(entry);
+ const label = WF_PROVENANCE_LABELS[p] || p;
+ const tips = {
+ 'built-in': 'Generated from the engine\'s stage table; read-only.',
+ 'curated': 'Reviewed and vouched for by a human.',
+ 'local': 'Saved on this hive by an operator.',
+ 'ingested-unreviewed': 'Derived from a repository\'s pipeline files; nobody has reviewed it yet. Inferred states and events compile with warnings.',
+ };
+ return `${esc(label)} `;
+ }
+
+ function wfCompiledBadge(entry) {
+ const diags = Array.isArray(entry.diagnostics) ? entry.diagnostics : [];
+ const warnings = diags.filter(d => d.severity !== 'error').length;
+ if (entry.compiled === false) {
+ return `does not compile `;
+ }
+ if (warnings) return `compiles · ${warnings} warning${warnings === 1 ? '' : 's'} `;
+ return 'compiles ';
+ }
+
+ // Repositories bound to name@version, from the bindings the library
+ // call returned.
+ function wfBoundRepos(bindings, name, version) {
+ return (Array.isArray(bindings) ? bindings : []).filter(b => b && b.name === name && b.version === version).map(b => b.repo);
+ }
+
+ function wfLibNav(view) {
+ const items = [['library', 'Library'], ['ingest', 'Ingest']];
+ return '
' + items.map(([id, label]) =>
+ `${label} `
+ ).join('') + '
';
+ }
+
+ function renderWorkflowLibraryRows(workflows, bindings, owner) {
+ const list = Array.isArray(workflows) ? workflows.slice() : [];
+ list.sort((a, b) => {
+ const pa = wfProvenanceOf(a) === 'built-in' ? 0 : 1, pb = wfProvenanceOf(b) === 'built-in' ? 0 : 1;
+ if (pa !== pb) return pa - pb;
+ return (a.name + '@' + a.version).localeCompare(b.name + '@' + b.version);
+ });
+ if (!list.length) return 'No workflow models stored yet. Ingest one from a repository, or wait for hive-default to register. ';
+ return list.map(e => {
+ const key = e.name + '@' + e.version;
+ const bound = wfBoundRepos(bindings, e.name, e.version);
+ const builtin = wfProvenanceOf(e) === 'built-in';
+ const canDelete = owner && !builtin && !e.readOnly && !bound.length;
+ const actions = [
+ `View `,
+ owner ? `Duplicate ` : '',
+ `Export `,
+ canDelete ? `Delete `
+ : (owner && !builtin && bound.length ? `bound ` : ''),
+ ].join('');
+ return `` +
+ `${esc(key)} ${e.description ? `${esc(e.description)}
` : ''} ` +
+ `${wfProvenanceBadge(e)} ` +
+ `${esc(e.source || '')} ` +
+ `${wfCompiledBadge(e)} ` +
+ `${esc(String(e.states || 0))} / ${esc(String(e.columns || 0))} ` +
+ `${bound.length ? `${bound.length} repo${bound.length === 1 ? '' : 's'} ` : '— '} ` +
+ `${actions} `;
+ }).join('');
+ }
+
+ function renderWorkflowLibraryTable(workflows, bindings, owner) {
+ return '' +
+ '
Workflow library ' +
+ '
Every workflow model this hive knows: the built-in hive-default lifecycle, models saved here, and drafts ingested from repository pipelines. A repository follows a model only once it is bound on Settings → Repos; binding changes the Activity Monitor\'s columns for that repository and, when ' + yamlHint('workflow.enabled') + ' is on, how its stages are derived.
' +
+ '
Model Provenance Source Compile S / C Bound ' +
+ '' + renderWorkflowLibraryRows(workflows, bindings, owner) + '
' +
+ '
';
+ }
+
+ function wfTrim(label, max) {
+ const s = String(label || '');
+ return s.length > max ? s.slice(0, max - 1) + '…' : s;
+ }
+
+ // Column/state diagram as inline SVG, laid out from the compiled
+ // profile's columns (display order) and the model's transitions. Pure.
+ function workflowDiagramSvg(model, profile) {
+ const spec = (model && model.spec) || {};
+ const cols = (profile && Array.isArray(profile.columns) && profile.columns.length) ? profile.columns : (spec.columns || []);
+ if (!cols.length) return 'This model declares no columns, so there is nothing to draw.
';
+ const D = WF_DIAGRAM;
+ const states = {};
+ (function walk(list) { (list || []).forEach(s => { states[s.id] = s; walk(s.regions); }); })(spec.states);
+ const pos = {};
+ let maxRows = 1;
+ cols.forEach((c, ci) => {
+ const ids = Array.isArray(c.states) ? c.states : [];
+ maxRows = Math.max(maxRows, ids.length);
+ ids.forEach((id, ri) => {
+ pos[id] = { x: D.pad + ci * (D.colW + D.colGap), y: D.pad + D.headH + ri * (D.chipH + D.chipGap), col: ci };
+ });
+ });
+ const width = D.pad * 2 + cols.length * D.colW + (cols.length - 1) * D.colGap;
+ const height = D.pad * 2 + D.headH + maxRows * (D.chipH + D.chipGap);
+ const out = [``];
+ out.push(' ');
+ cols.forEach((c, ci) => {
+ const x = D.pad + ci * (D.colW + D.colGap);
+ out.push(` ` +
+ `${esc(wfTrim(c.label || c.id, D.maxLabel))} `);
+ });
+ const edges = Array.isArray(spec.transitions) ? spec.transitions : [];
+ const seen = {};
+ edges.forEach(t => {
+ const a = pos[t.from], b = pos[t.to];
+ if (!a || !b || t.from === t.to) return;
+ const k = t.from + '>' + t.to;
+ if (seen[k]) return;
+ seen[k] = true;
+ const title = esc((t.on || '') + (t.note ? ' — ' + t.note : ''));
+ let d;
+ if (a.col === b.col) {
+ // Same lane: bow out to the right of the chips.
+ const x = a.x + D.colW, y1 = a.y + D.chipH / 2, y2 = b.y + D.chipH / 2, bow = D.colGap * 0.6;
+ d = `M${x} ${y1} C${x + bow} ${y1}, ${x + bow} ${y2}, ${x} ${y2}`;
+ } else {
+ const fwd = b.col > a.col;
+ const x1 = fwd ? a.x + D.colW : a.x, x2 = fwd ? b.x : b.x + D.colW;
+ const y1 = a.y + D.chipH / 2, y2 = b.y + D.chipH / 2, dx = (x2 - x1) / 2;
+ d = `M${x1} ${y1} C${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
+ }
+ out.push(`${title} `);
+ });
+ cols.forEach(c => (c.states || []).forEach(id => {
+ const p = pos[id], s = states[id] || { id: id, label: id };
+ const lc = s.lifecycle || {};
+ const inferred = lc.confidence === 'inferred';
+ const num = lc.number ? String(lc.number).padStart(3, '0') : '';
+ const cls = 'wf-dg-state' + (inferred ? ' inferred' : '') + (spec.initial === id ? ' initial' : '') + (profile && profile.terminal && profile.terminal[id] ? ' terminal' : '');
+ const tip = esc(s.label || id) + (num ? ' · stage/' + num : '') + (inferred ? ' · inferred' : '') + (lc.column ? '\ncolumn ' + esc(lc.column) : '');
+ out.push(`${tip} ` +
+ ` ` +
+ (num ? `${num} ` : '') +
+ `${esc(wfTrim(s.label || id, num ? D.maxLabel - 5 : D.maxLabel))} `);
+ }));
+ out.push(' ');
+ return out.join('');
+ }
+
+ function renderWorkflowDiagnostics(diags) {
+ const list = Array.isArray(diags) ? diags : [];
+ if (!list.length) return 'No diagnostics: the model compiles clean.
';
+ return '' + list.map(d =>
+ `${esc(d.code || '')} ${esc(d.message || '')}${d.path ? ` ${esc(d.path)} ` : ''} `
+ ).join('') + ' ';
+ }
+
+ function renderWorkflowDetail(detail, bindings, owner) {
+ const e = detail.entry || {}, m = detail.model || {}, p = detail.profile || null;
+ const key = e.name + '@' + e.version;
+ const bound = wfBoundRepos(bindings, e.name, e.version);
+ const meta = (m.spec && m.spec.meta) || {};
+ const sources = (m.spec && Array.isArray(m.spec.sources)) ? m.spec.sources : [];
+ const citations = [];
+ (function walk(list) { (list || []).forEach(s => { ((s.lifecycle || {}).citations || []).forEach(c => citations.push(c)); walk(s.regions); }); })(m.spec && m.spec.states);
+ const builtin = wfProvenanceOf(e) === 'built-in';
+ const facts = [
+ ['Provenance', wfProvenanceBadge(e)],
+ ['Source', esc(e.source || meta.source || '—')],
+ ['Derivation', esc((m.spec && m.spec.derivation && m.spec.derivation.mode) || (p && p.mode) || '—')],
+ ['Compile', wfCompiledBadge(e)],
+ ['States / columns', esc(String(e.states || 0)) + ' / ' + esc(String(e.columns || 0))],
+ ['Hash', `${esc(String(e.hash || '').slice(0, 12))} `],
+ ['Saved', e.savedAt && e.savedAt !== '0001-01-01T00:00:00Z' ? esc(formatDashboardDateTime(e.savedAt)) + (e.savedBy ? ' by ' + esc(e.savedBy) : '') : '—'],
+ ['Bound repositories', bound.length ? bound.map(r => `${esc(r)} `).join(', ') : 'none — bind on Settings → Repos '],
+ ];
+ if (sources.length) facts.push(['Ingested from', sources.map(s => `${esc(s.repo || s.ref || s.path || JSON.stringify(s))} `).join(', ')]);
+ const actions = [
+ '← Library ',
+ owner ? `Duplicate ` : '',
+ `Export JSON `,
+ owner && !builtin && !bound.length ? `Delete ` : '',
+ ].join('');
+ return '' +
+ `
` +
+ (e.description ? `
${esc(e.description)}
` : '') +
+ '
' + facts.map(([k, v]) => `${k} ${v} `).join('') + ' ' +
+ '
Columns and states ' +
+ '
Left to right as the Activity Monitor draws them. Arrows are the model\'s transitions; hover one for its event. Dashed states were inferred by ingest and have not been confirmed by a human.
' +
+ '
' + workflowDiagramSvg(m, p) + '
' +
+ '
Diagnostics ' + renderWorkflowDiagnostics(e.diagnostics || (p && p.diagnostics)) +
+ (citations.length ? '
Citations ' + citations.slice(0, 40).map(c => `${esc(c.path || '')}${c.line ? ':' + esc(String(c.line)) : ''}${c.note ? ` ${esc(c.note)} ` : ''} `).join('') + (citations.length > 40 ? `… ${citations.length - 40} more ` : '') + ' ' : '') +
+ '
Model JSON ' + esc(JSON.stringify(m, null, 2)) + ' ' +
+ '
';
+ }
+
+ function wfRepoChoices() {
+ const d = (_configState && _configState.data) || {};
+ const org = String(d.org || '').trim();
+ const repos = Array.isArray(d.repos) ? d.repos : [];
+ return repos.map(r => {
+ const name = typeof r === 'string' ? r : (r && (r.name || r.repo)) || '';
+ if (!name) return '';
+ return name.indexOf('/') >= 0 || !org ? name : org + '/' + name;
+ }).filter(Boolean);
+ }
+
+ function renderWorkflowIngest(state, owner) {
+ const st = state || _wfLib.ingest;
+ const choices = wfRepoChoices();
+ const opts = ['— choose a watched repository — '].concat(choices.map(r => `${esc(r)} `)).join('');
+ const r = st.result;
+ let result = '';
+ if (st.error) result = `${esc(st.error)}
`;
+ else if (r) {
+ const e = r.entry || null;
+ const cols = (r.profile && r.profile.columns) || (r.model && r.model.spec && r.model.spec.columns) || [];
+ const pipelines = Array.isArray(r.pipelines) ? r.pipelines : [];
+ result = '' +
+ `
${r.compiled ? '
compiles ' : '
does not compile '}` +
+ ` · ${esc(String(r.files || 0))} pipeline file${r.files === 1 ? '' : 's'} · ${pipelines.length} pipeline${pipelines.length === 1 ? '' : 's'} · ${cols.length} column${cols.length === 1 ? '' : 's'}` +
+ (r.ref ? ` ·
${esc(String(r.ref).slice(0, 12))} ` : '') +
+ (e ? ` · saved as
${esc(e.name + '@' + e.version)} ` : '') +
+ '
' +
+ (pipelines.length ? '
' + pipelines.map(p => `${esc(p.system || p.kind || '')} ${esc(p.name || p.id || '')}${p.path ? ` ${esc(p.path)} ` : ''} `).join('') + ' ' : '') +
+ '
Diagnostics ' + renderWorkflowDiagnostics(r.diagnostics) +
+ '
Proposed columns ' + workflowDiagramSvg(r.model, r.profile) + '
' +
+ '
';
+ }
+ const disabled = !owner || st.busy ? 'disabled' : '';
+ return '' +
+ '
Ingest a repository\'s pipelines ' +
+ '
Reads the repository\'s GitHub Actions workflows and Tekton pipelines from its default branch and proposes a lifecycle model: hive-default\'s review flow with the repository\'s PR-verify pipelines bound in, plus post-merge states for its main-branch CI, release and deploy pipelines. Everything inferred is marked inferred and the model is saved with provenance ingested · unreviewed . Nothing is bound until you choose to on Settings → Repos.
' +
+ (owner ? '' : '
Owner role is required to ingest.
') +
+ '
' + result + '
';
+ }
+
+ function wfIngestDefaultName(repo) {
+ const r = String(repo || '').trim().toLowerCase();
+ if (!r) return '';
+ return r.replace(/[^a-z0-9/._-]+/g, '-').replace(/\//g, '-').replace(/^-+|-+$/g, '');
+ }
+
+ function renderGovWorkflows() {
+ const owner = dashboardRoleAtLeast(window._hiveRole || 'read', 'owner');
+ if (!_workflowState.loaded) {
+ loadWorkflowLibrary(() => { if (_configState.tab === 'Workflows') renderConfigTab('Workflows'); });
+ return wfLibNav(_wfLib.view) + 'Loading the workflow library…
';
+ }
+ if (_wfLib.pending) {
+ const p = _wfLib.pending; _wfLib.pending = null;
+ wfLibOpen(p.name, p.version);
+ }
+ let body;
+ if (_wfLib.view === 'detail') {
+ if (_wfLib.detailError) body = `← Library ${esc(_wfLib.detailError)}
`;
+ else if (!_wfLib.detail) body = 'Loading ' + esc(_wfLib.loadingKey || 'model') + '…
';
+ else body = renderWorkflowDetail(_wfLib.detail, _workflowState.bindings, owner);
+ } else if (_wfLib.view === 'ingest') {
+ body = renderWorkflowIngest(_wfLib.ingest, owner);
+ } else {
+ body = renderWorkflowLibraryTable(_workflowState.workflows, _workflowState.bindings, owner);
+ }
+ return wfLibNav(_wfLib.view) + body;
+ }
+
+ function wfLibRerender() { if (_configState && _configState.tab === 'Workflows') renderConfigTab('Workflows'); }
+
+ function wfLibShow(view) {
+ _wfLib.view = WF_LIB_VIEWS.indexOf(view) >= 0 ? view : 'library';
+ if (_wfLib.view !== 'detail') { _wfLib.detail = null; _wfLib.detailError = ''; }
+ wfLibRerender();
+ }
+
+ async function wfLibOpen(name, version) {
+ const key = name + '@' + version;
+ _wfLib.view = 'detail'; _wfLib.detail = null; _wfLib.detailError = ''; _wfLib.loadingKey = key; _wfLib.selected = key;
+ wfLibRerender();
+ try {
+ const res = await fetch('/api/workflows/' + encodeURIComponent(name) + '/' + encodeURIComponent(version), { headers: _inceptionAuthHeaders() });
+ if (!res.ok) throw new Error(await saveErrorMessage(res));
+ const data = await res.json();
+ if (_wfLib.loadingKey !== key) return;
+ _wfLib.detail = data;
+ } catch (err) {
+ if (_wfLib.loadingKey !== key) return;
+ _wfLib.detailError = 'Could not load ' + key + ': ' + err.message;
+ }
+ wfLibRerender();
+ }
+
+ // Opens Settings → Workflows on a model; the Activity Monitor badge and
+ // the Repos binding row deep-link here.
+ function openWorkflowLibraryEntry(name, version) {
+ _wfLib.pending = { name: name, version: version };
+ if (typeof _configModalOpen !== 'undefined' && _configModalOpen && _configState.type === 'governor') {
+ switchConfigTab('Workflows');
+ return;
+ }
+ openConfigDialog('governor', null, 'Workflows');
+ }
+
+ async function wfLibReload() {
+ _workflowState.loaded = false;
+ await loadWorkflowLibrary();
+ wfLibRerender();
+ }
+
+ async function wfLibDuplicate(name, version) {
+ if (!dashboardRoleAtLeast(window._hiveRole || 'read', 'owner')) { showToast('Owner role is required to save a workflow.', 'error'); return; }
+ const newName = await hivePrompt({ title: 'Duplicate ' + name + '@' + version, message: 'Name for the copy (lower-case, digits, dots, dashes). It is saved as version 1 with provenance local; the original is untouched.', defaultValue: name === 'hive-default' ? 'my-lifecycle' : name + '-copy', confirmLabel: 'Duplicate' });
+ if (!newName) return;
+ const target = String(newName).trim().toLowerCase();
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(target)) { showToast('Name must be lower-case letters, digits, dots, dashes or underscores.', 'error'); return; }
+ try {
+ const res = await fetch('/api/workflows/' + encodeURIComponent(name) + '/' + encodeURIComponent(version), { headers: _inceptionAuthHeaders() });
+ if (!res.ok) throw new Error(await saveErrorMessage(res));
+ const data = await res.json();
+ const m = data.model || {};
+ m.metadata = Object.assign({}, m.metadata, { name: target });
+ m.spec = Object.assign({}, m.spec);
+ m.spec.meta = Object.assign({}, m.spec.meta, { version: '1', provenance: 'local', description: (m.spec.meta && m.spec.meta.description ? m.spec.meta.description + ' ' : '') + '(copy of ' + name + '@' + version + ')' });
+ const save = await fetch('/api/workflows', { method: 'POST', headers: _inceptionAuthHeaders(), body: JSON.stringify(m) });
+ if (!save.ok) throw new Error(await saveErrorMessage(save));
+ showToast('Saved ' + target + '@1');
+ await wfLibReload();
+ wfLibOpen(target, '1');
+ } catch (err) {
+ showToast('Duplicate failed: ' + err.message, 'error');
+ }
+ }
+
+ async function wfLibDelete(name, version) {
+ if (!dashboardRoleAtLeast(window._hiveRole || 'read', 'owner')) { showToast('Owner role is required to delete a workflow.', 'error'); return; }
+ const ok = await hiveConfirm({ title: 'Delete ' + name + '@' + version + '?', message: 'The model file is removed from this hive. Nothing happens to repositories: a bound model cannot be deleted, so none follows it.', confirmLabel: 'Delete', danger: true });
+ if (!ok) return;
+ try {
+ const res = await fetch('/api/workflows/' + encodeURIComponent(name) + '/' + encodeURIComponent(version), { method: 'DELETE', headers: _inceptionAuthHeaders() });
+ if (!res.ok) throw new Error(await saveErrorMessage(res));
+ showToast('Deleted ' + name + '@' + version);
+ _wfLib.view = 'library'; _wfLib.detail = null;
+ await wfLibReload();
+ } catch (err) {
+ showToast('Delete failed: ' + err.message, 'error');
+ }
+ }
+
+ async function wfLibExport(name, version) {
+ try {
+ const res = await fetch('/api/workflows/' + encodeURIComponent(name) + '/' + encodeURIComponent(version), { headers: _inceptionAuthHeaders() });
+ if (!res.ok) throw new Error(await saveErrorMessage(res));
+ const data = await res.json();
+ hiveExportViewer({ title: name + '@' + version + '.workflow.json', text: JSON.stringify(data.model || {}, null, 2), mime: 'application/json' });
+ } catch (err) {
+ showToast('Export failed: ' + err.message, 'error');
+ }
+ }
+
+ function wfIngestPickRepo(value) { _wfLib.ingest.repo = String(value || ''); _wfLib.ingest.result = null; _wfLib.ingest.error = ''; wfLibRerender(); }
+ function wfIngestSetRepo(value) { _wfLib.ingest.repo = String(value || ''); }
+ function wfIngestSetName(value) { _wfLib.ingest.name = String(value || ''); }
+
+ async function wfIngestRun(save) {
+ if (!dashboardRoleAtLeast(window._hiveRole || 'read', 'owner')) { showToast('Owner role is required to ingest.', 'error'); return; }
+ const st = _wfLib.ingest;
+ const repoEl = document.getElementById('wf-ingest-repo'), nameEl = document.getElementById('wf-ingest-name');
+ if (repoEl) st.repo = repoEl.value.trim();
+ if (nameEl) st.name = nameEl.value.trim();
+ if (!/^[^/\s]+\/[^/\s]+$/.test(st.repo)) { st.error = 'Enter a repository as owner/repo.'; st.result = null; wfLibRerender(); return; }
+ st.busy = true; st.error = ''; st.result = null; wfLibRerender();
+ try {
+ const body = { repo: st.repo, save: !!save };
+ if (st.name) body.name = st.name;
+ const res = await fetch('/api/workflows/ingest', { method: 'POST', headers: _inceptionAuthHeaders(), body: JSON.stringify(body) });
+ if (!res.ok) throw new Error(await saveErrorMessage(res));
+ st.result = await res.json();
+ if (save && st.result.entry) {
+ showToast('Saved ' + st.result.entry.name + '@' + st.result.entry.version);
+ _workflowState.loaded = false;
+ await loadWorkflowLibrary();
+ }
+ } catch (err) {
+ st.error = 'Ingest failed: ' + err.message;
+ } finally {
+ st.busy = false;
+ wfLibRerender();
+ }
+ }
diff --git a/src/pkg/dashboard/static-src/js/tracker-workflows.js b/src/pkg/dashboard/static-src/js/tracker-workflows.js
index df16e47b27..c472c85d30 100644
--- a/src/pkg/dashboard/static-src/js/tracker-workflows.js
+++ b/src/pkg/dashboard/static-src/js/tracker-workflows.js
@@ -41,7 +41,7 @@
function stageTrackerWorkflowBadgeHtml(wf) {
if (!wf) return '';
const key = `${wf.name}@${wf.version}`;
- return `${esc(key)} `;
+ return `${esc(key)} `;
}
// The model's column header, drawn under a bound repository's group
diff --git a/src/pkg/dashboard/static-src/manifest.txt b/src/pkg/dashboard/static-src/manifest.txt
index 2d894fd79b..3475de6ec9 100644
--- a/src/pkg/dashboard/static-src/manifest.txt
+++ b/src/pkg/dashboard/static-src/manifest.txt
@@ -22,6 +22,7 @@ css/cost.css
css/alerts-toasts.css
css/feedback-faq.css
css/config-dialog.css
+css/workflows.css
css/chat.css
css/nous.css
css/knowledge.css
@@ -135,6 +136,7 @@ js/config-hub.js
js/config-agent.js
js/config-governor.js
js/config-workflows.js
+js/config-workflows-library.js
js/config-watchdog-security.js
js/config-features.js
js/integration-execution-settings.js
diff --git a/src/pkg/dashboard/static/index.html b/src/pkg/dashboard/static/index.html
index 6df9a5d84e..cb0f7c2a1a 100644
--- a/src/pkg/dashboard/static/index.html
+++ b/src/pkg/dashboard/static/index.html
@@ -5045,6 +5045,54 @@
.config-body { --config-body-pad: 14px; padding: var(--sp-0) var(--config-body-pad) var(--config-body-pad); }
.config-field-row, .config-field-row4 { grid-template-columns: 1fr; }
}
+ /* Settings → Workflows (#17620 phase 3): library, detail, diagram, ingest */
+ .wf-nav { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-5); border-bottom: var(--line-width) solid var(--border); }
+ .wf-nav-item { background: none; border: none; border-bottom: 2px solid transparent; color: var(--muted); font: inherit; font-size: var(--fs-sm); font-weight: 700; padding: var(--sp-2) var(--sp-4); cursor: pointer; }
+ .wf-nav-item.active { color: var(--text); border-bottom-color: var(--brand); }
+ .wf-table td.wf-num { font-family: var(--font-mono); white-space: nowrap; }
+ .wf-key { color: var(--text); text-decoration: none; font-weight: 700; }
+ .wf-key:hover { text-decoration: underline; }
+ .wf-desc { font-size: var(--fs-xs); margin-top: var(--sp-1); max-width: 36em; }
+ .wf-prov, .wf-compiled { display: inline-block; border-radius: var(--r-pill); padding: var(--sp-1) var(--sp-3); font-size: var(--fs-xs); font-weight: 700; white-space: nowrap; border: var(--line-width) solid var(--border); color: var(--muted); }
+ .wf-prov-built-in { color: var(--status-info); border-color: color-mix(in srgb, var(--status-info) 40%, transparent); background: color-mix(in srgb, var(--status-info) 10%, transparent); }
+ .wf-prov-curated { color: var(--status-ok); border-color: color-mix(in srgb, var(--status-ok) 40%, transparent); background: color-mix(in srgb, var(--status-ok) 10%, transparent); }
+ .wf-prov-ingested-unreviewed { color: var(--status-warn); border-color: color-mix(in srgb, var(--status-warn) 40%, transparent); background: color-mix(in srgb, var(--status-warn) 10%, transparent); }
+ .wf-compiled-ok { color: var(--status-ok); }
+ .wf-compiled-warn { color: var(--status-warn); }
+ .wf-compiled-no { color: var(--status-error); border-color: color-mix(in srgb, var(--status-error) 40%, transparent); }
+ .wf-detail-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4); flex-wrap: wrap; }
+ .wf-detail-head h3 { margin: var(--sp-0); }
+ .wf-detail h4 { margin: var(--sp-6) var(--sp-0) var(--sp-2); font-size: var(--fs-base); }
+ .wf-facts { display: grid; grid-template-columns: max-content 1fr; gap: var(--sp-2) var(--sp-5); margin: var(--sp-4) var(--sp-0); font-size: var(--fs-sm); }
+ .wf-facts dt { color: var(--muted); font-weight: 700; }
+ .wf-facts dd { margin: var(--sp-0); min-width: 0; overflow-wrap: anywhere; }
+ .wf-diagram-wrap { overflow-x: auto; padding: var(--sp-2) var(--sp-0); }
+ .wf-diagram { display: block; max-width: none; font-family: var(--font-ui); }
+ .wf-dg-lane { fill: color-mix(in srgb, var(--panel) 70%, var(--bg)); stroke: var(--border); stroke-width: 1; }
+ .wf-dg-col.terminal .wf-dg-lane { stroke-dasharray: 4 3; }
+ .wf-dg-colname { fill: var(--muted); font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
+ .wf-dg-chip { fill: var(--panel-strong); stroke: var(--line-strong); stroke-width: 1; }
+ .wf-dg-state.initial .wf-dg-chip { stroke: var(--status-info); stroke-width: 1.5; }
+ .wf-dg-state.terminal .wf-dg-chip { stroke-width: 2; }
+ .wf-dg-state.inferred .wf-dg-chip { stroke-dasharray: 4 3; stroke: var(--status-warn); }
+ .wf-dg-label { fill: var(--text); font-size: var(--fs-sm); }
+ .wf-dg-num { fill: var(--muted); font-size: var(--fs-2xs); }
+ .wf-dg-edge { fill: none; stroke: var(--muted); stroke-width: 1.25; opacity: .8; }
+ .wf-dg-edge.back { stroke-dasharray: 3 3; }
+ .wf-dg-edge:hover { stroke: var(--text); stroke-width: 2; opacity: 1; }
+ .wf-dg-arrowhead { fill: var(--muted); }
+ .wf-diags, .wf-citations, .wf-pipelines { margin: var(--sp-0); padding-left: var(--sp-6); font-size: var(--fs-sm); }
+ .wf-diag-code { color: var(--muted); margin-right: var(--sp-2); }
+ .wf-diag-error .wf-diag-code { color: var(--status-error); }
+ .wf-diag-warning .wf-diag-code { color: var(--status-warn); }
+ .wf-json pre { max-height: 24rem; overflow: auto; font-size: var(--fs-xs); background: var(--bg-soft); padding: var(--sp-4); border-radius: var(--r-sm); }
+ .wf-ingest-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: var(--sp-4); align-items: end; }
+ .wf-ingest-form .config-field { margin-bottom: var(--sp-0); }
+ .wf-ingest-form .kc-actions { grid-column: 1 / -1; }
+ .wf-ingest-error { color: var(--status-error); font-size: var(--fs-sm); margin-top: var(--sp-4); }
+ .wf-ingest-result { margin-top: var(--sp-5); border-top: var(--line-width) solid var(--border); padding-top: var(--sp-4); }
+ .wf-ingest-result h4 { margin: var(--sp-5) var(--sp-0) var(--sp-2); font-size: var(--fs-base); }
+ .wf-ingest-summary { font-size: var(--fs-sm); display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
/* Hive Chat Panel */
.hive-chat-fab {
position: fixed; bottom: 24px; right: 24px; z-index: 9000;
@@ -19590,7 +19638,7 @@ Where to get help
function stageTrackerWorkflowBadgeHtml(wf) {
if (!wf) return '';
const key = `${wf.name}@${wf.version}`;
- return `${esc(key)} `;
+ return `${esc(key)} `;
}
// The model's column header, drawn under a bound repository's group
@@ -54445,7 +54493,7 @@ Configure LiteLLM
// which is read-only hub-synced user/role data. It sits next to Model
// Gateways because both are backend configuration.
const GOVERNOR_BOB_TAB = 'Bob';
- const GOVERNOR_CONFIG_TABS = ['General', 'Appearance', 'Dashboard', 'Agents', 'Thresholds', 'Labels', 'Repos', 'Budget', 'Notifications', 'Health', 'Sensing', 'Logging', 'Features', 'Knowledge', 'Project Observability', 'Advisory', 'Work Source', 'Hub', 'Forge App', 'Model Gateways', GOVERNOR_BOB_TAB, 'Variables', 'Security', 'Compliance'];
+ const GOVERNOR_CONFIG_TABS = ['General', 'Appearance', 'Dashboard', 'Agents', 'Thresholds', 'Labels', 'Repos', 'Workflows', 'Budget', 'Notifications', 'Health', 'Sensing', 'Logging', 'Features', 'Knowledge', 'Project Observability', 'Advisory', 'Work Source', 'Hub', 'Forge App', 'Model Gateways', GOVERNOR_BOB_TAB, 'Variables', 'Security', 'Compliance'];
const PIPELINE_STAGES = ['resolve-beads', 'track-prs', 'stale-check', 'repo-scan', 'coverage-gate', 'prompt-compose', 'budget-check', 'api-collect', 'final-compose'];
const PIPELINE_STAGE_TIPS = {
'resolve-beads': 'Fetch and inject knowledge beads (facts, patterns, gotchas) relevant to the agent\'s current task context.',
@@ -55791,6 +55839,7 @@ Configure LiteLLM
case 'Thresholds': return renderGovThresholds(d) + renderGovSLATargets(d);
case 'Labels': return renderGovLabels(d.labels || [], d.holdLabels || [], d.requireLabels || []);
case 'Repos': return renderGovRepos(d.repos || []);
+ case 'Workflows': return renderGovWorkflows();
case 'Budget': return renderGovBudget(d.budget || {}, d.health || {});
case 'Notifications': return renderGovNotifications(d.notifications || {});
case 'Health': return renderGovHealth(d.health || {});
@@ -62514,6 +62563,425 @@ Agent Watchdog
}
renderConfigTab('Repos');
}
+// Settings → Workflows (hivecommons/hive#17620, RFC phase 3).
+ //
+ // The spoke's home for workflow models: a Library of every stored model
+ // (built-in hive-default, locally saved, ingested), a read-only detail
+ // view with the compiled column/state diagram, diagnostics and bound
+ // repositories, and an Ingest form that turns a repository's pipeline
+ // files into a draft lifecycle model through POST /api/workflows/ingest.
+ // Binding a repository to a model stays on Settings → Repos
+ // (config-workflows.js); this tab never binds. Editing is phase 4.
+ //
+ // All HTML builders are pure functions of their inputs so the node
+ // harness (workflows_library_ui_test.go) can run them without a DOM.
+ const WF_LIB_VIEWS = ['library', 'detail', 'ingest'];
+ const WF_PROVENANCE_LABELS = {
+ 'built-in': 'built-in',
+ 'curated': 'curated',
+ 'local': 'local',
+ 'ingested-unreviewed': 'ingested · unreviewed',
+ };
+ const WF_DIAGRAM = { colW: 172, colGap: 28, chipH: 30, chipGap: 10, headH: 30, pad: 14, textPad: 10, maxLabel: 22 };
+ const _wfLib = { view: 'library', selected: null, detail: null, detailError: '', loadingKey: '', pending: null,
+ ingest: { repo: '', name: '', busy: false, result: null, error: '' } };
+
+ function wfProvenanceOf(entry) {
+ if (!entry) return 'local';
+ if (entry.readOnly || entry.name === 'hive-default') return 'built-in';
+ return entry.provenance || 'local';
+ }
+
+ function wfProvenanceBadge(entry) {
+ const p = wfProvenanceOf(entry);
+ const label = WF_PROVENANCE_LABELS[p] || p;
+ const tips = {
+ 'built-in': 'Generated from the engine\'s stage table; read-only.',
+ 'curated': 'Reviewed and vouched for by a human.',
+ 'local': 'Saved on this hive by an operator.',
+ 'ingested-unreviewed': 'Derived from a repository\'s pipeline files; nobody has reviewed it yet. Inferred states and events compile with warnings.',
+ };
+ return `${esc(label)} `;
+ }
+
+ function wfCompiledBadge(entry) {
+ const diags = Array.isArray(entry.diagnostics) ? entry.diagnostics : [];
+ const warnings = diags.filter(d => d.severity !== 'error').length;
+ if (entry.compiled === false) {
+ return `does not compile `;
+ }
+ if (warnings) return `compiles · ${warnings} warning${warnings === 1 ? '' : 's'} `;
+ return 'compiles ';
+ }
+
+ // Repositories bound to name@version, from the bindings the library
+ // call returned.
+ function wfBoundRepos(bindings, name, version) {
+ return (Array.isArray(bindings) ? bindings : []).filter(b => b && b.name === name && b.version === version).map(b => b.repo);
+ }
+
+ function wfLibNav(view) {
+ const items = [['library', 'Library'], ['ingest', 'Ingest']];
+ return '' + items.map(([id, label]) =>
+ `${label} `
+ ).join('') + '
';
+ }
+
+ function renderWorkflowLibraryRows(workflows, bindings, owner) {
+ const list = Array.isArray(workflows) ? workflows.slice() : [];
+ list.sort((a, b) => {
+ const pa = wfProvenanceOf(a) === 'built-in' ? 0 : 1, pb = wfProvenanceOf(b) === 'built-in' ? 0 : 1;
+ if (pa !== pb) return pa - pb;
+ return (a.name + '@' + a.version).localeCompare(b.name + '@' + b.version);
+ });
+ if (!list.length) return 'No workflow models stored yet. Ingest one from a repository, or wait for hive-default to register. ';
+ return list.map(e => {
+ const key = e.name + '@' + e.version;
+ const bound = wfBoundRepos(bindings, e.name, e.version);
+ const builtin = wfProvenanceOf(e) === 'built-in';
+ const canDelete = owner && !builtin && !e.readOnly && !bound.length;
+ const actions = [
+ `View `,
+ owner ? `Duplicate ` : '',
+ `Export `,
+ canDelete ? `Delete `
+ : (owner && !builtin && bound.length ? `bound ` : ''),
+ ].join('');
+ return `` +
+ `${esc(key)} ${e.description ? `${esc(e.description)}
` : ''} ` +
+ `${wfProvenanceBadge(e)} ` +
+ `${esc(e.source || '')} ` +
+ `${wfCompiledBadge(e)} ` +
+ `${esc(String(e.states || 0))} / ${esc(String(e.columns || 0))} ` +
+ `${bound.length ? `${bound.length} repo${bound.length === 1 ? '' : 's'} ` : '— '} ` +
+ `${actions} `;
+ }).join('');
+ }
+
+ function renderWorkflowLibraryTable(workflows, bindings, owner) {
+ return '' +
+ '
Workflow library ' +
+ '
Every workflow model this hive knows: the built-in hive-default lifecycle, models saved here, and drafts ingested from repository pipelines. A repository follows a model only once it is bound on Settings → Repos; binding changes the Activity Monitor\'s columns for that repository and, when ' + yamlHint('workflow.enabled') + ' is on, how its stages are derived.
' +
+ '
Model Provenance Source Compile S / C Bound ' +
+ '' + renderWorkflowLibraryRows(workflows, bindings, owner) + '
' +
+ '
';
+ }
+
+ function wfTrim(label, max) {
+ const s = String(label || '');
+ return s.length > max ? s.slice(0, max - 1) + '…' : s;
+ }
+
+ // Column/state diagram as inline SVG, laid out from the compiled
+ // profile's columns (display order) and the model's transitions. Pure.
+ function workflowDiagramSvg(model, profile) {
+ const spec = (model && model.spec) || {};
+ const cols = (profile && Array.isArray(profile.columns) && profile.columns.length) ? profile.columns : (spec.columns || []);
+ if (!cols.length) return 'This model declares no columns, so there is nothing to draw.
';
+ const D = WF_DIAGRAM;
+ const states = {};
+ (function walk(list) { (list || []).forEach(s => { states[s.id] = s; walk(s.regions); }); })(spec.states);
+ const pos = {};
+ let maxRows = 1;
+ cols.forEach((c, ci) => {
+ const ids = Array.isArray(c.states) ? c.states : [];
+ maxRows = Math.max(maxRows, ids.length);
+ ids.forEach((id, ri) => {
+ pos[id] = { x: D.pad + ci * (D.colW + D.colGap), y: D.pad + D.headH + ri * (D.chipH + D.chipGap), col: ci };
+ });
+ });
+ const width = D.pad * 2 + cols.length * D.colW + (cols.length - 1) * D.colGap;
+ const height = D.pad * 2 + D.headH + maxRows * (D.chipH + D.chipGap);
+ const out = [``];
+ out.push(' ');
+ cols.forEach((c, ci) => {
+ const x = D.pad + ci * (D.colW + D.colGap);
+ out.push(` ` +
+ `${esc(wfTrim(c.label || c.id, D.maxLabel))} `);
+ });
+ const edges = Array.isArray(spec.transitions) ? spec.transitions : [];
+ const seen = {};
+ edges.forEach(t => {
+ const a = pos[t.from], b = pos[t.to];
+ if (!a || !b || t.from === t.to) return;
+ const k = t.from + '>' + t.to;
+ if (seen[k]) return;
+ seen[k] = true;
+ const title = esc((t.on || '') + (t.note ? ' — ' + t.note : ''));
+ let d;
+ if (a.col === b.col) {
+ // Same lane: bow out to the right of the chips.
+ const x = a.x + D.colW, y1 = a.y + D.chipH / 2, y2 = b.y + D.chipH / 2, bow = D.colGap * 0.6;
+ d = `M${x} ${y1} C${x + bow} ${y1}, ${x + bow} ${y2}, ${x} ${y2}`;
+ } else {
+ const fwd = b.col > a.col;
+ const x1 = fwd ? a.x + D.colW : a.x, x2 = fwd ? b.x : b.x + D.colW;
+ const y1 = a.y + D.chipH / 2, y2 = b.y + D.chipH / 2, dx = (x2 - x1) / 2;
+ d = `M${x1} ${y1} C${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
+ }
+ out.push(`${title} `);
+ });
+ cols.forEach(c => (c.states || []).forEach(id => {
+ const p = pos[id], s = states[id] || { id: id, label: id };
+ const lc = s.lifecycle || {};
+ const inferred = lc.confidence === 'inferred';
+ const num = lc.number ? String(lc.number).padStart(3, '0') : '';
+ const cls = 'wf-dg-state' + (inferred ? ' inferred' : '') + (spec.initial === id ? ' initial' : '') + (profile && profile.terminal && profile.terminal[id] ? ' terminal' : '');
+ const tip = esc(s.label || id) + (num ? ' · stage/' + num : '') + (inferred ? ' · inferred' : '') + (lc.column ? '\ncolumn ' + esc(lc.column) : '');
+ out.push(`${tip} ` +
+ ` ` +
+ (num ? `${num} ` : '') +
+ `${esc(wfTrim(s.label || id, num ? D.maxLabel - 5 : D.maxLabel))} `);
+ }));
+ out.push(' ');
+ return out.join('');
+ }
+
+ function renderWorkflowDiagnostics(diags) {
+ const list = Array.isArray(diags) ? diags : [];
+ if (!list.length) return 'No diagnostics: the model compiles clean.
';
+ return '' + list.map(d =>
+ `${esc(d.code || '')} ${esc(d.message || '')}${d.path ? ` ${esc(d.path)} ` : ''} `
+ ).join('') + ' ';
+ }
+
+ function renderWorkflowDetail(detail, bindings, owner) {
+ const e = detail.entry || {}, m = detail.model || {}, p = detail.profile || null;
+ const key = e.name + '@' + e.version;
+ const bound = wfBoundRepos(bindings, e.name, e.version);
+ const meta = (m.spec && m.spec.meta) || {};
+ const sources = (m.spec && Array.isArray(m.spec.sources)) ? m.spec.sources : [];
+ const citations = [];
+ (function walk(list) { (list || []).forEach(s => { ((s.lifecycle || {}).citations || []).forEach(c => citations.push(c)); walk(s.regions); }); })(m.spec && m.spec.states);
+ const builtin = wfProvenanceOf(e) === 'built-in';
+ const facts = [
+ ['Provenance', wfProvenanceBadge(e)],
+ ['Source', esc(e.source || meta.source || '—')],
+ ['Derivation', esc((m.spec && m.spec.derivation && m.spec.derivation.mode) || (p && p.mode) || '—')],
+ ['Compile', wfCompiledBadge(e)],
+ ['States / columns', esc(String(e.states || 0)) + ' / ' + esc(String(e.columns || 0))],
+ ['Hash', `${esc(String(e.hash || '').slice(0, 12))} `],
+ ['Saved', e.savedAt && e.savedAt !== '0001-01-01T00:00:00Z' ? esc(formatDashboardDateTime(e.savedAt)) + (e.savedBy ? ' by ' + esc(e.savedBy) : '') : '—'],
+ ['Bound repositories', bound.length ? bound.map(r => `${esc(r)} `).join(', ') : 'none — bind on Settings → Repos '],
+ ];
+ if (sources.length) facts.push(['Ingested from', sources.map(s => `${esc(s.repo || s.ref || s.path || JSON.stringify(s))} `).join(', ')]);
+ const actions = [
+ '← Library ',
+ owner ? `Duplicate ` : '',
+ `Export JSON `,
+ owner && !builtin && !bound.length ? `Delete ` : '',
+ ].join('');
+ return '' +
+ `
` +
+ (e.description ? `
${esc(e.description)}
` : '') +
+ '
' + facts.map(([k, v]) => `${k} ${v} `).join('') + ' ' +
+ '
Columns and states ' +
+ '
Left to right as the Activity Monitor draws them. Arrows are the model\'s transitions; hover one for its event. Dashed states were inferred by ingest and have not been confirmed by a human.
' +
+ '
' + workflowDiagramSvg(m, p) + '
' +
+ '
Diagnostics ' + renderWorkflowDiagnostics(e.diagnostics || (p && p.diagnostics)) +
+ (citations.length ? '
Citations ' + citations.slice(0, 40).map(c => `${esc(c.path || '')}${c.line ? ':' + esc(String(c.line)) : ''}${c.note ? ` ${esc(c.note)} ` : ''} `).join('') + (citations.length > 40 ? `… ${citations.length - 40} more ` : '') + ' ' : '') +
+ '
Model JSON ' + esc(JSON.stringify(m, null, 2)) + ' ' +
+ '
';
+ }
+
+ function wfRepoChoices() {
+ const d = (_configState && _configState.data) || {};
+ const org = String(d.org || '').trim();
+ const repos = Array.isArray(d.repos) ? d.repos : [];
+ return repos.map(r => {
+ const name = typeof r === 'string' ? r : (r && (r.name || r.repo)) || '';
+ if (!name) return '';
+ return name.indexOf('/') >= 0 || !org ? name : org + '/' + name;
+ }).filter(Boolean);
+ }
+
+ function renderWorkflowIngest(state, owner) {
+ const st = state || _wfLib.ingest;
+ const choices = wfRepoChoices();
+ const opts = ['— choose a watched repository — '].concat(choices.map(r => `${esc(r)} `)).join('');
+ const r = st.result;
+ let result = '';
+ if (st.error) result = `${esc(st.error)}
`;
+ else if (r) {
+ const e = r.entry || null;
+ const cols = (r.profile && r.profile.columns) || (r.model && r.model.spec && r.model.spec.columns) || [];
+ const pipelines = Array.isArray(r.pipelines) ? r.pipelines : [];
+ result = '' +
+ `
${r.compiled ? '
compiles ' : '
does not compile '}` +
+ ` · ${esc(String(r.files || 0))} pipeline file${r.files === 1 ? '' : 's'} · ${pipelines.length} pipeline${pipelines.length === 1 ? '' : 's'} · ${cols.length} column${cols.length === 1 ? '' : 's'}` +
+ (r.ref ? ` ·
${esc(String(r.ref).slice(0, 12))} ` : '') +
+ (e ? ` · saved as
${esc(e.name + '@' + e.version)} ` : '') +
+ '
' +
+ (pipelines.length ? '
' + pipelines.map(p => `${esc(p.system || p.kind || '')} ${esc(p.name || p.id || '')}${p.path ? ` ${esc(p.path)} ` : ''} `).join('') + ' ' : '') +
+ '
Diagnostics ' + renderWorkflowDiagnostics(r.diagnostics) +
+ '
Proposed columns ' + workflowDiagramSvg(r.model, r.profile) + '
' +
+ '
';
+ }
+ const disabled = !owner || st.busy ? 'disabled' : '';
+ return '' +
+ '
Ingest a repository\'s pipelines ' +
+ '
Reads the repository\'s GitHub Actions workflows and Tekton pipelines from its default branch and proposes a lifecycle model: hive-default\'s review flow with the repository\'s PR-verify pipelines bound in, plus post-merge states for its main-branch CI, release and deploy pipelines. Everything inferred is marked inferred and the model is saved with provenance ingested · unreviewed . Nothing is bound until you choose to on Settings → Repos.
' +
+ (owner ? '' : '
Owner role is required to ingest.
') +
+ '
' + result + '
';
+ }
+
+ function wfIngestDefaultName(repo) {
+ const r = String(repo || '').trim().toLowerCase();
+ if (!r) return '';
+ return r.replace(/[^a-z0-9/._-]+/g, '-').replace(/\//g, '-').replace(/^-+|-+$/g, '');
+ }
+
+ function renderGovWorkflows() {
+ const owner = dashboardRoleAtLeast(window._hiveRole || 'read', 'owner');
+ if (!_workflowState.loaded) {
+ loadWorkflowLibrary(() => { if (_configState.tab === 'Workflows') renderConfigTab('Workflows'); });
+ return wfLibNav(_wfLib.view) + 'Loading the workflow library…
';
+ }
+ if (_wfLib.pending) {
+ const p = _wfLib.pending; _wfLib.pending = null;
+ wfLibOpen(p.name, p.version);
+ }
+ let body;
+ if (_wfLib.view === 'detail') {
+ if (_wfLib.detailError) body = `← Library ${esc(_wfLib.detailError)}
`;
+ else if (!_wfLib.detail) body = 'Loading ' + esc(_wfLib.loadingKey || 'model') + '…
';
+ else body = renderWorkflowDetail(_wfLib.detail, _workflowState.bindings, owner);
+ } else if (_wfLib.view === 'ingest') {
+ body = renderWorkflowIngest(_wfLib.ingest, owner);
+ } else {
+ body = renderWorkflowLibraryTable(_workflowState.workflows, _workflowState.bindings, owner);
+ }
+ return wfLibNav(_wfLib.view) + body;
+ }
+
+ function wfLibRerender() { if (_configState && _configState.tab === 'Workflows') renderConfigTab('Workflows'); }
+
+ function wfLibShow(view) {
+ _wfLib.view = WF_LIB_VIEWS.indexOf(view) >= 0 ? view : 'library';
+ if (_wfLib.view !== 'detail') { _wfLib.detail = null; _wfLib.detailError = ''; }
+ wfLibRerender();
+ }
+
+ async function wfLibOpen(name, version) {
+ const key = name + '@' + version;
+ _wfLib.view = 'detail'; _wfLib.detail = null; _wfLib.detailError = ''; _wfLib.loadingKey = key; _wfLib.selected = key;
+ wfLibRerender();
+ try {
+ const res = await fetch('/api/workflows/' + encodeURIComponent(name) + '/' + encodeURIComponent(version), { headers: _inceptionAuthHeaders() });
+ if (!res.ok) throw new Error(await saveErrorMessage(res));
+ const data = await res.json();
+ if (_wfLib.loadingKey !== key) return;
+ _wfLib.detail = data;
+ } catch (err) {
+ if (_wfLib.loadingKey !== key) return;
+ _wfLib.detailError = 'Could not load ' + key + ': ' + err.message;
+ }
+ wfLibRerender();
+ }
+
+ // Opens Settings → Workflows on a model; the Activity Monitor badge and
+ // the Repos binding row deep-link here.
+ function openWorkflowLibraryEntry(name, version) {
+ _wfLib.pending = { name: name, version: version };
+ if (typeof _configModalOpen !== 'undefined' && _configModalOpen && _configState.type === 'governor') {
+ switchConfigTab('Workflows');
+ return;
+ }
+ openConfigDialog('governor', null, 'Workflows');
+ }
+
+ async function wfLibReload() {
+ _workflowState.loaded = false;
+ await loadWorkflowLibrary();
+ wfLibRerender();
+ }
+
+ async function wfLibDuplicate(name, version) {
+ if (!dashboardRoleAtLeast(window._hiveRole || 'read', 'owner')) { showToast('Owner role is required to save a workflow.', 'error'); return; }
+ const newName = await hivePrompt({ title: 'Duplicate ' + name + '@' + version, message: 'Name for the copy (lower-case, digits, dots, dashes). It is saved as version 1 with provenance local; the original is untouched.', defaultValue: name === 'hive-default' ? 'my-lifecycle' : name + '-copy', confirmLabel: 'Duplicate' });
+ if (!newName) return;
+ const target = String(newName).trim().toLowerCase();
+ if (!/^[a-z0-9][a-z0-9._-]*$/.test(target)) { showToast('Name must be lower-case letters, digits, dots, dashes or underscores.', 'error'); return; }
+ try {
+ const res = await fetch('/api/workflows/' + encodeURIComponent(name) + '/' + encodeURIComponent(version), { headers: _inceptionAuthHeaders() });
+ if (!res.ok) throw new Error(await saveErrorMessage(res));
+ const data = await res.json();
+ const m = data.model || {};
+ m.metadata = Object.assign({}, m.metadata, { name: target });
+ m.spec = Object.assign({}, m.spec);
+ m.spec.meta = Object.assign({}, m.spec.meta, { version: '1', provenance: 'local', description: (m.spec.meta && m.spec.meta.description ? m.spec.meta.description + ' ' : '') + '(copy of ' + name + '@' + version + ')' });
+ const save = await fetch('/api/workflows', { method: 'POST', headers: _inceptionAuthHeaders(), body: JSON.stringify(m) });
+ if (!save.ok) throw new Error(await saveErrorMessage(save));
+ showToast('Saved ' + target + '@1');
+ await wfLibReload();
+ wfLibOpen(target, '1');
+ } catch (err) {
+ showToast('Duplicate failed: ' + err.message, 'error');
+ }
+ }
+
+ async function wfLibDelete(name, version) {
+ if (!dashboardRoleAtLeast(window._hiveRole || 'read', 'owner')) { showToast('Owner role is required to delete a workflow.', 'error'); return; }
+ const ok = await hiveConfirm({ title: 'Delete ' + name + '@' + version + '?', message: 'The model file is removed from this hive. Nothing happens to repositories: a bound model cannot be deleted, so none follows it.', confirmLabel: 'Delete', danger: true });
+ if (!ok) return;
+ try {
+ const res = await fetch('/api/workflows/' + encodeURIComponent(name) + '/' + encodeURIComponent(version), { method: 'DELETE', headers: _inceptionAuthHeaders() });
+ if (!res.ok) throw new Error(await saveErrorMessage(res));
+ showToast('Deleted ' + name + '@' + version);
+ _wfLib.view = 'library'; _wfLib.detail = null;
+ await wfLibReload();
+ } catch (err) {
+ showToast('Delete failed: ' + err.message, 'error');
+ }
+ }
+
+ async function wfLibExport(name, version) {
+ try {
+ const res = await fetch('/api/workflows/' + encodeURIComponent(name) + '/' + encodeURIComponent(version), { headers: _inceptionAuthHeaders() });
+ if (!res.ok) throw new Error(await saveErrorMessage(res));
+ const data = await res.json();
+ hiveExportViewer({ title: name + '@' + version + '.workflow.json', text: JSON.stringify(data.model || {}, null, 2), mime: 'application/json' });
+ } catch (err) {
+ showToast('Export failed: ' + err.message, 'error');
+ }
+ }
+
+ function wfIngestPickRepo(value) { _wfLib.ingest.repo = String(value || ''); _wfLib.ingest.result = null; _wfLib.ingest.error = ''; wfLibRerender(); }
+ function wfIngestSetRepo(value) { _wfLib.ingest.repo = String(value || ''); }
+ function wfIngestSetName(value) { _wfLib.ingest.name = String(value || ''); }
+
+ async function wfIngestRun(save) {
+ if (!dashboardRoleAtLeast(window._hiveRole || 'read', 'owner')) { showToast('Owner role is required to ingest.', 'error'); return; }
+ const st = _wfLib.ingest;
+ const repoEl = document.getElementById('wf-ingest-repo'), nameEl = document.getElementById('wf-ingest-name');
+ if (repoEl) st.repo = repoEl.value.trim();
+ if (nameEl) st.name = nameEl.value.trim();
+ if (!/^[^/\s]+\/[^/\s]+$/.test(st.repo)) { st.error = 'Enter a repository as owner/repo.'; st.result = null; wfLibRerender(); return; }
+ st.busy = true; st.error = ''; st.result = null; wfLibRerender();
+ try {
+ const body = { repo: st.repo, save: !!save };
+ if (st.name) body.name = st.name;
+ const res = await fetch('/api/workflows/ingest', { method: 'POST', headers: _inceptionAuthHeaders(), body: JSON.stringify(body) });
+ if (!res.ok) throw new Error(await saveErrorMessage(res));
+ st.result = await res.json();
+ if (save && st.result.entry) {
+ showToast('Saved ' + st.result.entry.name + '@' + st.result.entry.version);
+ _workflowState.loaded = false;
+ await loadWorkflowLibrary();
+ }
+ } catch (err) {
+ st.error = 'Ingest failed: ' + err.message;
+ } finally {
+ st.busy = false;
+ wfLibRerender();
+ }
+ }
// ── Watchdog activity strip (#7254) ──────────────────────────────────────
// "Review the watchdog-*-observed entries in the Audit Log, then switch to
// Heal" is only a data-driven decision if the data is on the page. The
diff --git a/src/pkg/dashboard/workflows_library_ui_test.go b/src/pkg/dashboard/workflows_library_ui_test.go
new file mode 100644
index 0000000000..9bd6bfbc6f
--- /dev/null
+++ b/src/pkg/dashboard/workflows_library_ui_test.go
@@ -0,0 +1,118 @@
+package dashboard
+
+import (
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+// TestWorkflowsLibraryTabWiring pins the RFC phase-3b surface
+// (hivecommons/hive#17620): Settings grows a Workflows tab that renders the
+// library, the governor dispatcher routes to it, the Activity Monitor model
+// badge deep-links into it, and the tab never binds (binding stays on
+// Settings → Repos).
+func TestWorkflowsLibraryTabWiring(t *testing.T) {
+ html := indexHTML(t)
+ for _, want := range []string{
+ `'Repos', 'Workflows'`,
+ `case 'Workflows': return renderGovWorkflows();`,
+ `function renderGovWorkflows()`,
+ `function renderWorkflowLibraryRows(workflows, bindings, owner)`,
+ `function renderWorkflowDetail(detail, bindings, owner)`,
+ `function workflowDiagramSvg(model, profile)`,
+ `function renderWorkflowIngest(state, owner)`,
+ `function openWorkflowLibraryEntry(name, version)`,
+ `fetch('/api/workflows/ingest', {`,
+ `data-action="openWorkflowLibraryEntry"`,
+ `.wf-diagram {`,
+ } {
+ if !strings.Contains(html, want) {
+ t.Errorf("index.html missing Workflows tab wiring %q", want)
+ }
+ }
+}
+
+// Pure helpers under node: provenance resolution, the sort order and badge
+// text of the library rows, bound-repository lookup, the ingest default
+// name, and the diagram's lanes, chips, inferred marking and edges.
+func TestWorkflowsLibraryNode(t *testing.T) {
+ node, err := exec.LookPath("node")
+ if err != nil {
+ t.Skip("node not installed")
+ }
+ html := indexHTML(t)
+ script := `const assert = require('assert');
+function esc(s) { return String(s).replace(/&/g, '&').replace(/ m[1]);
+assert.deepStrictEqual(keys, ['hive-default@1', 'broken@1', 'techdocs@2'], 'built-in first, then by name');
+assert.ok(rows.includes('compiles · 1 warning'));
+assert.ok(rows.includes('does not compile'));
+assert.ok(rows.includes('ingested · unreviewed'));
+assert.ok(rows.includes('>1 repo<'), 'techdocs@2 shows its one bound repo');
+const hd = rows.slice(rows.indexOf('hive-default@1'), rows.indexOf('broken@1'));
+assert.ok(!hd.includes('wfLibDelete'), 'built-in has no Delete');
+const td = rows.slice(rows.indexOf('data-wf-entry="techdocs@2"'));
+assert.ok(!td.includes('wfLibDelete') && td.includes('>bound<'), 'bound model shows bound instead of Delete');
+const br = rows.slice(rows.indexOf('data-wf-entry="broken@1"'), rows.indexOf('data-wf-entry="techdocs@2"'));
+assert.ok(br.includes('wfLibDelete'), 'unbound local model can be deleted');
+const ro = renderWorkflowLibraryRows(entries, bindings, false);
+assert.ok(!ro.includes('wfLibDuplicate') && !ro.includes('wfLibDelete'), 'non-owners get View/Export only');
+
+assert.ok(workflowDiagramSvg({ spec: {} }, null).includes('declares no columns'));
+const model = { spec: {
+ initial: 'open',
+ states: [{ id: 'open', label: 'Open', lifecycle: { number: 10 } }, { id: 'review', label: 'Review', lifecycle: { number: 50 } },
+ { id: 'merged', label: 'Merged', lifecycle: { number: 100 } }, { id: 'deploy', label: 'Deploy prod', lifecycle: { confidence: 'inferred' } }],
+ transitions: [{ from: 'open', to: 'review', on: 'pr-opened' }, { from: 'review', to: 'open', on: 'changes-requested' },
+ { from: 'review', to: 'merged', on: 'merged' }, { from: 'merged', to: 'deploy', on: 'pipeline-succeeded' }, { from: 'open', to: 'review', on: 'dup' }, { from: 'x', to: 'review' }],
+} };
+const profile = { columns: [{ id: 'c1', label: 'Open', states: ['open'] }, { id: 'c2', label: 'Review', states: ['review'] },
+ { id: 'c3', label: 'Merged', states: ['merged', 'deploy'], terminal: true }], terminal: { merged: true } };
+const svg = workflowDiagramSvg(model, profile);
+assert.strictEqual((svg.match(/class="wf-dg-lane"/g) || []).length, 3, 'one lane per column');
+assert.strictEqual((svg.match(/class="wf-dg-chip"/g) || []).length, 4, 'one chip per state');
+assert.strictEqual((svg.match(/