From 53d1b6332f0b120424babc0dc2902983c7e60494 Mon Sep 17 00:00:00 2001 From: Andrew Anderson Date: Wed, 11 Nov 2026 07:54:27 -0700 Subject: [PATCH 1/2] =?UTF-8?q?Add=20Settings=20=E2=86=92=20Workflows=20ta?= =?UTF-8?q?b:=20library,=20diagram,=20ingest=20(RFC=20phase=203b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings gains a Workflows tab listing every stored workflow model with provenance/compile badges and bound-repository counts, a read-only detail view that draws the compiled columns and states as an SVG diagram, and an Ingest form over POST /api/workflows/ingest. The Activity Monitor model badge deep-links into the library entry. Binding stays on Settings → Repos. Refs hivecommons/hive#17620 Signed-off-by: Andrew Anderson --- .../added-workflowmodel-workflows-tab.md | 1 + src/docs/design/workflow-models.md | 28 ++ .../dashboard/static-src/css/workflows.css | 48 ++ .../dashboard/static-src/js/config-state.js | 2 +- .../dashboard/static-src/js/config-tabs.js | 1 + .../static-src/js/config-workflows-library.js | 419 ++++++++++++++++ .../static-src/js/tracker-workflows.js | 2 +- src/pkg/dashboard/static-src/manifest.txt | 2 + src/pkg/dashboard/static/index.html | 472 +++++++++++++++++- .../dashboard/workflows_library_ui_test.go | 118 +++++ 10 files changed, 1089 insertions(+), 4 deletions(-) create mode 100644 src/changelog.d/added-workflowmodel-workflows-tab.md create mode 100644 src/pkg/dashboard/static-src/css/workflows.css create mode 100644 src/pkg/dashboard/static-src/js/config-workflows-library.js create mode 100644 src/pkg/dashboard/workflows_library_ui_test.go diff --git a/src/changelog.d/added-workflowmodel-workflows-tab.md b/src/changelog.d/added-workflowmodel-workflows-tab.md new file mode 100644 index 0000000000..a0a7f73539 --- /dev/null +++ b/src/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]) => + `` + ).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 = [ + ``, + owner ? `` : '', + ``, + canDelete ? `` + : (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.

' + + '' + + '' + renderWorkflowLibraryRows(workflows, bindings, owner) + '
ModelProvenanceSourceCompileS / CBound
' + + '
'; + } + + 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 ''; + } + + 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 = [ + '', + owner ? `` : '', + ``, + owner && !builtin && !bound.length ? `` : '', + ].join(''); + return '
' + + `

${esc(key)}

${actions}
` + + (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 = [''].concat(choices.map(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 = `

${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]) => + `` + ).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 = [ + ``, + owner ? `` : '', + ``, + canDelete ? `` + : (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.

' + + '' + + '' + renderWorkflowLibraryRows(workflows, bindings, owner) + '
ModelProvenanceSourceCompileS / CBound
' + + '
'; + } + + 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 ''; + } + + 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 = [ + '', + owner ? `` : '', + ``, + owner && !builtin && !bound.length ? `` : '', + ].join(''); + return '
' + + `

${esc(key)}

${actions}
` + + (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 = [''].concat(choices.map(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 = `

${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(/ Date: Wed, 11 Nov 2026 07:59:35 -0700 Subject: [PATCH 2/2] Move changelog fragment to repo-root changelog.d Refs hivecommons/hive#17620 Signed-off-by: Andrew Anderson --- .../added-workflowmodel-workflows-tab.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {src/changelog.d => changelog.d}/added-workflowmodel-workflows-tab.md (100%) diff --git a/src/changelog.d/added-workflowmodel-workflows-tab.md b/changelog.d/added-workflowmodel-workflows-tab.md similarity index 100% rename from src/changelog.d/added-workflowmodel-workflows-tab.md rename to changelog.d/added-workflowmodel-workflows-tab.md