From 3116f43e65224d95eaa9d88d8134e64e06bacf4b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 07:32:18 -0600 Subject: [PATCH] feat(webui): sp9, an organization's workflows read only, a list at #/orgs//workflows, a step graph at #/orgs//workflows/ with the selected step's roles, state and neighbours, and a Workflows card on the organization page (#1873, lane sp9-workflows) The graph reads the existing GET /api/orgs//workflows; no new route and no write. A stopped bus shows the data-ws-unread note, never "no workflows". An organization with none says so and links Apply a pack only where this Console may apply one. At 700px and below the graph runs top to bottom inside its own frame. --- packages/webui/src/public/brand.js | 2 +- packages/webui/src/public/bus.js | 8 +- packages/webui/src/public/index.html | 2 +- packages/webui/src/public/workflows.css | 55 ++++ packages/webui/src/public/workflows.js | 174 +++++++++++- packages/webui/src/public/workspace.js | 3 +- packages/webui/src/serve.mjs | 3 +- .../webui/tests/workflows-browser.test.mjs | 252 ++++++++++++++++++ 8 files changed, 493 insertions(+), 6 deletions(-) create mode 100644 packages/webui/src/public/workflows.css create mode 100644 packages/webui/tests/workflows-browser.test.mjs diff --git a/packages/webui/src/public/brand.js b/packages/webui/src/public/brand.js index 787f2b37c..9c729f475 100644 --- a/packages/webui/src/public/brand.js +++ b/packages/webui/src/public/brand.js @@ -201,7 +201,7 @@ if (parts[0] === 'board' && /^#\/orgs\//.test(location.hash)) { box.innerHTML = 'Control board'; return; } if (!parts.length || parts[0] === 'board' || parts[0] === 'dashboard') { box.innerHTML = 'Dashboard'; return; } if (parts[0] === 'organization') { const h = $('bus-view').querySelector('[data-org-dashboard] h1')?.textContent?.trim(); box.innerHTML = `Dashboard${h ? `${escape(h)}` : ''}`; return; } - const names = {projects:'Projects',chat:'Projects',inbox:'Inbox',messages:'Messages',assistant:'Assistant',tasks:'Tasks',agents:'Agents',queue:'Queue',business:'Business',docs:'Docs',settings:'Settings'}; + const names = {projects:'Projects',chat:'Projects',inbox:'Inbox',messages:'Messages',assistant:'Assistant',tasks:'Tasks',agents:'Agents',workflows:'Workflows',queue:'Queue',business:'Business',docs:'Docs',settings:'Settings'}; if (parts[0] === 'settings' && parts[1] === 'business' && $('bus-view').querySelector('#settings-page-title')?.textContent.trim() === 'Business') { box.innerHTML = 'SettingsBusiness'; return; } const name = names[parts[0]] || 'Dashboard', heading = $('bus-view').querySelector('h1')?.textContent?.trim(); const project = $('bus-view').querySelector('.chat-conversation > p:first-child a, .chat-room > p:first-child a'); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index d0213eb88..c9cc4ad79 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -1717,6 +1717,12 @@ return agentsView(get, q, org); } if (a === 'agents' && n === 1) return agentsView(get); + // SP9 (#1873): #/orgs//workflows lists an organization's workflows; #/orgs//workflows/ draws one, read only. + if (a === 'workflows' && (n === 1 || n === 2) && window.MosaicOrg && window.MosaicWorkflows?.page) { + const scope = /^#\/orgs\/([^/?]+)/.exec(location.hash)?.[1]; + let org = null; try { org = scope ? decodeURIComponent(scope) : window.MosaicOrg.id; } catch { return notFound(); } + return org ? window.MosaicWorkflows.page(org, n === 2 ? b : null, q) : notFound(); + } if (a === 'queue' && n === 1) return queueView(get, q); if (a === 'queue' && n === 2) return /^[1-9]\d{0,5}$/.test(b) ? queueRowView(get, b) : notFound(`${txt(b)} is not a queue row id.`); if (a === 'settings' && (n === 1 || (n === 2 && [...settingsPages.map(p=>p[0]),'names'].includes(b)))) return settingsView(get, b || 'appearance'); @@ -1731,7 +1737,7 @@ const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true'; // A file open in the editor is never refreshed from under its draft; Reload is the person's to press. // The cross-organization pages (U3) poll at the workspace read's pollMs. - const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents' || path === '/tasks'); + const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/dashboard' || path === '/projects' || path === '/inbox' || path === '/agents' || path === '/tasks' || /^\/workflows(\/[^/]+)?$/.test(path)); function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 10000); } // Keep focus, open
and the copy status across a refresh of the same page. // In the send form, the focused field and its caret too (its values come from `draft`). diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index 7092bb291..d6b78363e 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -7,7 +7,7 @@ - + diff --git a/packages/webui/src/public/workflows.css b/packages/webui/src/public/workflows.css new file mode 100644 index 000000000..2cc11a773 --- /dev/null +++ b/packages/webui/src/public/workflows.css @@ -0,0 +1,55 @@ +/* SP9 (#1873): the read-only workflow pages (#/orgs//workflows[/]) and the organization + page's Workflows card. Colours come from the theme tokens, so light and dark both follow the theme. */ +.wfg{min-width:0} +.wfg h2{font-size:15px;margin:22px 0 8px} +.wfg-scope:first-of-type h2{margin-top:6px} +.wfg-list{list-style:none;margin:0;padding:0;background:var(--surface);border:1px solid var(--line);border-radius:10px} +.wfg-item{padding:12px 14px;border-top:1px solid var(--line-soft,var(--line));overflow-wrap:anywhere} +.wfg-item:first-child{border-top:0} +.wfg-item-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px} +.wfg-item-h a{font-weight:600;color:var(--action,var(--accent));text-decoration:none} +.wfg-item-h a:hover{text-decoration:underline} +.wfg-desc{margin:4px 0 0;color:var(--muted);font-size:13.5px} +.wfg-chain{list-style:none;display:flex;flex-wrap:wrap;gap:4px 0;margin:8px 0 0;padding:0;font-size:13px;color:var(--text)} +.wfg-chain li{display:inline-flex;align-items:center} +.wfg-chain li+li::before{content:"\2192";color:var(--muted);padding:0 8px} +:is(.wfg,.wfg-card) a:focus-visible{outline:2px solid var(--focus);outline-offset:2px;border-radius:4px} + +/* The graph scrolls inside its own frame when it is wider than the frame; the page never does. */ +.wfg-frame{max-width:100%;overflow-x:auto;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px} +.wfg-graph{display:block} +.wfg-graph.is-vertical{width:100%;max-width:340px;height:auto;margin:0 auto} +.wfg-node{cursor:pointer;text-decoration:none} +.wfg-node rect{fill:var(--canvas);stroke:var(--line);stroke-width:1.5} +.wfg-node:hover rect{stroke:var(--muted)} +.wfg-node.is-sel rect{stroke:var(--accent);stroke-width:2.5;fill:var(--raised)} +.wfg-node:focus{outline:none} +.wfg-node:focus-visible rect{stroke:var(--focus);stroke-width:3} +.wfg-node text{font-family:var(--font);fill:var(--text)} +.wfg-meta{font-size:12px;fill:var(--muted)!important;letter-spacing:.02em} +.wfg-title{font-size:15px;font-weight:600;fill:var(--strong,var(--text))!important} +.wfg-roles{font-size:13px;font-family:var(--mono)!important;fill:var(--text)} +.wfg-miss{font-size:12px;fill:var(--danger)!important} +.wfg-edge{fill:none;stroke:var(--muted);stroke-width:1.5} +.wfg-arrow{fill:var(--muted)} + +.wfg-detail{margin-top:16px;overflow-wrap:anywhere} +.wfg-detail h2{margin:0 0 10px} +.wfg-dl{display:grid;grid-template-columns:max-content minmax(0,1fr);align-items:baseline;gap:8px 16px;margin:0 0 12px} +.wfg-dl dt{color:var(--muted);font-size:13px} +.wfg-dl dd{margin:0} +@media (max-width:700px){ + .wfg-dl{grid-template-columns:minmax(0,1fr);gap:2px} + .wfg-dl dd{margin-bottom:8px} +} + +/* The organization page's card, above Settings in the right column. */ +.wfg-card>.ws-sec-h{margin-top:0} +.wfg-card+.ws-sec-h{margin-top:22px} +.wfg-cardlist{list-style:none;margin:0} +.wfg-cardlist li+li{border-top:1px solid var(--line-soft,var(--line))} +.wfg-cardlist a{display:block;padding:8px 0;color:var(--text);text-decoration:none;overflow-wrap:anywhere} +.wfg-cardlist a:hover strong{text-decoration:underline} +.wfg-cardlist strong{color:var(--action,var(--accent))} +.wfg-card [data-wfg-none]{margin:0 0 8px} +.wfg-card p:last-child{margin-bottom:0} diff --git a/packages/webui/src/public/workflows.js b/packages/webui/src/public/workflows.js index b99beba0c..e21abab7f 100644 --- a/packages/webui/src/public/workflows.js +++ b/packages/webui/src/public/workflows.js @@ -197,5 +197,177 @@ } catch (error) { s.status = error.message; } redraw(org); }); - window.MosaicWorkflows = { load, section }; + + // ---------- SP9 (#1873): the read-only view ---------- + // #/orgs//workflows lists the organization's workflows; #/orgs//workflows/[?project=

] + // draws one as an inline-SVG graph. The data is the same read as the editor above: each step's title, + // state and roles, in file order. A workflow has no edges or branches in the file, so a step hands on to + // the next one. Nothing else is drawn: no gate, no inputs and no running mark, because no read gives them. + const WS = () => window.MosaicWorkspace; + const orgHash = org => `#/orgs/${encodeURIComponent(org)}`; + const flowHref = (org, scope, name, step) => { + const q = new URLSearchParams(); + if (scope) q.set('project', scope); + if (step) q.set('step', String(step)); + return `${orgHash(org)}/workflows/${encodeURIComponent(name)}${q.size ? `?${q}` : ''}`; + }; + const stepsOf = w => (Array.isArray(w?.steps) ? w.steps.filter(st => st && typeof st === 'object') : []); + const rolesOf = st => (Array.isArray(st?.roles) ? st.roles.filter(r => typeof r === 'string') : []); + const count = n => `${n} step${n === 1 ? '' : 's'}`; + const stateLabel = st => STATE_LABEL[st?.state] ?? String(st?.state ?? ''); + const scopeLabel = scope => (scope ? `Project ${scope}` : 'Organization'); + // Each scope that holds a workflow, the organization first. Names are own keys only. + const listed = s => scopes(s).map(([scope, label, flows]) => [scope, label, Object.keys(plain(flows) ? flows : {}).sort().map(n => [n, flows[n]])]); + const total = s => listed(s).reduce((n, [, , rows]) => n + rows.length, 0); + // The organization's bus state from the membership list, and for one that is down the workspace read's + // reason in the Inbox's words (orgsOf + unreadNote, the data-ws-unread note). + async function orgOf(org) { + const list = await window.MosaicWorkspaceData.organizations().catch(() => null); + const row = Array.isArray(list) ? list.find(o => o?.id === org && !o.invalid) : null; + if (!row) return { row: null, list: Array.isArray(list) }; + let unread = ''; + if (!row.busHealthy) { + const body = await window.MosaicWorkspaceData.workspace({ org, sections: 'projects' }).catch(() => null); + const o = body ? WS().orgsOf({ orgs: (Array.isArray(body.orgs) ? body.orgs : []).filter(r => r?.org === org) }) : null; + unread = o?.unread.length ? WS().unreadNote(o, [], 'workflows') + : `

Not read: ${esc(row.label || org)} ${row.busStopped ? 'bus stopped' : 'bus not answering'}. No reason was read.

`; + } + return { row, unread, stopped: !row.busHealthy && row.busStopped === true, label: row.label || org }; + } + const head = (title, sub) => `

${title}

${sub}

`; + const applyLink = (org, s) => (s?.data?.writable + ? `

Apply a pack

` + : '

A starter pack brings workflows. Only the owner, on a Console started with send, can apply one.

'); + const none = (org, s, label) => `

No workflows in ${esc(label)} yet.

${applyLink(org, s)}`; + + function listBody(org, s, label) { + if (total(s) === 0) return none(org, s, label); + return listed(s).filter(([scope, , rows]) => !scope || rows.length).map(([scope, label2, rows]) => `

${esc(label2)}

${rows.length ? `
    ${rows.map(([name, w]) => { + const steps = stepsOf(w); + return `
  • ${esc(w?.title || name)}${w?.title ? ` ${esc(name)}` : ''} ${count(steps.length)}
    + ${w?.description ? `

    ${esc(w.description)}

    ` : ''}
      ${steps.map(st => `
    1. ${esc(st.title)}
    2. `).join('')}
  • `; + }).join('')}
` : '

None at the organization level.

'}
`).join(''); + } + + // Layout: left to right on a wide screen, top to bottom on a narrow one. The graph scrolls inside its + // own frame when it is wider than the frame; the page never does. + const NARROW = '(max-width: 700px)'; + const narrow = () => window.matchMedia(NARROW).matches; + const clip = (text, n) => (text.length > n ? `${text.slice(0, n - 1)}…` : text); + // Up to two lines of about `n` characters, broken at spaces; the second ends in … when the title is longer. + function wrap(text, n) { + const words = String(text ?? '').split(/\s+/).filter(Boolean), lines = ['']; + for (const w of words) { + const cur = lines[lines.length - 1]; + if (!cur || (cur + ' ' + w).length <= n) lines[lines.length - 1] = cur ? `${cur} ${w}` : w; + else if (lines.length < 2) lines.push(w); + else { lines[1] = `${lines[1]} ${w}`; } + } + if (lines[0].length > n) return [clip(lines[0], n)]; + if (lines[1] && lines[1].length > n) lines[1] = clip(lines[1], n); + return lines; + } + function graph(org, s, scope, name, steps, sel) { + const vertical = narrow(); + const W = vertical ? 300 : 188, GAP = vertical ? 32 : 36, PAD = 14; + const per = vertical ? 36 : 21; + // Each box is as tall as the tallest step needs: meta, one or two title lines, roles, and the missing-role line if any. + const rolesY = st => 46 + (wrap(st.title, per).length - 1) * 19 + 24; + const H = Math.max(...steps.map(st => rolesY(st) + (rolesOf(st).some(r => !s.known.has(r)) ? 18 : 0) + 16)); + const pos = i => (vertical ? [PAD, PAD + i * (H + GAP)] : [PAD + i * (W + GAP), PAD]); + const width = vertical ? W + 2 * PAD : PAD * 2 + steps.length * W + (steps.length - 1) * GAP; + const height = vertical ? PAD * 2 + steps.length * H + (steps.length - 1) * GAP : H + 2 * PAD; + const edges = steps.slice(1).map((_, i) => { + const [x, y] = pos(i), [x2, y2] = pos(i + 1); + const d = vertical ? `M${x + W / 2} ${y + H} V${y2 - 3}` : `M${x + W} ${y + H / 2} H${x2 - 3}`; + return ``; + }).join(''); + const nodes = steps.map((st, i) => { + const [x, y] = pos(i), n = i + 1, roles = rolesOf(st), missing = roles.filter(r => !s.known.has(r)); + const lines = wrap(st.title, per), on = sel === n; + const label = `Step ${n} of ${steps.length}: ${st.title ?? ''}. ${stateLabel(st)}. Roles ${roles.join(', ')}${missing.length ? `; ${missing.join(', ')} ${MISSING}` : ''}.`; + return ` + ${esc(st.title)} + Step ${n} · ${esc(stateLabel(st))} + ${lines.map((l, j) => `${esc(l)}${j < lines.length - 1 ? ' ' : ''}`).join('')} + ${esc(clip(roles.join(', '), per + 4))} + ${missing.length ? `${esc(clip(`${missing.join(', ')} ${MISSING}`, per + 4))}` : ''}`; + }).join(''); + return ` + ${edges}${nodes}`; + } + const stepRef = (org, scope, name, steps, n) => `Step ${n}, ${esc(steps[n - 1].title)}`; + function detail(org, s, scope, name, steps, sel) { + if (!sel) return `

Step details

Select a step to see its roles, the state it puts a task in and the step it hands on to.

`; + const st = steps[sel - 1], roles = rolesOf(st); + const roleList = roles.map(r => r === 'human' ? 'human (a person)' : s.known.has(r) ? `${esc(r)}` : `${esc(r)} ${MISSING}`).join(', '); + return `

${esc(st.title)}

+
Position
Step ${sel} of ${steps.length}
+
Roles
${roleList || 'None recorded'}
+
Puts the task in
${esc(stateLabel(st))}
+
Comes after
${sel > 1 ? stepRef(org, scope, name, steps, sel - 1) : 'Nothing: this is the first step'}
+
Hands on to
${sel < steps.length ? stepRef(org, scope, name, steps, sel + 1) : 'Nothing: this is the last step'}
+

The workflow file records no gate or inputs for a step, and no read says which step a task is on now.

`; + } + function graphBody(org, s, name, q) { + const scope = q.get('project') || ''; + const flows = scope ? s.data.projects[scope] : s.data.workflows; + if (scope && !Object.hasOwn(s.data.projects, scope)) return { title: esc(name), sub: '', body: `

No code project named ${esc(scope)} in the organization file.

` }; + if (!plain(flows) || !Object.hasOwn(flows, name)) return { title: esc(name), sub: '', body: `

No workflow named ${esc(name)} in ${scope ? `project ${esc(scope)}` : 'the organization'}.

` }; + const w = flows[name], steps = stepsOf(w); + const raw = Number(q.get('step')), sel = Number.isInteger(raw) && raw >= 1 && raw <= steps.length ? raw : 0; + const sub = `${esc(scopeLabel(scope))}, ${esc(name)}, ${count(steps.length)}.${w?.description ? ` ${esc(w.description)}` : ''}`; + const body = steps.length + ? `
${graph(org, s, scope, name, steps, sel)}
${detail(org, s, scope, name, steps, sel)}` + : '

This workflow has no steps.

'; + return { title: esc(w?.title || name), sub, body }; + } + // The page for #/orgs//workflows (name null) or #/orgs//workflows/. + async function page(org, name, q) { + const attr = name === null ? 'data-wfg-list' : 'data-wfg-page'; + const info = await orgOf(org); + if (!info.row) return `
${head('Organization unavailable', info.list ? 'This organization is not on your list.' : 'The organization list could not be read.')}
`; + const wrapIt = (title, sub, body) => `
${head(title, sub)}${info.unread}${body}
`; + // E2E6 P2-8: a stopped bus is not read; the note above says why. + if (info.stopped) return wrapIt(name === null ? 'Workflows' : esc(name), `Workflows in ${esc(info.label)}.`, ''); + await load(org); + const s = states.get(org); + if (!s?.data) return wrapIt(name === null ? 'Workflows' : esc(name), `Workflows in ${esc(info.label)}.`, `

${esc(s?.error ?? 'Workflows could not be read.')}

`); + if (name === null) return wrapIt('Workflows', `The steps a task goes through in ${esc(info.label)}, in order, with the roles that do each. Read only; the organization page changes them.`, listBody(org, s, info.label)); + const g = graphBody(org, s, name, q); + return wrapIt(g.title, g.sub, g.body); + } + // The organization page's card: each workflow as a link to its graph, from the page's one workflow read. + function card(org, { stopped = false, label = org } = {}) { + const all = `All workflows`; + const h = note => `

Workflows

${note ? `${note}` : ''}${all}
`; + if (stopped) return `
${h('')}

Not read: the bus is stopped.

`; + const s = states.get(org); + if (!s?.data) return `
${h('')}

${esc(s?.error ?? 'Workflows could not be read.')}

`; + const rows = listed(s).flatMap(([scope, , list]) => list.map(([name, w]) => [scope, name, w])); + if (!rows.length) return `
${h('none')}
${none(org, s, label)}
`; + return `
${h(String(rows.length))}
`; + } + // A step box or a step link in the details selects that step in the address (setQuery repaints). + document.addEventListener('click', ev => { + const link = ev.target.closest('[data-wfg-step],[data-wfg-go]'); + if (link && WS()?.setQuery && ev.button === 0 && !ev.metaKey && !ev.ctrlKey && !ev.shiftKey && !ev.altKey) { + ev.preventDefault(); + WS().setQuery({ step: link.dataset.wfgStep ?? link.dataset.wfgGo }); + return; + } + // Apply a pack: the pack picker on the organization page, scrolled into view. + const apply = ev.target.closest('[data-wfg-apply]'); + if (!apply || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return; + const show = () => { const p = document.querySelector('[data-packs]'); if (!p) return false; p.scrollIntoView({ block: 'start' }); return true; }; + if (location.hash === apply.getAttribute('href') && show()) { ev.preventDefault(); return; } + const until = Date.now() + 8000; + const wait = () => { if (location.hash === apply.getAttribute('href') && document.querySelector('[data-org-dashboard] [data-packs]')) setTimeout(show, 0); else if (Date.now() < until) setTimeout(wait, 60); }; + setTimeout(wait, 60); + }); + // Crossing the narrow breakpoint redraws the graph in the other direction. + window.matchMedia(NARROW).addEventListener('change', () => { + if (document.querySelector('[data-wfg-graph]')) document.dispatchEvent(new CustomEvent('mosaic-workspace-render')); + }); + window.MosaicWorkflows = { load, section, page, card }; })(); diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index c15392a14..69c5b4212 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -343,7 +343,8 @@ ${secH('Queue', queueNote, 'Open queue')} ${queueWhy} -
+
+ ${window.MosaicWorkflows?.card?.(id, { stopped, label: label(org) }) ?? ''} ${secH('Settings', 'every setting, with where its value comes from')} ${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })}
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index e92a58943..14c058fc2 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -86,6 +86,7 @@ const files = new Map([ ['/packs.js', ['packs.js', 'text/javascript; charset=utf-8']], ['/workflows.js', ['workflows.js', 'text/javascript; charset=utf-8']], ['/packs.css', ['packs.css', 'text/css; charset=utf-8']], + ['/workflows.css', ['workflows.css', 'text/css; charset=utf-8']], ...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]), ...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]), ]); @@ -1085,7 +1086,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF return templatesHTTP(req, res, { path, owner, read: requestRead, write: send ? templatesWrite : null, session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso?.publicOrigin }); } - if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/packs.js', '/packs.css', '/workflows.js'].includes(path)) return json(res, 403, { error: 'owner-only' }); + if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/packs.js', '/packs.css', '/workflows.js', '/workflows.css'].includes(path)) return json(res, 403, { error: 'owner-only' }); // Row 194 (#1692, D4.1): the tester also launches and terminates. if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path)) return json(res, 403, { error: 'read-only', sent: false }); diff --git a/packages/webui/tests/workflows-browser.test.mjs b/packages/webui/tests/workflows-browser.test.mjs new file mode 100644 index 000000000..619c808f0 --- /dev/null +++ b/packages/webui/tests/workflows-browser.test.mjs @@ -0,0 +1,252 @@ +// Single pane SP9 (#1873): an organization's workflows, read only. #/orgs//workflows lists them, +// #/orgs//workflows/ draws one as an inline-SVG graph (one box per step, an arrowed line per +// hand-off), and the organization page carries a small card. The data is what GET /api/orgs//workflows +// returns from the organization file: steps in order, each with a title, a state and its roles. Nothing +// else is drawn. A stopped organization is not read and says why. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer } from '../src/serve.mjs'; +import { createDevOwner } from '../src/dev-owner.mjs'; +import { browser } from './browser.mjs'; +import { close } from './fixture.mjs'; + +const XSS = ''; +const at = minutes => new Date(Date.now() - minutes * 60000).toISOString(); +const waitFor = b => (expression, what = expression) => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(what)}+': '+document.body.innerText.slice(0,1500))})()`); + +// The coding starter pack's workflow as pack.apply writes it (the pack's step ids are not kept). +const CHANGE = { + description: 'One change from task to merge.', + steps: [ + { title: 'Write the task', roles: ['pm'], state: 'todo' }, + { title: 'Implement the change', roles: ['coder'], state: 'in-progress' }, + { title: 'Review the change', roles: ['reviewer'], state: 'in-review' }, + { title: 'Approve the merge', roles: ['human', 'merge-gate'], state: 'done' }, + ], +}; +const DOCS = { + acme: { + definitions: { pm: {}, coder: {}, reviewer: {} }, + workflows: { change: CHANGE, intake: { title: `Intake ${XSS}`, steps: [{ title: `Sort ${XSS}`, state: 'todo', roles: ['human'] }] } }, + projects: { site: { workflows: { change: { title: 'Site release', steps: [{ title: 'Build', state: 'in-progress', roles: ['coder'] }, { title: 'Ship', state: 'done', roles: ['human'] }] } } }, notes: { kind: 'library', workflows: { hidden: { steps: [{ title: 'x', state: 'todo', roles: ['human'] }] } } } }, + }, + bare: { definitions: {}, projects: {} }, +}; +const LIST = [ + { id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true }, + { id: 'bare', label: 'Bare Works', theme: 'grove', busHealthy: true }, + { id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false, busStopped: true }, +]; + +function organizations(reads) { + const member = org => ({ + id: org.id, doc: { appearance: { theme: org.theme }, roles: {}, projects: {} }, + status: async () => (org.busHealthy ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }), + options: () => ({ bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => [] }, chat: { threads: async () => [], projects: async () => [] } }), + }); + return { + list: async () => LIST, + members: async () => LIST.map(member), + resolve: async id => { + const org = LIST.find(o => o.id === id); if (!org) return null; + const workflowsRead = async () => { reads.push(id); return { revision: 'a'.repeat(64), document: structuredClone(DOCS[id] ?? {}), templates: ['writer'] }; }; + if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, workflowsRead, chat: { projects: async () => [], threads: async () => [] } }; + return { + busHealthy: true, workflowsRead, packsWrite: async () => ({}), decisionWrite: async () => ({}), send: async () => ({}), + requestRead: async verb => { if (verb === 'packs.console') return { packs: [] }; if (verb === 'autonomy.get.console') return { revision: 'e'.repeat(64), autonomy: {}, levels: {} }; throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); }, + reads: { business: async () => ({ business: { id, projects: [], instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }) }, + bus: { instances: async () => ({ instances: {} }), agents: async () => [], 'thread.claims': async () => [], tasks: async () => [], inbox: async () => [], messages: async () => ({ messages: [] }) }, + chat: { projects: async () => [], threads: async () => [] }, + }; + }, + }; +} + +async function setup(t, { path, viewport = [1440, 900], mode = null } = {}) { + const reads = []; + const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: organizations(reads) }); + t.after(() => close(server)); + const b = await browser({ machine: false, args: ['--disable-extensions'] }); + t.after(() => b.close()); + await b.viewport(...viewport); + // The Console's own mode wins over the OS setting (Harbor in the dark by default), so a theme test sets it per organization. + if (mode) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `try { for (const o of ['acme','bare','gamma']) localStorage.setItem('mosaic-console-appearance:' + o, JSON.stringify({ theme: 'harbor', mode: ${JSON.stringify(mode)} })); } catch {}` }); + await b.navigate(`http://127.0.0.1:${server.address().port}/${path}`); + return { b, reads, wait: waitFor(b) }; +} + +const boxes = `[...document.querySelectorAll('[data-wfg-graph] [data-wfg-step]')]`; + +test('the list names each workflow by scope with its steps in order, links to its graph, and keeps titles as text', { timeout: 60000 }, async t => { + const { b, wait } = await setup(t, { path: '#/orgs/acme/workflows' }); + await wait(`document.querySelector('[data-wfg-list] [data-wfg-item="change"]')`, 'workflow list'); + assert.equal(await b.evaluate(`document.querySelector('#bus-view h1').textContent`), 'Workflows'); + // Organization first, then each code project; a library project has no workflows. + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-scope-list]')].map(n=>[n.dataset.wfgScopeList,n.querySelector('h2').textContent])`), [['', 'Organization'], ['site', 'Project site']]); + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-scope-list=""] [data-wfg-item]')].map(n=>n.dataset.wfgItem)`), ['change', 'intake']); + const change = `[data-wfg-scope-list=""] [data-wfg-item="change"]`; + assert.equal(await b.evaluate(`document.querySelector('${change} a[data-wfg-open]').getAttribute('href')`), '#/orgs/acme/workflows/change'); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-scope-list="site"] [data-wfg-item="change"] a[data-wfg-open]').getAttribute('href')`), '#/orgs/acme/workflows/change?project=site'); + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('${change} [data-wfg-chain] li')].map(l=>l.textContent)`), ['Write the task', 'Implement the change', 'Review the change', 'Approve the merge']); + assert.match(await b.evaluate(`document.querySelector('${change}').textContent`), /4 steps/); + assert.match(await b.evaluate(`document.querySelector('${change}').textContent`), /One change from task to merge\./); + // The title is shown as written. + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-item="intake"] a[data-wfg-open]').textContent`), `Intake ${XSS}`); + assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view img').length`), 0); + assert.equal(await b.evaluate(`window.__sp9pwn===undefined`), true); + assert.equal(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]')`), null, 'a running bus has no unread note'); + // The trail names the organization and the page. + await wait(`document.querySelector('#breadcrumbs [data-crumb-org]')?.textContent === 'Acme Studio'`, 'org crumb'); + // A click opens the graph. + await b.evaluate(`document.querySelector('${change} a[data-wfg-open]').click();true`); + await wait(`location.hash === '#/orgs/acme/workflows/change' && document.querySelectorAll('[data-wfg-graph] [data-wfg-step]').length === 4`, 'graph after click'); +}); + +test('the graph draws one box per step in order with its roles and one arrowed edge per hand-off', { timeout: 60000 }, async t => { + const { b, wait } = await setup(t, { path: '#/orgs/acme/workflows/change' }); + await wait(`${boxes}.length === 4`, 'four boxes'); + assert.equal(await b.evaluate(`document.querySelector('#bus-view h1').textContent`), 'change'); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-graph]').tagName.toLowerCase()`), 'svg'); + assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.dataset.wfgStep)`), ['1', '2', '3', '4']); + assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelector('[data-wfg-title]').textContent)`), ['Write the task', 'Implement the change', 'Review the change', 'Approve the merge']); + assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelector('[data-wfg-roles]').textContent)`), ['pm', 'coder', 'reviewer', 'human, merge-gate']); + // A role that resolves to no definition or template (and is not human) is marked on its box. + assert.deepEqual(await b.evaluate(`${boxes}.map(g=>g.querySelectorAll('[data-wfg-missing]').length)`), [0, 0, 0, 1]); + // At 1440 the steps run left to right. + const xs = await b.evaluate(`${boxes}.map(g=>g.querySelector('rect').getBoundingClientRect().left)`); + assert.deepEqual([...xs].sort((p, q) => p - q), xs); + assert.equal(new Set(xs).size, 4); + // One edge per hand-off, from each box to the next, with an arrowhead. + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-graph] [data-wfg-edge]')].map(e=>e.dataset.wfgEdge)`), ['1-2', '2-3', '3-4']); + const touches = await b.evaluate(`[...document.querySelectorAll('[data-wfg-graph] [data-wfg-edge]')].map(e=>{ + const [f,t]=e.dataset.wfgEdge.split('-'), box=n=>document.querySelector('[data-wfg-graph] [data-wfg-step="'+n+'"] rect').getBBox(); + const p=e.getPointAtLength(0), q=e.getPointAtLength(e.getTotalLength()), a=box(f), z=box(t), near=(pt,r)=>pt.x>=r.x-14&&pt.x<=r.x+r.width+14&&pt.y>=r.y-14&&pt.y<=r.y+r.height+14; + return near(p,a)&&near(q,z)&&!!e.getAttribute('marker-end');})`); + assert.deepEqual(touches, [true, true, true]); + // The graph scrolls inside its own box, never the page. + assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true); + // Nothing running is drawn: no read links a task to a step. + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-running]')`), null); +}); + +test('selecting a step shows what the data holds for it and survives a repaint', { timeout: 60000 }, async t => { + const { b, wait, reads } = await setup(t, { path: '#/orgs/acme/workflows/change' }); + await wait(`${boxes}.length === 4`, 'four boxes'); + assert.match(await b.evaluate(`document.querySelector('[data-wfg-detail]').textContent`), /Select a step/); + await b.evaluate(`document.querySelector('[data-wfg-step="2"]').dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));true`); + await wait(`/[?&]step=2/.test(location.hash) && document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Implement the change'`, 'detail for step 2'); + assert.equal(await b.evaluate(`location.hash`), '#/orgs/acme/workflows/change?step=2'); + const field = name => b.evaluate(`document.querySelector('[data-wfg-detail] [data-wfg-field="${name}"]')?.textContent.replace(/\\s+/g,' ').trim() ?? null`); + assert.equal(await field('position'), 'Step 2 of 4'); + assert.equal(await field('roles'), 'coder'); + assert.equal(await field('state'), 'In progress'); + assert.equal(await field('after'), 'Step 1, Write the task'); + assert.equal(await field('next'), 'Step 3, Review the change'); + // The workflow file holds no gate or inputs for a step; the panel says so once instead of inventing them. + assert.match(await b.evaluate(`document.querySelector('[data-wfg-detail]').textContent`), /records no gate or inputs/); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-step="2"]').getAttribute('aria-current')`), 'step'); + assert.equal(await b.evaluate(`document.querySelectorAll('[data-wfg-graph] [aria-current]').length`), 1); + // The last step hands on to nothing; an unknown role is named in the panel too. + await b.evaluate(`document.querySelector('[data-wfg-step="4"]').dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true}));true`); + await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'detail for step 4'); + assert.equal(await field('next'), 'Nothing: this is the last step'); + assert.match(await field('roles'), /human \(a person\)/); + assert.match(await field('roles'), /merge-gate names no current role/); + // Keyboard: a step box is a link that takes focus and opens on Enter. + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-step="1"]').closest('a,[tabindex]') !== null`), true); + // A repaint (the poll) keeps the selection. + const before = reads.length; + await b.evaluate(`document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));true`); + await wait(`document.querySelector('[data-wfg-detail] [data-wfg-detail-title]')?.textContent === 'Approve the merge'`, 'kept after repaint'); + assert.ok(reads.length >= before); + // A project's workflow of the same name is its own graph. + await b.evaluate(`location.hash = '#/orgs/acme/workflows/change?project=site'`); + await wait(`${boxes}.length === 2`, 'project graph'); + assert.match(await b.evaluate(`document.querySelector('#bus-view').textContent`), /Project site/); + // A name the scope does not hold says so. + await b.evaluate(`location.hash = '#/orgs/acme/workflows/nope'`); + await wait(`/No workflow named nope/.test(document.querySelector('#bus-view').textContent)`, 'unknown workflow'); +}); + +test('an organization with no workflows says so on its page and its card, and links to apply a pack', { timeout: 60000 }, async t => { + const { b, wait } = await setup(t, { path: '#/orgs/bare/workflows' }); + await wait(`document.querySelector('[data-wfg-list] [data-wfg-none]')`, 'none note'); + assert.match(await b.evaluate(`document.querySelector('[data-wfg-none]').textContent`), /No workflows in Bare Works yet/); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-list] [data-wfg-apply]').getAttribute('href')`), '#/orgs/bare/organization'); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-list] [data-wfg-item]')`), null); + // The organization page's card says the same and links the list. + await b.evaluate(`location.hash = '#/orgs/bare/organization'`); + await wait(`document.querySelector('[data-org-dashboard] [data-wfg-card] [data-wfg-none]')`, 'card none'); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-all]').getAttribute('href')`), '#/orgs/bare/workflows'); + // Apply a pack takes the person to the pack picker on the same page. + await wait(`document.querySelector('[data-packs]')`, 'packs section'); + await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-apply]').click();true`); + await wait(`(()=>{const r=document.querySelector('[data-packs]').getBoundingClientRect();return r.top>=0&&r.top { + const { b, wait, reads } = await setup(t, { path: '#/orgs/acme/organization' }); + await wait(`document.querySelectorAll('[data-org-dashboard] [data-wfg-card] [data-wfg-card-item]').length === 3`, 'card items'); + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wfg-card] [data-wfg-card-item]')].map(a=>[a.getAttribute('href'),a.textContent.replace(/\\s+/g,' ').trim()])`), [ + ['#/orgs/acme/workflows/change', 'change 4 steps'], + ['#/orgs/acme/workflows/intake', `Intake ${XSS} 1 step`], + ['#/orgs/acme/workflows/change?project=site', 'Site release 2 steps, project site'], + ]); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-all]').getAttribute('href')`), '#/orgs/acme/workflows'); + assert.equal(await b.evaluate(`document.querySelectorAll('[data-org-dashboard] img').length`), 0); + assert.equal(await b.evaluate(`window.__sp9pwn===undefined`), true); + // The card reuses the page's one workflow read; it sends no second one. + assert.equal(reads.filter(id => id === 'acme').length, 1); + await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-card-item]').click();true`); + await wait(`location.hash === '#/orgs/acme/workflows/change' && document.querySelectorAll('[data-wfg-graph] [data-wfg-step]').length === 4`, 'graph from card'); +}); + +test('a stopped organization is not read: the page and card say the bus is stopped with the reason, never "no workflows"', { timeout: 60000 }, async t => { + const { b, wait, reads } = await setup(t, { path: '#/orgs/gamma/workflows' }); + await wait(`document.querySelector('[data-wfg-list] [data-ws-unread]')`, 'unread note'); + const note = await b.evaluate(`document.querySelector('[data-wfg-list] [data-ws-unread]').textContent`); + assert.match(note, /^Not read: Gamma Home/); + assert.match(note, /stopped by the owner/); + assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), /No workflows/); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-none],[data-wfg-item]')`), null); + // The graph address says the same. + await b.evaluate(`location.hash = '#/orgs/gamma/workflows/change'`); + await wait(`document.querySelector('[data-wfg-page] [data-ws-unread]')`, 'graph unread note'); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-graph]')`), null); + assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), /No workflow/); + // The organization page card. + await b.evaluate(`location.hash = '#/orgs/gamma/organization'`); + await wait(`document.querySelector('[data-org-dashboard] [data-wfg-card][data-bus-stopped]')`, 'stopped card'); + assert.match(await b.evaluate(`document.querySelector('[data-wfg-card]').textContent`), /Not read: the bus is stopped\./); + assert.equal(await b.evaluate(`document.querySelector('[data-wfg-card] [data-wfg-none]')`), null); + assert.equal(reads.filter(id => id === 'gamma').length, 0, 'a stopped organization is not read'); +}); + +test('at 390 the graph runs top to bottom inside its box and the page does not scroll sideways', { timeout: 60000 }, async t => { + for (const mode of ['light', 'dark']) { + const dark = mode === 'dark'; + await t.test(mode, async t2 => { + const { b, wait } = await setup(t2, { path: '#/orgs/acme/workflows/change?step=3', viewport: [390, 844], mode }); + await wait(`${boxes}.length === 4 && document.querySelector('[data-wfg-detail-title]')`, 'graph at 390'); + assert.equal(await b.evaluate(`document.documentElement.dataset.mode`), mode, 'the page shows the mode under test'); + const ys = await b.evaluate(`${boxes}.map(g=>g.querySelector('rect').getBoundingClientRect().top)`); + assert.deepEqual([...ys].sort((p, q) => p - q), ys); + assert.equal(new Set(ys).size, 4); + assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true, 'no sideways page scroll'); + // Every box fits the width of its own box. + assert.equal(await b.evaluate(`(()=>{const w=document.querySelector('[data-wfg-graph]').closest('[data-wfg-frame]').getBoundingClientRect();return ${boxes}.every(g=>{const r=g.querySelector('rect').getBoundingClientRect();return r.left>=w.left-1&&r.right<=w.right+1})})()`), true); + // The box text and the edges read in the theme's colours, not the browser default black on dark. + const fill = await b.evaluate(`getComputedStyle(document.querySelector('[data-wfg-step="1"] [data-wfg-title]')).fill`); + const bg = await b.evaluate(`getComputedStyle(document.body).backgroundColor`); + assert.notEqual(fill, bg); + assert.notEqual(fill, dark ? 'rgb(0, 0, 0)' : 'rgb(255, 255, 255)'); + // An SVG rect with no theme fill is drawn black. + assert.notEqual(await b.evaluate(`getComputedStyle(document.querySelector('[data-wfg-step="1"] rect')).fill`), 'rgb(0, 0, 0)'); + // The list and the card fit too. + await b.evaluate(`location.hash = '#/orgs/acme/workflows'`); + await wait(`document.querySelector('[data-wfg-list] [data-wfg-item]')`, 'list at 390'); + assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true, 'list: no sideways page scroll'); + }); + } +});