From 154c2216f30716bea08438ace533db171b237f1d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 12 Nov 2026 18:17:26 -0600 Subject: [PATCH] feat(webui): board labels, pack picker and workflow editor on the organization page (#1873 SP6) The Tasks board takes the selected organization's board: its labels and column order over the five fixed states, and its default layout unless the viewer saved one. The all-organizations view keeps the fixed labels. The organization page gains the owner's starter pack picker (list, preview with marks, flagged lines, credential lines and noHire marks, apply by digest) and a workflow list and editor that marks a step role naming no current role. Every organization- or pack-supplied string goes through esc or textContent. The workspace read carries each organization's and project's board. GET /api/orgs//workflows is the owner's read of the workflows, the role names a step may resolve to and the file revision. packsServes pins HEAD and GET /api/board to the Board read, never the pack route. --- packages/webui/src/cli.mjs | 5 + packages/webui/src/packs-http.mjs | 48 ++++- packages/webui/src/public/index.html | 2 +- packages/webui/src/public/packs.css | 15 ++ packages/webui/src/public/packs.js | 131 ++++++++++++ packages/webui/src/public/tasks-board.js | 43 +++- packages/webui/src/public/workflows.js | 182 ++++++++++++++++ packages/webui/src/public/workspace.js | 4 +- packages/webui/src/serve.mjs | 19 +- packages/webui/src/workspace-read.mjs | 8 +- packages/webui/tests/browser.test.mjs | 2 +- packages/webui/tests/packs-http.test.mjs | 164 ++++++++++++++- .../tests/packs-workflows-browser.test.mjs | 196 ++++++++++++++++++ .../tests/tasks-board-labels-browser.test.mjs | 183 ++++++++++++++++ packages/webui/tests/workspace-read.test.mjs | 12 +- 15 files changed, 977 insertions(+), 37 deletions(-) create mode 100644 packages/webui/src/public/packs.css create mode 100644 packages/webui/src/public/packs.js create mode 100644 packages/webui/src/public/workflows.js create mode 100644 packages/webui/tests/packs-workflows-browser.test.mjs create mode 100644 packages/webui/tests/tasks-board-labels-browser.test.mjs diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index bbd61cff9..6de4b2947 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -21,6 +21,8 @@ import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs'; import { showOrg } from '../../organization/src/org-service.mjs'; import { configDir } from '../../organization/src/organization-config.mjs'; import { loadRootAgents } from '../../organization/src/agent-levels.mjs'; +import { organizationWriter } from '../../organization/src/organization.mjs'; +import { templateHashes } from '../../organization/src/definitions.mjs'; import { consoleReads, redactor } from './reads.mjs'; import { createDevOwner } from './dev-owner.mjs'; import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.mjs'; @@ -199,6 +201,9 @@ try { // Starter packs SP4 (#1873): the owner's pack apply, board and workflow writes, the three write // .console verbs only. Preview and the pack list are reads over requestRead. packsWrite: session.user === undefined && sendOn ? packsCaller(consoleCall({ dataRoot: root, socket: paths.socket, business: id })) : null, + // Starter packs SP6 (#1873): the owner's workflow read, the organization file and its revision as the + // writer reads them (the read `mosaic org workflow set` takes its revision from), and the V2 template names. + workflowsRead: session.user === undefined ? () => ({ ...organizationWriter({ dir: configDir(), rolesDir: rolesDir() }).read(id), templates: templateHashes(rolesDir()).map(t => t.template) }) : null, reads: consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: id, orgRoot: root }), }; }, diff --git a/packages/webui/src/packs-http.mjs b/packages/webui/src/packs-http.mjs index e5369a9e4..6a69fe680 100644 --- a/packages/webui/src/packs-http.mjs +++ b/packages/webui/src/packs-http.mjs @@ -9,12 +9,22 @@ // POST /api/orgs//board {revision, board, project?}: board.set.console (board null removes it) // POST /api/orgs//workflows/ {revision, workflow, project?}: workflow.set.console // DELETE /api/orgs//workflows/ {revision, project?}: workflow.set.console with workflow null +// GET /api/orgs//workflows {workflows, projects, roles, revision, writable} (SP6): the org's and +// each code project's workflows, the role names a step may resolve to +// now (org definitions and V2 templates; `human` always does), and the +// file revision a workflow write sends back // // `read` is the owner's Console call (consoleCall), used for the two verbs that write nothing. `write` // is the owner's Console call fenced to the three write verbs (packsCaller), present only for the // owner's own session under --send. The broker answers every .console verb to the business human's -// console cap alone, and runs the merge, the digest and the held-credential check itself. -export const PACKS_ROUTE = /^\/api\/(?:packs(?:\/(preview|apply))?|board|workflows\/([^/]+))$/; +// console cap alone, and runs the merge, the digest and the held-credential check itself. `workflows` +// (SP6) is the owner's read of the organization file as the writer reads it ({document, revision, +// templates}), the same read `mosaic org workflow set` takes its revision from; no bus verb answers it. +export const PACKS_ROUTE = /^\/api\/(?:packs(?:\/(preview|apply))?|board|workflows(?:\/([^/]+))?)$/; +// Which requests the pack route answers. GET and HEAD /api/board stay the Board read (the Console's own task +// board through its upstream); every other method on /api/board is this route's (POST writes, the rest 405). +// serve.mjs dispatches on this, so the HEAD pin in packs-http.test.mjs tests the predicate it uses. +export const packsServes = (path, method) => PACKS_ROUTE.test(path) && !(path === '/api/board' && (method === 'GET' || method === 'HEAD')); export const PACK_WRITE_VERBS = Object.freeze(['pack.apply.console', 'board.set.console', 'workflow.set.console']); export const packsCaller = call => async (verb, args, options) => { if (!PACK_WRITE_VERBS.includes(verb)) throw new Error(`the pack writer does not call ${verb}`); @@ -51,7 +61,7 @@ async function body(req, required, optional) { return value; } -export async function packsHTTP(req, res, { path, owner, read, write, session, live, origin }) { +export async function packsHTTP(req, res, { path, owner, read, write, workflows = null, session, live, origin }) { const refuse = (status, error) => json(res, status, { error }); const fail = error => { const code = codes[error?.code] ? error.code : 'outcome-unknown'; return refuse(codes[code] ?? 503, code); }; const match = PACKS_ROUTE.exec(path); @@ -61,20 +71,21 @@ export async function packsHTTP(req, res, { path, owner, read, write, session, l try { name = decodeURIComponent(match[2]); } catch { return refuse(400, 'invalid-request'); } if (!NAME.test(name)) return refuse(400, 'invalid-request'); } - const list = path === '/api/packs'; - const allowed = list ? ['GET'] : name !== null ? ['POST', 'DELETE'] : ['POST']; + const list = path === '/api/packs', flows = path === '/api/workflows'; + const allowed = list || flows ? ['GET'] : name !== null ? ['POST', 'DELETE'] : ['POST']; if (!allowed.includes(req.method)) return refuse(405, 'method-not-allowed'); // A method the route never serves answers 405 first; every served method is the root owner's. if (!owner) return refuse(403, 'owner-only'); if (!session) return refuse(401, 'sign-in-required'); - if (!read) return refuse(503, 'not-configured'); - const writes = !list && step !== 'preview'; + if (flows ? !workflows : !read) return refuse(503, 'not-configured'); + const writes = !list && !flows && step !== 'preview'; if (writes && !write) return refuse(403, 'read-only'); const site = one(req, 'sec-fetch-site'), from = one(req, 'origin'); if (req.method !== 'GET' ? from !== origin || site !== 'same-origin' : (req.headersDistinct.origin && from !== origin) || (req.headersDistinct['sec-fetch-site'] && !['same-origin', 'none'].includes(site))) return refuse(403, 'cross-origin'); + if (flows) return workflowsRead(res, { workflows, live, session, writable: Boolean(write) }); let verb, args = {}; if (list) verb = 'packs.console'; else if (step !== null) { @@ -124,3 +135,26 @@ export async function packsHTTP(req, res, { path, owner, read, write, session, l if (!REVISION.test(result.revision ?? '')) return refuse(503, 'outcome-unknown'); return json(res, 200, { revision: result.revision, added: Array.isArray(result.added) ? result.added : [], ...(step === 'apply' ? { pack: result.pack } : {}) }); } + +// SP6: the workflow list and editor's read. Only workflows, the names a step role may resolve to and the +// revision leave; nothing else of the organization file does. A workflow stays as the file holds it, so a +// step naming a role that no longer resolves is shown as written and the page marks it. +async function workflowsRead(res, { workflows, live, session, writable }) { + const refuse = (status, error) => json(res, status, { error }); + let current; + try { current = await workflows(); } catch { return refuse(503, 'org-unavailable'); } + const doc = current?.document; + if (!plain(doc) || !REVISION.test(current.revision ?? '') || !Array.isArray(current.templates)) return refuse(503, 'outcome-unknown'); + // A sign-out while the file was read shows nothing. + if (live() !== session) return refuse(401, 'sign-in-required'); + const flowsOf = scope => (plain(scope?.workflows) ? scope.workflows : {}); + const projects = {}; + for (const [id, entry] of Object.entries(plain(doc.projects) ? doc.projects : {})) { + if (plain(entry) && entry.kind !== 'library') projects[id] = flowsOf(entry); + } + const roles = { + definitions: Object.keys(plain(doc.definitions) ? doc.definitions : {}).sort(), + templates: current.templates.filter(n => typeof n === 'string').sort(), + }; + return json(res, 200, { workflows: flowsOf(doc), projects, roles, revision: current.revision, writable }); +} diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index c9598e0ef..c1b33382a 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/packs.css b/packages/webui/src/public/packs.css new file mode 100644 index 000000000..919458330 --- /dev/null +++ b/packages/webui/src/public/packs.css @@ -0,0 +1,15 @@ +[data-packs],[data-workflows]{margin-top:16px} +:is(.pack-list,.pack-hires,.wf-list){list-style:none;margin:8px 0;padding:0} +:is(.pack-row,.pack-hire,.wf-item){border-top:1px solid var(--border);padding:12px 0;overflow-wrap:anywhere} +.pack-preview{border:1px solid var(--border);border-radius:var(--r);padding:12px;margin:12px 0;overflow-wrap:anywhere} +.pack-preview .ws-tbl{overflow-x:auto} +.pack-nohire,.wf-missing,.wf-note{color:var(--danger)} +.wf-missing{font-size:.9em} +.wf-steps{margin:6px 0;padding-left:22px}.wf-steps li{margin:4px 0} +.wf-editor{display:grid;gap:12px;margin:12px 0} +.wf-step{border:1px solid var(--border);border-radius:var(--r);padding:12px;display:grid;gap:10px;min-width:0} +:is(.wf-editor,[data-packs]) label{display:flex;flex-direction:column;gap:6px;min-width:0} +:is(.wf-editor,[data-packs]) :is(select,input,textarea){width:100%;box-sizing:border-box;min-height:44px;padding:8px 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--canvas);color:var(--text);font:inherit} +:is([data-packs],[data-workflows]) .btn{min-height:44px;margin:4px 8px 4px 0} +:is([data-packs],[data-workflows]) :is(button,select,input,textarea):focus-visible{outline:2px solid var(--focus);outline-offset:2px} +:is([data-packs],[data-workflows]) [role=status]{overflow-wrap:anywhere} diff --git a/packages/webui/src/public/packs.js b/packages/webui/src/public/packs.js new file mode 100644 index 000000000..04eac991e --- /dev/null +++ b/packages/webui/src/public/packs.js @@ -0,0 +1,131 @@ +// Starter packs SP6 (#1873, design section 8): the owner's pack picker on an organization's page. +// It lists the shipped and installed packs (GET /api/orgs//packs), previews one through +// pack.preview (POST .../packs/preview, writes nothing) and applies the previewed plan by its digest +// (POST .../packs/apply). The bus builds the plan, the marks, the flagged and credential lines and the +// noHire marks; this page only shows them. Every pack- or organization-supplied string goes through esc. +(() => { + 'use strict'; + const esc = x => String(x ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const base = org => `/api/orgs/${encodeURIComponent(org)}`; + const states = new Map(); + const guidance = { + 'pack-plan-stale': 'The organization or the pack changed since this preview. Preview again before applying.', + 'revision-conflict': 'The organization changed. Preview again before applying.', + 'organization-busy': 'The organization is busy with another write. Try again shortly.', + 'starter-pack-not-found': 'This pack is no longer installed. Reload the page.', + 'starter-pack-template-changed': 'A role template this pack pins has changed. The pack needs a new version.', + 'starter-pack-invalid': 'This pack does not pass its checks against the organization.', + 'definition-invalid': 'A role in this pack does not pass the organization checks.', + 'project-not-found': 'That project is not in the organization file.', + 'credential-leak': 'The pack carries a value the credential store holds. It cannot be applied.', + 'read-only': 'This Console was started without send. Packs can be previewed, not applied.', + 'owner-only': 'Only the owner can use starter packs.', + 'sign-in-required': 'Sign in again to use starter packs.', + 'cross-origin': 'The request did not come from this Console.', + 'not-configured': 'This Console has no bus connection for packs.', + 'no-bus-host': 'The organization bus is not running.', + 'invalid-request': 'The bus refused the request as malformed.', + }; + const MARKS = { + add: 'will be added', + same: 'already the same', + kept: 'kept: the organization has its own version', + removed: 'stays removed: you removed it after an earlier apply', + }; + async function call(url, body) { + const r = await fetch(url, body === undefined ? { cache: 'no-store' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + let value = null; + try { value = await r.json(); } catch { value = null; } + if (!r.ok) throw Error(guidance[value?.error] ?? `The bus refused: ${value?.error ?? 'outcome unknown'}.`); + return value; + } + async function load(org) { + const s = { packs: null, writable: false, error: null, projects: null, preview: null, status: '' }; + try { + const value = await call(`${base(org)}/packs`); + s.packs = Array.isArray(value?.packs) ? value.packs.filter(p => p && typeof p === 'object' && p.pack && typeof p.pack === 'object') : []; + s.writable = value?.writable === true; + } catch (error) { s.error = error.message; } + // A project pack needs a code project. The workflow read lists them; when it fails, a project pack + // says so instead of guessing. + if (s.packs?.some(p => p.pack.scope === 'project')) { + try { const w = await call(`${base(org)}/workflows`); s.projects = w && typeof w.projects === 'object' ? Object.keys(w.projects).sort() : null; } catch { s.projects = null; } + } + states.set(org, s); + } + const markText = m => MARKS[m] ?? m; + function itemRow(item) { + const what = item.item === 'board' ? 'Board' : item.item === 'definition' ? 'Role' : item.item === 'workflow' ? 'Workflow' : item.item; + const name = item.item === 'board' ? '' : item.target !== item.name ? `${esc(item.target)} from ${esc(item.name)}` : esc(item.target); + const diff = item.diff?.primitives?.length ? ` differs in ${item.diff.primitives.map(esc).join(', ')}` : ''; + return `${esc(what)}${name}${esc(item.path)}${esc(markText(item.mark))}${diff}`; + } + function hireBlock(h) { + const flagged = h.flagged === null + ? '

No chief to compare against: every line will need the owner.

' + : Array.isArray(h.flagged) && h.flagged.length + ? `

Flagged lines, which only the owner approves:

    ${h.flagged.map(l => `
  • ${esc(l.field)} ${esc(l.value)} ${(l.reasons || []).map(r => r === 'above-chief' ? 'above the chief' : r === 'resolve-right' ? 'a resolve right' : r).map(esc).join(', ')}
  • `).join('')}
` + : '

No flagged lines.

'; + const creds = Array.isArray(h.credentialLines) && h.credentialLines.length + ? `

Credential lines:

    ${h.credentialLines.map(([service, scope]) => `
  • ${esc(service)} ${esc(scope)}
  • `).join('')}
` + : '

No credential lines.

'; + const mark = h.mark === 'dropped' ? 'Not proposed: an agent or open hire decision already has this name.' : 'Recorded on apply for the chief to raise as a hire decision.'; + const noHire = h.noHire ? '

On this organization\'s no-hire list: the decision will say so.

' : ''; + return `
  • ${esc(h.name)} role ${esc(h.definition)}

    ${esc(mark)}

    ${noHire}${flagged}${creds}
  • `; + } + function previewBlock(s) { + const p = s.preview; + if (!p) return ''; + const v = p.value; + const items = Array.isArray(v.items) && v.items.length ? `
    ${v.items.map(itemRow).join('')}
    ItemNamePathPlan
    ` : '

    The pack has no items.

    '; + const hires = Array.isArray(v.hires) && v.hires.length ? `

    Hires

      ${v.hires.map(hireBlock).join('')}
    ` : '

    The pack proposes no hires.

    '; + const skills = v.skills?.notEnabled?.length ? `

    Skills this machine has not enabled: ${v.skills.notEnabled.map(n => `${esc(n)}`).join(', ')}

    ` : ''; + const chief = v.chief === false ? '

    The organization has no chief, so no line is compared against one.

    ' : ''; + const apply = s.writable ? `` : '

    This Console was started without send. Packs can be previewed, not applied.

    '; + return `

    Preview: ${esc(v.pack?.label ?? p.pack)}${v.project ? ` for project ${esc(v.project)}` : ''}

    ${items}${hires}${skills}${chief}${apply}
    `; + } + function packRow(org, s, entry) { + const pack = entry.pack; + const project = pack.scope === 'project' + ? s.projects === null ? '

    The project list could not be read.

    ' : s.projects.length + ? `` + : '

    No code project to apply this pack to.

    ' + : ''; + const canPreview = pack.scope !== 'project' || (s.projects && s.projects.length); + return `
  • ${esc(pack.label ?? entry.id)} ${esc(entry.id)} ${esc(pack.version)} · ${esc(entry.origin)} · ${pack.scope === 'project' ? 'project pack' : 'organization pack'} +

    ${esc(pack.description)}

    ${project}${canPreview ? '' : ''}
  • `; + } + function section(org) { + const s = states.get(org); + if (!s || s.error) return `

    Starter packs

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

    `; + const list = s.packs.length ? `
      ${s.packs.map(e => packRow(org, s, e)).join('')}
    ` : '

    No starter packs are installed.

    '; + return `

    Starter packs

    A pack adds roles, a board and workflows to the organization. Preview shows what it would change; apply adds only what is missing, and a hire it proposes is only raised by the chief, as a decision.

    ${list}${previewBlock(s)}

    ${esc(s.status)}

    `; + } + function redraw(org) { + const el = [...document.querySelectorAll('[data-packs-org]')].find(n => n.dataset.packsOrg === org); + if (el) el.outerHTML = section(org); + } + document.addEventListener('click', async e => { + const button = e.target.closest('[data-pack-preview-button],[data-pack-apply]'); + if (!button) return; + const root = button.closest('[data-packs-org]'), org = root?.dataset.packsOrg, s = states.get(org); + if (!s) return; + button.disabled = true; + try { + if (button.hasAttribute('data-pack-preview-button')) { + const row = button.closest('[data-pack]'), pack = row.dataset.pack, project = row.querySelector('[data-pack-project]')?.value; + const body = { pack, ...(project ? { project } : {}) }; + s.preview = { pack, project: project ?? null, value: await call(`${base(org)}/packs/preview`, body) }; + s.status = ''; + } else { + const p = s.preview; + const r = await call(`${base(org)}/packs/apply`, { pack: p.pack, digest: p.value.digest, ...(p.project ? { project: p.project } : {}) }); + const added = Array.isArray(r.added) && r.added.length ? r.added.join(', ') : 'nothing new'; + s.preview = null; + s.status = `Applied. Added ${added}. Proposed hires are recorded for the chief to raise as decisions.`; + } + } catch (error) { s.status = error.message; } + redraw(org); + }); + window.MosaicPacks = { load, section }; +})(); diff --git a/packages/webui/src/public/tasks-board.js b/packages/webui/src/public/tasks-board.js index e99c688d0..f93df90b5 100644 --- a/packages/webui/src/public/tasks-board.js +++ b/packages/webui/src/public/tasks-board.js @@ -20,6 +20,13 @@ const save = (key, value) => { try { localStorage.setItem(key, value); } catch {} }; const STATES = [['todo', 'To do'], ['in-progress', 'In progress'], ['in-review', 'Review'], ['blocked', 'Blocked'], ['done', 'Done']]; const STATE_LABEL = Object.fromEntries(STATES); + // Starter packs SP6 (#1873): an organization's or project's board style relabels, reorders and sets + // the default layout over the five fixed states (Jason 11-12 Q3); the states themselves never change. + // It applies only while the view is narrowed to one organization or project, so the all-orgs view + // keeps the fixed labels. A board that is not exactly that shape is ignored, never half applied. + const boardOk = b => Boolean(b) && typeof b === 'object' && Array.isArray(b.columns) && b.columns.length === STATES.length + && b.columns.every(c => c && typeof c === 'object' && Object.hasOwn(STATE_LABEL, c.state) && typeof c.label === 'string' && c.label.trim() !== '' && c.label.length <= 32) + && new Set(b.columns.map(c => c.state)).size === STATES.length && (b.layout === 'kanban' || b.layout === 'list'); const UNREAD = 'unread'; const REQUIREMENT = '^REQ-[A-Z]+-[1-9][0-9]*$'; const ORG_STATE = { down: ['s-offline', '○', 'bus down'], timeout: ['s-waiting', '◐', 'no answer in time'], invalid: ['s-error', '▲', 'invalid answer'], starting: ['s-waiting', '◐', 'starting'] }; @@ -28,8 +35,24 @@ // The layout is remembered per viewer and per width: a phone opens on List until its viewer picks // Kanban, a wide screen on Kanban until its viewer picks List. const layouts = (() => { try { const v = JSON.parse(load(LAYOUT) || '{}'); return v && typeof v === 'object' ? v : {}; } catch { return {}; } })(); - const layoutNow = () => { const v = layouts[phone() ? 'phone' : 'wide']; return v === 'kanban' || v === 'list' ? v : phone() ? 'list' : 'kanban'; }; const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '' }; + // The board style in force: the filtered project's when it has one, else the one organization's the + // view is narrowed to (by the org chip or by a project of that org), else none. + function activeBoard() { + if (!snap) return null; + const m = /^([^:]+):(.+)$/.exec(filter.project); + const org = filter.org || (m ? m[1] : ''); + if (!org) return null; + const project = m && m[1] === org ? snap.projects.find(p => p.org === org && p.id === m[2]) : null; + if (boardOk(project?.board)) return project.board; + const o = snap.orgs.find(x => x.id === org); + return boardOk(o?.board) ? o.board : null; + } + // The five states as [state, label] in the board's order, or the fixed ones. + const states = () => { const b = activeBoard(); return b ? b.columns.map(c => [c.state, c.label]) : STATES; }; + const stateLabel = s => states().find(([v]) => v === s)?.[1]; + // The viewer's saved choice for this width wins; then the board's layout; then the width's default. + const layoutNow = () => { const v = layouts[phone() ? 'phone' : 'wide']; return v === 'kanban' || v === 'list' ? v : activeBoard()?.layout ?? (phone() ? 'list' : 'kanban'); }; // `opened` is the task as the edit dialog showed it: Save diffs against it and sends its digest, so a // poll that reads a newer task while the dialog is open turns a stale save into a task-conflict. let snap = null, canWrite = false, notice = null, dragging = null, dragEnded = null, opened = null; @@ -120,7 +143,7 @@ const roles = [...new Set(scope.flatMap(o => o.roles).concat(snap.tasks.filter(t => !filter.org || t.org === filter.org).flatMap(t => t.assignees)))].sort(); if (filter.assignee && filter.assignee !== '-' && !roles.includes(filter.assignee)) roles.push(filter.assignee); const assigneeOpts = opt('', 'Anyone', filter.assignee) + opt('-', 'Nobody', filter.assignee) + roles.map(r => opt(r, r, filter.assignee)).join(''); - const stateOpts = opt('', 'Any', filter.state) + STATES.map(([v, l]) => opt(v, l, filter.state)).join('') + (snap.tasks.some(t => stateOf(t) === UNREAD) ? opt(UNREAD, 'State not read', filter.state) : ''); + const stateOpts = opt('', 'Any', filter.state) + states().map(([v, l]) => opt(v, l, filter.state)).join('') + (snap.tasks.some(t => stateOf(t) === UNREAD) ? opt(UNREAD, 'State not read', filter.state) : ''); const labels = [...new Set(snap.tasks.flatMap(t => t.labels.map(String)))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); const labelSel = labels.length ? select('label', 'Label', opt('', 'Any', filter.label) + labels.map(l => opt(l, l, filter.label)).join(''), 'tb-label') : ''; return `
    @@ -144,7 +167,7 @@ `; } function kanban(rows) { - const cols = STATES.concat(rows.some(t => stateOf(t) === UNREAD) ? [[UNREAD, 'State not read']] : []); + const cols = states().concat(rows.some(t => stateOf(t) === UNREAD) ? [[UNREAD, 'State not read']] : []); return `
    ${cols.map(([v, l]) => { const inCol = rows.filter(t => stateOf(t) === v), orgs = [...new Set(inCol.map(t => t.org))]; const drop = v !== UNREAD ? ` data-tb-col="${v}"` : ''; @@ -152,12 +175,12 @@ ${v === UNREAD ? '

    Vikunja does not tell the bus which bucket an open task is in.

    ' : ''}${inCol.map(card).join('') || '

    No tasks

    '}`; }).join('')}
    `; } - const order = t => { const i = STATES.findIndex(([v]) => v === t.state); return i < 0 ? STATES.length : i; }; function list(rows) { + const cols = states(), order = t => { const i = cols.findIndex(([v]) => v === t.state); return i < 0 ? cols.length : i; }; const sorted = [...rows].sort((a, b) => order(a) - order(b) || a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref, undefined, { numeric: true })); const stateCell = t => { - if (!taskWritable(t)) return esc(STATE_LABEL[t.state] || 'Not read'); - return ``; + if (!taskWritable(t)) return esc(stateLabel(t.state) || 'Not read'); + return ``; }; return `
    ${sorted.map(t => ` @@ -280,9 +303,9 @@ async function move(k, state) { const t = find(k); if (!t || !taskWritable(t) || t.state === state) return; - notice = { text: `Moving ${shortId(t.ref)} to ${STATE_LABEL[state]}…` }; paint(); + notice = { text: `Moving ${shortId(t.ref)} to ${stateLabel(state)}…` }; paint(); const r = await write({ op: 'edit', org: t.org, task_ref: t.ref, expect: t.digest, state }); - notice = r.ok ? { text: `${shortId(t.ref)} moved to ${STATE_LABEL[state]} in ${orgOf(t.org).label}.` } : { error: true, text: r.unknown ? `${shortId(t.ref)}: ${r.message}` : `${shortId(t.ref)} was not moved. ${r.message}` }; + notice = r.ok ? { text: `${shortId(t.ref)} moved to ${stateLabel(state)} in ${orgOf(t.org).label}.` } : { error: true, text: r.unknown ? `${shortId(t.ref)}: ${r.message}` : `${shortId(t.ref)} was not moved. ${r.message}` }; try { await reload(); } catch {} paint(); } @@ -303,7 +326,7 @@ ? `
    Assignees${roles.map(r => ``).join('')}
    ` : '

    Assignees: this organization reports no roles.

    '); const prioSelect = p => ``; - const stateSelect = s => ``; + const stateSelect = s => ``; function projectOptions(org, current) { return `${snap.projects.filter(p => p.org === org).map(p => ``).join('')}`; } @@ -329,7 +352,7 @@ const facts = `

    ${av(t.org, 'xs')}${esc(o.label)} · ${esc(shortId(t.ref))} ${trk(t.tracker)}${t.requirement ? ` · ${esc(t.requirement)}` : ''}${projectName(t) ? ` · ${esc(projectName(t))}` : ''}

    `; if (!w) { return `

    ${title(t)}

    ${facts} -
    State
    ${esc(STATE_LABEL[t.state] || 'Not read')}
    Assignees
    ${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}
    Priority
    ${prio(t) || 'none'}
    Due
    ${due(t) || 'none'}
    +
    State
    ${esc(stateLabel(t.state) || 'Not read')}
    Assignees
    ${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}
    Priority
    ${prio(t) || 'none'}
    Due
    ${due(t) || 'none'}

    ${esc(why)}

    ${refusal ? `

    ${esc(refusal)}

    ` : ''}
    `; diff --git a/packages/webui/src/public/workflows.js b/packages/webui/src/public/workflows.js new file mode 100644 index 000000000..f83523b70 --- /dev/null +++ b/packages/webui/src/public/workflows.js @@ -0,0 +1,182 @@ +// Starter packs SP6 (#1873, design section 8): the owner's workflow list and editor on an +// organization's page. GET /api/orgs//workflows reads the org's and each code project's workflows +// with the file revision; a save is POST .../workflows/ {revision, workflow, project?} and a delete +// DELETE .../workflows/ {revision, project?} (workflow.set.console). The bus checks the shape; this +// page marks a step role that resolves to no org definition or role template (and is not `human`) as +// naming no current role. Every organization-supplied string goes through esc. +(() => { + 'use strict'; + const esc = x => String(x ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const base = org => `/api/orgs/${encodeURIComponent(org)}`; + const STATES = [['todo', 'To do'], ['in-progress', 'In progress'], ['in-review', 'Review'], ['blocked', 'Blocked'], ['done', 'Done']]; + const STATE_LABEL = Object.fromEntries(STATES); + const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/; + const MISSING = 'names no current role'; + const states = new Map(); + const guidance = { + 'revision-conflict': 'The organization changed since this page read it. Reload the page before saving.', + 'organization-busy': 'The organization is busy with another write. Try again shortly.', + 'invalid-request': 'The bus refused the workflow. Check each step has a title, a state and one to eight distinct roles.', + 'project-not-found': 'That project is not in the organization file.', + 'credential-leak': 'The workflow carries a value the credential store holds.', + 'read-only': 'This Console was started without send. Workflows can be read, not changed.', + 'owner-only': 'Only the owner can read and change workflows.', + 'sign-in-required': 'Sign in again to read workflows.', + 'cross-origin': 'The request did not come from this Console.', + 'not-configured': 'This Console cannot read the organization file.', + 'org-unavailable': 'The organization file could not be read.', + 'no-bus-host': 'The organization bus is not running.', + }; + async function call(url, method, body) { + const r = await fetch(url, method === 'GET' ? { cache: 'no-store' } : { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + let value = null; + try { value = await r.json(); } catch { value = null; } + if (!r.ok) throw Error(guidance[value?.error] ?? `The bus refused: ${value?.error ?? 'outcome unknown'}.`); + return value; + } + const plain = v => v !== null && typeof v === 'object' && !Array.isArray(v); + async function read(org, s) { + const value = await call(`${base(org)}/workflows`, 'GET'); + if (!plain(value) || !plain(value.workflows) || !plain(value.projects) || !plain(value.roles) || typeof value.revision !== 'string') throw Error('The workflow read returned an unexpected answer.'); + s.data = value; + s.known = new Set([...(value.roles.definitions || []), ...(value.roles.templates || []), 'human']); + } + async function load(org) { + const s = { data: null, known: new Set(), error: null, editing: null, confirm: null, status: '' }; + try { await read(org, s); } catch (error) { s.error = error.message; } + states.set(org, s); + } + const scopes = s => [['', 'Organization', s.data.workflows], ...Object.keys(s.data.projects).sort().map(p => [p, `Project ${p}`, s.data.projects[p]])]; + const roleChip = (s, role) => s.known.has(role) + ? `${esc(role)}` + : `${esc(role)} ${MISSING}`; + function stepItem(s, step) { + const roles = Array.isArray(step?.roles) ? step.roles : []; + return `
  • ${esc(step?.title)} ${esc(STATE_LABEL[step?.state] ?? step?.state)} ${roles.map(r => roleChip(s, r)).join(', ')}
  • `; + } + function workflowItem(org, s, scope, name, w) { + const steps = Array.isArray(w?.steps) ? w.steps : []; + const confirm = s.confirm && s.confirm.scope === scope && s.confirm.name === name; + const actions = !s.data.writable ? '' : confirm + ? `

    Delete this workflow?

    ` + : `

    `; + return `
  • ${esc(w?.title || name)} ${esc(name)} + ${w?.description ? `

    ${esc(w.description)}

    ` : ''}
      ${steps.map(st => stepItem(s, st)).join('')}
    ${actions}
  • `; + } + function stepRow(s, step, i) { + const roles = step.roles.split(',').map(r => r.trim()).filter(Boolean); + const missing = roles.filter(r => !s.known.has(r)); + return `
    Step ${i + 1} + + + +

    ${missing.length ? `${missing.map(r => `${esc(r)}`).join(', ')} ${MISSING}` : ''}

    +
    `; + } + function editor(s) { + const e = s.editing; + if (!e) return ''; + const scopeField = e.isNew + ? `` + : `

    ${e.scope ? `Project ${esc(e.scope)}` : 'Organization'}, ${esc(e.name)}

    `; + return `

    ${e.isNew ? 'New workflow' : 'Edit workflow'}

    ${scopeField} + + + ${e.draft.steps.map((st, i) => stepRow(s, st, i)).join('')} +

    +

    Roles name an organization role, a role template, or human.

    `; + } + function section(org) { + const s = states.get(org); + if (!s || s.error) return `

    Workflows

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

    `; + const lists = scopes(s).map(([scope, label, flows]) => { + const names = Object.keys(flows || {}).sort(); + return `

    ${esc(label)}

    ${names.length ? `
      ${names.map(n => workflowItem(org, s, scope, n, flows[n])).join('')}
    ` : '

    No workflows.

    '}
    `; + }).join(''); + const add = s.data.writable ? (s.editing ? '' : '

    ') : '

    Only the owner, on a Console started with send, can change workflows.

    '; + return `

    Workflows

    A workflow lists the steps a task goes through, the state each step puts it in and the roles that do it.

    ${lists}${add}${editor(s)}

    ${esc(s.status)}

    `; + } + function redraw(org) { + const el = [...document.querySelectorAll('[data-workflows-org]')].find(n => n.dataset.workflowsOrg === org); + if (el) el.outerHTML = section(org); + } + // The editor's current values back into the draft, so adding or removing a step keeps what was typed. + function capture(root, s) { + const form = root.querySelector('[data-wf-editor]'); + if (!form || !s.editing) return; + const e = s.editing; + if (e.isNew) { e.scope = form.elements.scope?.value ?? e.scope; e.name = form.elements.name?.value ?? e.name; } + e.draft.title = form.querySelector(':scope > label > input[name="title"]').value; + e.draft.description = form.elements.description.value; + e.draft.steps = [...form.querySelectorAll('[data-wf-edit-step]')].map(f => ({ title: f.querySelector('[name="title"]').value, state: f.querySelector('[name="state"]').value, roles: f.querySelector('[name="roles"]').value })); + } + const flowsIn = (s, scope) => scope ? s.data.projects[scope] : s.data.workflows; + const target = (s, scope) => scope ? { project: scope } : {}; + async function reload(org, s, status) { + try { await read(org, s); s.status = status; } catch (error) { s.status = `${status} ${error.message}`; } + } + document.addEventListener('click', async ev => { + const button = ev.target.closest('[data-wf-new],[data-wf-edit],[data-wf-cancel],[data-wf-step-add],[data-wf-step-remove],[data-wf-delete],[data-wf-delete-yes],[data-wf-delete-no]'); + if (!button) return; + const root = button.closest('[data-workflows-org]'), org = root?.dataset.workflowsOrg, s = states.get(org); + if (!s?.data) return; + const item = button.closest('[data-wf]'), scope = item?.dataset.wfScope ?? '', name = item?.dataset.wf; + if (button.hasAttribute('data-wf-new')) { + s.editing = { isNew: true, scope: '', name: '', draft: { title: '', description: '', steps: [{ title: '', state: 'todo', roles: '' }] } }; + } else if (button.hasAttribute('data-wf-edit')) { + const w = flowsIn(s, scope)?.[name]; + s.editing = { isNew: false, scope, name, draft: { title: w?.title ?? '', description: w?.description ?? '', steps: (w?.steps ?? []).map(st => ({ title: st.title ?? '', state: st.state ?? 'todo', roles: (st.roles ?? []).join(', ') })) } }; + } else if (button.hasAttribute('data-wf-cancel')) { + s.editing = null; + } else if (button.hasAttribute('data-wf-step-add')) { + capture(root, s); s.editing.draft.steps.push({ title: '', state: 'todo', roles: '' }); + } else if (button.hasAttribute('data-wf-step-remove')) { + capture(root, s); s.editing.draft.steps.splice(Number(button.closest('[data-wf-edit-step]').dataset.wfEditStep), 1); + } else if (button.hasAttribute('data-wf-delete')) { + s.confirm = { scope, name }; + } else if (button.hasAttribute('data-wf-delete-no')) { + s.confirm = null; + } else if (button.hasAttribute('data-wf-delete-yes')) { + button.disabled = true; + try { + await call(`${base(org)}/workflows/${encodeURIComponent(name)}`, 'DELETE', { revision: s.data.revision, ...target(s, scope) }); + s.confirm = null; + await reload(org, s, `Deleted ${name}.`); + } catch (error) { s.status = error.message; } + } + redraw(org); + }); + document.addEventListener('input', ev => { + const field = ev.target.closest('[data-wf-edit-step] [name="roles"]'); + if (!field) return; + const s = states.get(field.closest('[data-workflows-org]')?.dataset.workflowsOrg); + if (!s) return; + const missing = field.value.split(',').map(r => r.trim()).filter(r => r && !s.known.has(r)); + // textContent and created nodes only: a typed name never becomes markup. + const note = field.closest('[data-wf-edit-step]').querySelector('[data-wf-step-missing]'); + note.textContent = missing.length ? `${missing.join(', ')} ${MISSING}` : ''; + }); + document.addEventListener('submit', async ev => { + if (!ev.target.matches('[data-wf-editor]')) return; + ev.preventDefault(); + const root = ev.target.closest('[data-workflows-org]'), org = root?.dataset.workflowsOrg, s = states.get(org); + if (!s?.editing) return; + capture(root, s); + const e = s.editing; + if (!NAME.test(e.name)) { s.status = 'A workflow name is 1 to 64 lowercase letters, digits, dots, dashes or underscores, starting with a letter or digit.'; redraw(org); return; } + const workflow = { + ...(e.draft.title.trim() ? { title: e.draft.title.trim() } : {}), + ...(e.draft.description.trim() ? { description: e.draft.description.trim() } : {}), + steps: e.draft.steps.map(st => ({ title: st.title.trim(), state: st.state, roles: st.roles.split(',').map(r => r.trim()).filter(Boolean) })), + }; + const button = ev.target.querySelector('button[type="submit"]'); + button.disabled = true; + try { + await call(`${base(org)}/workflows/${encodeURIComponent(e.name)}`, 'POST', { revision: s.data.revision, workflow, ...target(s, e.scope) }); + s.editing = null; + await reload(org, s, `Saved ${e.name}.`); + } catch (error) { s.status = error.message; } + redraw(org); + }); + window.MosaicWorkflows = { load, section }; +})(); diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index c55633e34..56307107a 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -207,7 +207,7 @@ const org = list?.find(o => o.id === id && !o.invalid); if (!org) return `

    Organization unavailable

    ${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}

    `; const types = window.MosaicSystemTypes || null; - const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), types ? types.load(id) : null, window.MosaicAutonomy?.load(id)]); + const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), types ? types.load(id) : null, window.MosaicAutonomy?.load(id), window.MosaicPacks?.load(id), window.MosaicWorkflows?.load(id)]); const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null); const business = value(0), settings = value(1), instances = instancesOf(value(2)); const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5)); @@ -269,6 +269,8 @@ ${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null, types })} ${types ? types.section(id, { pane, idPrefix: opts.idPrefix || '' }) : ''} ${window.MosaicAutonomy?.section(id)??''} + ${window.MosaicPacks?.section(id) ?? ''} + ${window.MosaicWorkflows?.section(id) ?? ''} `; diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 14ef40970..0b9301569 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -21,7 +21,7 @@ import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } fro import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs'; import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs'; import { SYSTEM_TYPES_ROUTE, systemTypesHTTP } from './system-types-http.mjs'; -import { PACKS_ROUTE, packsHTTP } from './packs-http.mjs'; +import { PACKS_ROUTE, packsHTTP, packsServes } from './packs-http.mjs'; import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs'; import { consoleRestartHTTP } from './console-restart-http.mjs'; import { workspaceHTTP } from './workspace-read.mjs'; @@ -83,6 +83,9 @@ const files = new Map([ // Phase P2 (#1873): the owner's System types control (dashboard section and project selection). ['/autonomy.js', ['autonomy.js', 'text/javascript; charset=utf-8']], ['/autonomy.css', ['autonomy.css', 'text/css; charset=utf-8']], + ['/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']], ['/system-types.js', ['system-types.js', 'text/javascript; charset=utf-8']], ['/system-types.css', ['system-types.css', 'text/css; charset=utf-8']], ...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]), @@ -863,7 +866,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF let boundAddress = null, boundPort = null; const server = createServer(async (req, res) => { // A granted user's request rebinds these below; every other request keeps the owner's. - let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [], autonomyWrite = null, packsWrite = null; + let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [], autonomyWrite = null, packsWrite = null, workflowsRead = null; res.setHeader('x-content-type-options', 'nosniff'); res.setHeader('referrer-policy', 'no-referrer'); // Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and @@ -935,7 +938,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF // No CORS. JSON content type and same-origin checks keep browser forms out. const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), systemTypesPath = SYSTEM_TYPES_ROUTE.test(path), autonomyPath = AUTONOMY_ROUTE.test(path), // GET and HEAD /api/board stay the Board read; every other method on it is the pack route's (POST writes, the rest 405). - packsPath = PACKS_ROUTE.test(path) && !(path === '/api/board' && (req.method === 'GET' || req.method === 'HEAD')); + packsPath = packsServes(path, req.method); if (req.headers.origin && req.headers.origin !== (named ? (sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`) : `http://${req.headers.host}`)) return decisionPath ? decisionRefuse(res, 'cross-origin') : requestPath ? requestRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' }); let signer = preview, ssoSession = null; @@ -960,7 +963,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF const instanceAction = originalPath === '/api/console/restart'; if (session && !session.rootOwner && session.user === undefined && orgId === null && !orgList && !hook?.publicRead && !instanceAction && path !== '/api/config' && path !== '/healthz' && !files.has(path)) return json(res, 404, { error: 'not-found' }); if (session && !session.rootOwner && session.user === undefined) { - bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = autonomyWrite = packsWrite = upstream = null; + bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = autonomyWrite = packsWrite = workflowsRead = upstream = null; } if (session?.user !== undefined && !session.rootOwner && !instanceAction) { // #1873: proxy setup answers a grant holder the way the bus answers an agent. @@ -968,7 +971,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' }); if (!organizations && userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' }); if (!organizations) ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session)); - reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null; systemTypesWrite = null; packsWrite = null; + reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null; systemTypesWrite = null; packsWrite = null; workflowsRead = null; chatGrants = sso.chatGrants?.(session) ?? []; } } @@ -999,7 +1002,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF ({ bus = null, reads = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, - processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, autonomyWrite = null, packsWrite = null, upstream = null } = selected); + processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, autonomyWrite = null, packsWrite = null, workflowsRead = null, upstream = null } = selected); } // F3: the owner's settings writes reach the selected organization's bus too (limits, routing, // agents, assistant, instances, model proxy). The routes below keep their own checks. @@ -1078,10 +1081,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF // Starter packs SP4 (#1873): the owner's pack, board and workflow routes on the selected organization. if (packsPath) { const owner = !(tester || granted || !named || sso === null); - return packsHTTP(req, res, { path, owner, read: requestRead, write: send ? packsWrite : null, + return packsHTTP(req, res, { path, owner, read: requestRead, write: send ? packsWrite : null, workflows: workflowsRead, 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', '/system-types.js', '/system-types.css'].includes(path)) return json(res, 403, { error: 'owner-only' }); + if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/system-types.js', '/system-types.css', '/packs.js', '/packs.css', '/workflows.js'].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/src/workspace-read.mjs b/packages/webui/src/workspace-read.mjs index ca0b1f4db..69b22e665 100644 --- a/packages/webui/src/workspace-read.mjs +++ b/packages/webui/src/workspace-read.mjs @@ -137,9 +137,12 @@ function median(values) { // One org's bus state and reads. Never throws: every failure becomes the org's state. async function readOrg(member, needs, { timeoutMs, now, ctx }) { const started = now(); - const head = { org: member.id, label: member.id, theme: member.doc?.appearance?.theme ?? null, tracker: member.doc?.tracker ? 'vikunja' : 'built-in', roles: Object.keys(member.doc?.roles ?? {}).sort() }; + const head = { org: member.id, label: member.id, theme: member.doc?.appearance?.theme ?? null, tracker: member.doc?.tracker ? 'vikunja' : 'built-in', roles: Object.keys(member.doc?.roles ?? {}).sort(), + // Starter packs SP6 (#1873): the org's board style (labels, order and a default layout over the five + // fixed states), as the organization file holds it; the tasks page applies it only to a one-org view. + board: member.doc?.board ?? null }; const finish = (state, extra = {}) => ({ ...head, state, at: iso(now()), readMs: now() - started, ...extra }); - if (member.invalid) return { org: { ...head, roles: [], state: 'invalid', at: iso(now()), readMs: 0, reason: member.reason } }; + if (member.invalid) return { org: { ...head, roles: [], board: null, state: 'invalid', at: iso(now()), readMs: 0, reason: member.reason } }; const limit = deadline(timeoutMs); const bound = { signal: limit.signal, deadline: limit.deadline }; const invalid = () => ({ org: finish('invalid', { error: 'invalid-response' }) }); @@ -278,6 +281,7 @@ function orgRows(org, data, doc, nowMs) { org: id, id: p.id, key: p.repo ?? null, name: p.name ?? null, status: p.archived ? 'archived' : 'active', state: counts.working ? 'working' : counts.waiting || needs.length ? 'waiting' : counts.idle ? 'idle' : 'none', git: entry ? (entry.git?.mode ?? (entry.remote ? 'remote' : null)) : null, + board: entry?.board ?? null, sessions: counts, openTasks: open.length, needsYou: needs.length, lastActivity: latest([p.at, ...tasks.filter(t => t.project?.id === p.id).map(t => t.changedAt), ...needs.map(d => d.at), ...ps.map(s => s.claimedAt)]), }; diff --git a/packages/webui/tests/browser.test.mjs b/packages/webui/tests/browser.test.mjs index 0ceac241f..752c6977a 100644 --- a/packages/webui/tests/browser.test.mjs +++ b/packages/webui/tests/browser.test.mjs @@ -19,7 +19,7 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t await b.evaluate('document.querySelector("#hide-offline").click()'); assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4); // Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it. P2 (#1873) adds system-types.js after project-remote.js. - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/system-types.js', '/autonomy.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/system-types.js', '/autonomy.js', '/packs.js', '/workflows.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1); assert.equal(await b.evaluate('!!window.injected'), false); diff --git a/packages/webui/tests/packs-http.test.mjs b/packages/webui/tests/packs-http.test.mjs index 6ebdd96ec..7f59fc833 100644 --- a/packages/webui/tests/packs-http.test.mjs +++ b/packages/webui/tests/packs-http.test.mjs @@ -4,25 +4,28 @@ // Console's consoleCall does. The SSO session is a fixture authority. import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; -import { request } from 'node:http'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { createServer, request } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs'; import { loadBusiness } from '../../organization/src/organization-config.mjs'; import { resolveInstance } from '../../organization/src/resolve.mjs'; import { busOrganization } from '../../bus/src/organization-context.mjs'; +import { organizationWriter } from '../../organization/src/organization.mjs'; +import { templateHashes } from '../../organization/src/definitions.mjs'; import { startBroker } from '../../bus/src/runtime.mjs'; import { serve } from '../../bus/src/server.mjs'; import { Client } from '../../bus/src/client.mjs'; import { startServer } from '../src/serve.mjs'; -import { PACK_WRITE_VERBS, packsCaller } from '../src/packs-http.mjs'; +import { PACK_WRITE_VERBS, packsCaller, packsServes } from '../src/packs-http.mjs'; import { close } from './fixture.mjs'; const FLOW = (title) => ({ description: `${title}.`, steps: [{ title, state: 'todo', roles: ['human'] }] }); const BOARD = { columns: [{ state: 'todo', label: 'Backlog' }, { state: 'in-progress', label: 'Building' }, { state: 'in-review', label: 'Review' }, { state: 'blocked', label: 'Blocked' }, { state: 'done', label: 'Merged' }], layout: 'kanban' }; -async function fixture(t, { send = true } = {}) { +async function fixture(t, { send = true, workflows = true } = {}) { const undo = []; t.after(async () => { for (const step of undo.reverse()) await step(); }); const root = mkdtempSync(join(tmpdir(), 'pk-')); const rolesDir = rolesCopy(root), system = systemFor(root); @@ -40,6 +43,16 @@ async function fixture(t, { send = true } = {}) { authenticateConsole: proof => runtime.broker.bindConsole({ business: 'demo', human: 'jason', subject: proof.subject }) }); undo.push(() => socketServer.close()); const calls = []; + // SP6: the owner's workflow read, as cli.mjs builds it: the writer's read of the file and the V2 template names. + const flowReads = []; + let flowFails = false; + const workflowsRead = () => { flowReads.push(1); if (flowFails) throw new Error('unreadable'); return { ...organizationWriter({ dir: root, rolesDir }).read('demo'), templates: templateHashes(rolesDir).map(x => x.template) }; }; + // A stand-in Board upstream: it counts what reaches it, so a test can tell the Board read from the pack route. + const boardHits = []; + const board = createServer((req, res) => { boardHits.push([req.method, req.url]); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ from: 'board' })); }); + await new Promise(resolve => board.listen(0, '127.0.0.1', resolve)); + undo.push(() => close(board)); + const upstream = `http://127.0.0.1:${board.address().port}`; const consoleCall = kind => async (verb, args, ctx) => { calls.push([kind, verb, Object.keys(args).sort()]); return new Client({ path, console: { subject: ctx.subject } }).call(verb, args); }; const requestRead = consoleCall('read'); const packsWrite = packsCaller(consoleCall('write')); @@ -48,7 +61,7 @@ async function fixture(t, { send = true } = {}) { const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) }; const organizations = { list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }], - resolve: async id => id !== 'demo' ? null : { busHealthy: true, send: send ? async () => ({}) : null, requestRead, packsWrite: send ? packsWrite : null, + resolve: async id => id !== 'demo' ? null : { busHealthy: true, send: send ? async () => ({}) : null, requestRead, packsWrite: send ? packsWrite : null, workflowsRead: typeof workflows === 'function' ? workflows : workflows ? workflowsRead : null, upstream, reads: { business: async () => ({ business: { id: 'demo' } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } }, }; const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), organizations, log: () => {} }); @@ -56,7 +69,7 @@ async function fixture(t, { send = true } = {}) { sso.publicOrigin = base; sso.publicHost = new URL(base).host; undo.push(() => close(web)); const onDisk = () => JSON.parse(readFileSync(file, 'utf8')); - return { base, file, calls, onDisk, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } }; + return { base, file, root, calls, onDisk, flowReads, boardHits, failFlows: () => { flowFails = true; }, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } }; } function call(f, path, method = 'GET', body, headers = {}) { @@ -220,3 +233,142 @@ test('SP4 a Console without sign-in serves no pack route; a method the route nev } for (const method of ['OPTIONS', 'PUT', 'DELETE']) assert.equal((await call(f, '/api/board', method))[0], 405, method); }); + +// SP6: a HEAD carries no body, so it reports only the status. +function head(f, path) { + return new Promise((resolve, reject) => { + const req = request(f.base + path, { method: 'HEAD' }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); }); + req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(); + }); +} + +test('SP6 GET /workflows returns the org and code-project workflows, the role names and the file revision a save takes', async t => { + const f = await fixture(t); + // One workflow per scope, written straight into the file. The step role `ghost` names no definition or + // template: the load checks only the shape of a role name, so the file holds it and the read shows it as written. + const doc = f.onDisk(); + doc.definitions = { triage: { rev: 1, role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Sort new work.' } }; + doc.workflows = { intake: { title: 'Intake ', description: 'New work.', steps: [{ title: 'Sort', state: 'todo', roles: ['triage', 'human'] }, { title: 'Hand off', state: 'in-progress', roles: ['ghost'] }] } }; + doc.projects.stack.workflows = { ship: { steps: [{ title: 'Build', state: 'in-progress', roles: ['coder'] }] } }; + writeFileSync(f.file, `${JSON.stringify(doc, null, 2)}\n`); + const [status, body] = await call(f, `${ORG}/workflows`); + assert.equal(status, 200, JSON.stringify(body)); + assert.deepEqual(Object.keys(body).sort(), ['projects', 'revision', 'roles', 'workflows', 'writable']); + assert.deepEqual(body.workflows, doc.workflows); + assert.deepEqual(body.projects, Object.fromEntries(Object.entries(doc.projects).map(([id, p]) => [id, p.workflows ?? {}]))); + assert.deepEqual(body.projects.stack, doc.projects.stack.workflows); + assert.deepEqual(body.roles.definitions, ['triage']); + assert.deepEqual(body.roles.templates, templateHashes(join(f.root, 'roles')).map(x => x.template).sort()); + assert.ok(body.roles.templates.includes('coder') && !body.roles.templates.includes('ghost')); + assert.equal(body.revision, createHash('sha256').update(readFileSync(f.file)).digest('hex'), 'the revision is the writer\'s: the hash of the file bytes'); + assert.equal(body.writable, true); + assert.equal(JSON.stringify(body).includes(f.root), false, 'no host path leaves'); + // The revision is the one a save sends back. + const [saved, receipt] = await call(f, `${ORG}/workflows/review`, 'POST', { revision: body.revision, workflow: FLOW('Check') }); + assert.equal(saved, 200, JSON.stringify(receipt)); + const [, again] = await call(f, `${ORG}/workflows`); + assert.equal(again.revision, receipt.revision); + assert.deepEqual(Object.keys(again.workflows).sort(), ['intake', 'review']); + // A project save and delete through the same revision chain. + const [projectSaved, projectReceipt] = await call(f, `${ORG}/workflows/review`, 'POST', { revision: again.revision, workflow: FLOW('Check'), project: 'stack' }); + assert.equal(projectSaved, 200, JSON.stringify(projectReceipt)); + const [deleted] = await call(f, `${ORG}/workflows/review`, 'DELETE', { revision: projectReceipt.revision, project: 'stack' }); + assert.equal(deleted, 200); + assert.deepEqual(Object.keys((await call(f, `${ORG}/workflows`))[1].projects.stack).sort(), ['ship']); +}); + +test('SP6 GET /workflows leaves out a library project and answers outcome-unknown for a read it cannot trust', async t => { + const REV = 'a'.repeat(64); + const read = { document: { projects: { code: { workflows: { w: FLOW('W') } }, lib: { kind: 'library', workflows: { never: FLOW('N') } }, bare: {} } }, revision: REV, templates: ['coder', 7] }; + const f = await fixture(t, { workflows: () => read }); + const [status, body] = await call(f, `${ORG}/workflows`); + assert.equal(status, 200, JSON.stringify(body)); + assert.deepEqual(body, { workflows: {}, projects: { code: { w: FLOW('W') }, bare: {} }, roles: { definitions: [], templates: ['coder'] }, revision: REV, writable: true }); + for (const bad of [{ ...read, revision: 'stale' }, { ...read, templates: null }, { ...read, document: [] }, null]) { + const g = await fixture(t, { workflows: () => bad }); + assert.deepEqual((await call(g, `${ORG}/workflows`)).map((x, i) => i ? x.error : x), [503, 'outcome-unknown'], JSON.stringify(bad)); + } +}); + +test('SP6 GET /workflows is the owner\'s: non-owner, sign-out, no reader, an unreadable file, a write method and a cross-site read', async t => { + const f = await fixture(t); + const before = readFileSync(f.file); + assert.deepEqual((await call(f, `${ORG}/workflows`, 'POST', { revision: '0'.repeat(64) })).map((x, i) => i ? x.error : x), [405, 'method-not-allowed']); + assert.deepEqual((await call(f, `${ORG}/workflows`, 'DELETE', { revision: '0'.repeat(64) })).map((x, i) => i ? x.error : x), [405, 'method-not-allowed']); + assert.deepEqual((await call(f, `${ORG}/workflows`, 'GET', undefined, { 'sec-fetch-site': 'cross-site' })).map((x, i) => i ? x.error : x), [403, 'cross-origin']); + assert.equal(f.flowReads.length, 0, 'no refused request read the file'); + f.failFlows(); + assert.deepEqual((await call(f, `${ORG}/workflows`)).map((x, i) => i ? x.error : x), [503, 'org-unavailable']); + const reads = f.flowReads.length; + f.revokeOwner(); + assert.deepEqual((await call(f, `${ORG}/workflows`)).map((x, i) => i ? x.error : x), [403, 'owner-only']); + assert.equal(f.flowReads.length, reads, 'a non-owner reads nothing'); + assert.deepEqual(readFileSync(f.file), before); + + const g = await fixture(t, { workflows: false }); + assert.deepEqual((await call(g, `${ORG}/workflows`)).map((x, i) => i ? x.error : x), [503, 'not-configured']); + g.signOut(); + assert.deepEqual((await call(g, `${ORG}/workflows`)).map((x, i) => i ? x.error : x), [401, 'sign-in-required']); + + const h = await fixture(t, { send: false }); + const [status, body] = await call(h, `${ORG}/workflows`); + assert.equal(status, 200, JSON.stringify(body)); + assert.equal(body.writable, false, 'a Console without send reads workflows but cannot save them'); +}); + +test('SP6 HEAD /api/board: serve sends GET and HEAD to the Board read, never the pack route', async t => { + // The predicate serve.mjs dispatches on (`packsPath = packsServes(path, req.method)`). + for (const method of ['GET', 'HEAD']) assert.equal(packsServes('/api/board', method), false, method); + for (const method of ['POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH']) assert.equal(packsServes('/api/board', method), true, method); + assert.equal(packsServes('/api/workflows', 'HEAD'), true, 'only /api/board is carved out'); + assert.equal(packsServes('/api/packs', 'HEAD'), true); + + // On an organization: GET /api/orgs//board reaches the organization's Board upstream; HEAD is the Board + // read's own 405 (it serves GET only) and nothing reaches the upstream or the pack route's bus. + const f = await fixture(t); + const [got, board] = await call(f, `${ORG}/board`); + assert.deepEqual([got, board], [200, { from: 'board' }]); + assert.deepEqual(f.boardHits, [['GET', '/api/board']]); + const asked = f.calls.length; + assert.equal(await head(f, `${ORG}/board`), 405); + assert.deepEqual(f.boardHits, [['GET', '/api/board']], 'the HEAD reached no upstream'); + assert.equal(f.calls.length, asked, 'the HEAD reached no bus'); + // POST stays the pack route's: a board write with a stale revision is the bus's revision-conflict. + const [posted, postBody] = await call(f, `${ORG}/board`, 'POST', { revision: '0'.repeat(64), board: BOARD }); + assert.deepEqual([posted, postBody.error], [409, 'revision-conflict']); + assert.deepEqual(f.calls.at(-1), ['write', 'board.set.console', ['board', 'revision']]); + + // On a root Console: GET /api/board is the Board read through its upstream, HEAD its 405, POST the pack route's owner-only. + const hits = []; + const upstream = createServer((req, res) => { hits.push(req.method); res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"from":"root-board"}'); }); + await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve)); + t.after(() => close(upstream)); + const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, log: () => {} }); + t.after(() => close(web)); + const r = { base: `http://127.0.0.1:${web.address().port}` }; + assert.deepEqual(await call(r, '/api/board'), [200, { from: 'root-board' }]); + assert.equal(await head(r, '/api/board'), 405); + assert.deepEqual(hits, ['GET']); + assert.deepEqual((await call(r, '/api/board', 'POST', { revision: '0'.repeat(64), board: BOARD })).map((x, i) => i ? x.error : x), [403, 'owner-only']); +}); + +test('SP6 the pack picker and workflow files are the owner\'s: the tester listener gets 403, the owner 200', async t => { + const { createDevOwner } = await import('../src/dev-owner.mjs'); + const organizations = { list: async () => [], resolve: async () => null }; + const root = mkdtempSync(join(tmpdir(), 'pkt-')), socket = join(root, 't.sock'); + const tester = await startServer({ tester: true, testerSocket: socket, board: 'http://127.0.0.1:9', organizations, log: () => {} }); + t.after(async () => { await close(tester); rmSync(root, { recursive: true, force: true }); }); + const viaTester = path => new Promise((resolve, reject) => { + const req = request({ socketPath: socket, path, headers: { host: 'mosaic-tester.invalid' } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); }); + req.on('error', reject); req.end(); + }); + const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, log: () => {} }); + t.after(() => close(owner)); + for (const [path, type] of [['/packs.js', /javascript/], ['/workflows.js', /javascript/], ['/packs.css', /css/]]) { + assert.equal(await viaTester(path), 403, path); + const res = await fetch(`http://127.0.0.1:${owner.address().port}${path}`); + assert.equal(res.status, 200, path); + assert.match(res.headers.get('content-type'), type, path); + assert.ok((await res.text()).length > 100, path); + } +}); diff --git a/packages/webui/tests/packs-workflows-browser.test.mjs b/packages/webui/tests/packs-workflows-browser.test.mjs new file mode 100644 index 000000000..6da622de7 --- /dev/null +++ b/packages/webui/tests/packs-workflows-browser.test.mjs @@ -0,0 +1,196 @@ +// Starter packs SP6 (#1873): the owner's pack picker and workflow editor on an organization's page, +// driven in Chromium against stand-in pack verbs and a stand-in workflow read. It checks what the page +// shows from what the bus returns: the preview's marks, flagged lines, credential lines and noHire mark; +// a step role that no longer resolves; an organization-supplied title or label rendered as text; and +// the save and delete requests the editor sends. +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 waitFor = (b, expression, what) => 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,2000))})()`); +const REV = n => String(n).repeat(64); + +const PACKS = [ + { id: 'software-team', origin: 'shipped', pack: { label: `Software team ${XSS}`, description: 'Roles, a board and a review workflow.', version: '1.0.0', scope: 'organization' } }, + { id: 'web-project', origin: 'installed', pack: { label: 'Web project', description: 'A project board.', version: '0.2.0', scope: 'project' } }, +]; +const PREVIEW = { + pack: { id: 'software-team', version: '1.0.0', label: `Software team ${XSS}`, origin: 'shipped', scope: 'organization' }, + project: null, revision: REV('c'), + items: [ + { item: 'board', name: 'board', target: 'board', path: 'board', mark: 'add' }, + { item: 'definition', name: 'reviewer', target: 'reviewer', path: 'definitions.reviewer', mark: 'kept', diff: { primitives: ['tools', 'instructions'] } }, + { item: 'workflow', name: 'review', target: 'review', path: 'workflows.review', mark: 'same' }, + { item: 'workflow', name: 'ship', target: 'ship', path: 'workflows.ship', mark: 'removed' }, + ], + hires: [ + { name: 'lead-reviewer', definition: 'org:reviewer', mark: 'propose', pin: null, flagged: [{ field: 'authority.withinRole', value: 'decision.resolve.technical', reasons: ['above-chief', 'resolve-right'] }], credentialLines: [['gitea', 'repo:write']], noHire: true }, + { name: 'builder', definition: 'coder', mark: 'dropped', pin: null, flagged: null, credentialLines: [], noHire: false }, + ], + skills: { notEnabled: ['lint-fix'] }, chief: true, plan: {}, digest: REV('d'), +}; + +async function fixture(t, { send = true } = {}) { + const verbs = []; + let flows = { + revision: REV('a'), + document: { + workflows: { + intake: { title: `Intake ${XSS}`, description: `Sort ${XSS}`, steps: [{ title: `Sort ${XSS}`, state: 'todo', roles: ['triage', 'human'] }, { title: 'Hand off', state: 'in-progress', roles: ['ghost'] }] }, + }, + projects: { site: { workflows: { ship: { steps: [{ title: 'Build', state: 'in-progress', roles: ['coder'] }] } } } }, + definitions: { triage: {} }, + }, + templates: ['coder', 'reviewer'], + }; + const read = async (verb, args) => { + verbs.push([verb, args]); + if (verb === 'packs.console') return { packs: PACKS }; + if (verb === 'pack.preview.console') return { ...PREVIEW, project: args.project ?? null }; + if (verb === 'autonomy.get.console') return { revision: REV('e'), autonomy: {}, levels: {} }; + if (verb === 'system-types.console') return { installed: [], enabled: [], project: null }; + throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); + }; + const write = async (verb, args) => { + verbs.push([verb, args]); + if (verb === 'pack.apply.console') return { revision: REV('f'), added: ['board', 'definitions.reviewer'], pack: { id: args.pack, version: '1.0.0' } }; + if (verb === 'workflow.set.console') { + assert.equal(args.revision, flows.revision); + const doc = structuredClone(flows.document); + const scope = args.project ? doc.projects[args.project] : doc; + scope.workflows ??= {}; + if (args.workflow === null) delete scope.workflows[args.name]; else scope.workflows[args.name] = args.workflow; + flows = { ...flows, revision: REV(flows.revision[0] === 'a' ? 'b' : '9'), document: doc }; + return { revision: flows.revision, added: [] }; + } + throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); + }; + const server = await startServer({ + port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, + organizations: { + list: async () => [{ id: 'acme', label: 'Acme', busHealthy: true }], + resolve: async () => ({ + busHealthy: true, decisionWrite: async () => ({}), requestRead: read, requestWrite: read, packsWrite: send ? write : null, + workflowsRead: async () => structuredClone(flows), send: send ? async () => ({}) : null, + reads: { business: async () => ({ business: { id: 'acme', 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 () => [] }, + }), + }, + }); + t.after(() => close(server)); + const b = await browser({ machine: false }); + t.after(() => b.close()); + await b.viewport(1440, 900); + await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/organization`); + return { b, verbs, flows: () => flows }; +} + +test('SP6 pack picker: list, preview with marks, flagged, credential and noHire lines, apply by digest; pack text stays text', { timeout: 60000 }, async t => { + const { b, verbs } = await fixture(t); + await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-preview-button]')`, 'pack list'); + assert.equal(await b.evaluate(`document.querySelector('[data-pack="software-team"] strong').textContent`), `Software team ${XSS}`); + // The project pack offers the code projects the workflow read lists. + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-pack="web-project"] [data-pack-project] option')].map(o=>o.value)`), ['site']); + await b.evaluate(`document.querySelector('[data-pack="software-team"] [data-pack-preview-button]').click();true`); + await waitFor(b, `document.querySelector('[data-pack-preview]')`, 'preview'); + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-pack-item]')].map(r=>[r.dataset.packItem,r.querySelector('[data-pack-mark]').dataset.packMark,r.querySelector('[data-pack-mark]').textContent])`), [ + ['board', 'add', 'will be added'], + ['definitions.reviewer', 'kept', 'kept: the organization has its own version differs in tools, instructions'], + ['workflows.review', 'same', 'already the same'], + ['workflows.ship', 'removed', 'stays removed: you removed it after an earlier apply'], + ]); + const lead = '[data-pack-hire="lead-reviewer"]'; + assert.match(await b.evaluate(`document.querySelector('${lead} [data-pack-flagged]').textContent`), /authority\.withinRole decision\.resolve\.technical above the chief, a resolve right/); + assert.equal(await b.evaluate(`document.querySelector('${lead} [data-pack-credentials]').textContent`), 'gitea repo:write'); + assert.match(await b.evaluate(`document.querySelector('${lead} [data-pack-nohire]').textContent`), /no-hire list/); + assert.equal(await b.evaluate(`document.querySelector('[data-pack-hire="builder"] [data-pack-nohire]')`), null); + assert.ok(await b.evaluate(`document.querySelector('[data-pack-hire="builder"] [data-pack-noflag]')!==null`)); + assert.match(await b.evaluate(`document.querySelector('[data-pack-hire="builder"]').textContent`), /Not proposed/); + assert.match(await b.evaluate(`document.querySelector('[data-pack-skills]').textContent`), /lint-fix/); + assert.equal(await b.evaluate(`document.querySelectorAll('[data-packs] img').length`), 0, 'a pack label never becomes markup'); + assert.equal(await b.evaluate(`window.__sp6pwn===undefined`), true); + assert.equal(verbs.filter(([v]) => v.startsWith('pack.apply')).length, 0, 'preview writes nothing'); + await b.evaluate(`document.querySelector('[data-pack-apply]').click();true`); + await waitFor(b, `/^Applied\\./.test(document.querySelector('[data-packs-status]').textContent)`, 'apply status'); + assert.equal(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), 'Applied. Added board, definitions.reviewer. Proposed hires are recorded for the chief to raise as decisions.'); + assert.deepEqual(verbs.filter(([v]) => v === 'pack.apply.console').map(([, a]) => a), [{ pack: 'software-team', digest: REV('d') }]); + assert.equal(await b.evaluate(`document.querySelector('[data-pack-preview]')`), null); + // A project pack previews against the chosen project. + await b.evaluate(`document.querySelector('[data-pack="web-project"] [data-pack-preview-button]').click();true`); + await waitFor(b, `/for project site/.test(document.querySelector('[data-pack-preview] h4')?.textContent??'')`, 'project preview'); + assert.deepEqual(verbs.filter(([v]) => v === 'pack.preview.console').at(-1)[1], { pack: 'web-project', project: 'site' }); +}); + +test('SP6 pack picker without send previews but offers no apply', { timeout: 60000 }, async t => { + const { b } = await fixture(t, { send: false }); + await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-preview-button]')`, 'pack list'); + await b.evaluate(`document.querySelector('[data-pack="software-team"] [data-pack-preview-button]').click();true`); + await waitFor(b, `document.querySelector('[data-pack-preview]')`, 'preview'); + assert.equal(await b.evaluate(`document.querySelector('[data-pack-apply]')`), null); + assert.match(await b.evaluate(`document.querySelector('[data-pack-preview]').textContent`), /started without send/); + assert.equal(await b.evaluate(`document.querySelector('[data-wf-edit],[data-wf-new]')`), null, 'no workflow change controls without send'); +}); + +test('SP6 workflows: list by scope, a step naming no current role, titles as text, edit, new and delete', { timeout: 60000 }, async t => { + const { b, verbs, flows } = await fixture(t); + await waitFor(b, `document.querySelector('[data-wf="intake"]')`, 'workflow list'); + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wf-scope-list]')].map(n=>n.dataset.wfScopeList)`), ['', 'site']); + assert.equal(await b.evaluate(`document.querySelector('[data-wf="intake"] [data-wf-title]').textContent`), `Intake ${XSS}`); + assert.equal(await b.evaluate(`document.querySelectorAll('[data-workflows] img').length`), 0, 'a workflow title never becomes markup'); + assert.equal(await b.evaluate(`window.__sp6pwn===undefined`), true); + // triage (an org definition), human and coder (a template) resolve; ghost names no current role. + assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-wf="intake"] [data-wf-step]')].map(s=>s.querySelectorAll('[data-wf-missing]').length)`), [0, 1]); + assert.match(await b.evaluate(`document.querySelectorAll('[data-wf="intake"] [data-wf-step]')[1].textContent`), /ghost names no current role/); + assert.equal(await b.evaluate(`document.querySelectorAll('[data-wf="ship"] [data-wf-missing]').length`), 0); + + // Edit: the ghost step shows the mark in the editor; fixing the role clears it; save sends the revision. + await b.evaluate(`document.querySelector('[data-wf="intake"] [data-wf-edit]').click();true`); + await waitFor(b, `document.querySelector('[data-wf-editor]')`, 'editor'); + assert.equal(await b.evaluate(`document.querySelector('[data-wf-edit-step="1"] [data-wf-step-missing]').textContent`), 'ghost names no current role'); + assert.equal(await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value`), `Intake ${XSS}`); + await b.evaluate(`{const r=document.querySelector('[data-wf-edit-step="1"] [name=roles]');r.value='reviewer, x';r.dispatchEvent(new Event('input',{bubbles:true}))}true`); + assert.equal(await b.evaluate(`document.querySelector('[data-wf-edit-step="1"] [data-wf-step-missing]').textContent`), 'x names no current role'); + assert.equal(await b.evaluate(`document.querySelectorAll('[data-wf-edit-step="1"] b').length`), 0); + await b.evaluate(`{const r=document.querySelector('[data-wf-edit-step="1"] [name=roles]');r.value='reviewer';r.dispatchEvent(new Event('input',{bubbles:true}))}true`); + assert.equal(await b.evaluate(`document.querySelector('[data-wf-edit-step="1"] [data-wf-step-missing]').textContent`), ''); + await b.evaluate(`document.querySelector('[data-wf-editor] input[name=title]').value='Intake';document.querySelector('[data-wf-editor]').requestSubmit();true`); + await waitFor(b, `document.querySelector('[data-workflows-status]').textContent==='Saved intake.'`, 'save status'); + const saved = verbs.filter(([v]) => v === 'workflow.set.console').at(-1)[1]; + assert.deepEqual(saved, { revision: REV('a'), name: 'intake', workflow: { title: 'Intake', description: `Sort ${XSS}`, steps: [{ title: `Sort ${XSS}`, state: 'todo', roles: ['triage', 'human'] }, { title: 'Hand off', state: 'in-progress', roles: ['reviewer'] }] } }); + assert.equal(await b.evaluate(`document.querySelectorAll('[data-wf="intake"] [data-wf-missing]').length`), 0, 'the re-read shows the fixed step'); + + // New, in the project scope: a bad name is refused on the page and sends nothing. + const sets = () => verbs.filter(([v]) => v === 'workflow.set.console').length; + const before = sets(); + await b.evaluate(`document.querySelector('[data-wf-new]').click();true`); + await waitFor(b, `document.querySelector('[data-wf-editor] select[name=scope]')`, 'new editor'); + await b.evaluate(`{const f=document.querySelector('[data-wf-editor]');f.elements.scope.value='site';f.elements.name.value='Bad Name';f.querySelector('[data-wf-edit-step="0"] [name=title]').value='Plan';f.querySelector('[data-wf-edit-step="0"] [name=roles]').value='human';f.requestSubmit()}true`); + await waitFor(b, `/lowercase/.test(document.querySelector('[data-workflows-status]').textContent)`, 'name refusal'); + assert.equal(sets(), before); + await b.evaluate(`{const f=document.querySelector('[data-wf-editor]');f.elements.name.value='plan';f.requestSubmit()}true`); + await waitFor(b, `document.querySelector('[data-workflows-status]').textContent==='Saved plan.'`, 'new save'); + assert.deepEqual(verbs.filter(([v]) => v === 'workflow.set.console').at(-1)[1], { revision: REV('b'), name: 'plan', workflow: { steps: [{ title: 'Plan', state: 'todo', roles: ['human'] }] }, project: 'site' }); + assert.ok(await b.evaluate(`document.querySelector('[data-wf-scope-list="site"] [data-wf="plan"]')!==null`)); + + // Delete asks first; Keep sends nothing; Delete sends the project and the revision. + const del = `[data-wf-scope-list="site"] [data-wf="ship"]`; + await b.evaluate(`document.querySelector('${del} [data-wf-delete]').click();true`); + await waitFor(b, `document.querySelector('${del} [data-wf-delete-no]')`, 'delete confirm'); + await b.evaluate(`document.querySelector('${del} [data-wf-delete-no]').click();true`); + await waitFor(b, `document.querySelector('${del} [data-wf-delete]')`, 'kept'); + const n = sets(); + await b.evaluate(`document.querySelector('${del} [data-wf-delete]').click();true`); + await waitFor(b, `document.querySelector('${del} [data-wf-delete-yes]')`, 'delete confirm again'); + assert.equal(sets(), n); + const rev = flows().revision; + await b.evaluate(`document.querySelector('${del} [data-wf-delete-yes]').click();true`); + await waitFor(b, `document.querySelector('[data-workflows-status]').textContent==='Deleted ship.'`, 'delete status'); + assert.deepEqual(verbs.filter(([v]) => v === 'workflow.set.console').at(-1)[1], { revision: rev, name: 'ship', workflow: null, project: 'site' }); + assert.equal(await b.evaluate(`document.querySelector('${del}')`), null); + assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`), true); +}); diff --git a/packages/webui/tests/tasks-board-labels-browser.test.mjs b/packages/webui/tests/tasks-board-labels-browser.test.mjs new file mode 100644 index 000000000..8e12a53e7 --- /dev/null +++ b/packages/webui/tests/tasks-board-labels-browser.test.mjs @@ -0,0 +1,183 @@ +// Starter packs SP6 (#1873): board styles on the task board, in a real browser. A board style +// relabels, reorders and sets the default layout over the five fixed states (Jason 11-12 Q3), and +// only while the view is narrowed to one organization or project; the all-orgs view keeps the fixed +// labels. Every bus is a fake in this file. +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 waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); +const D = n => String(n).repeat(64); +const FIXED = ['To do', 'In progress', 'Review', 'Blocked', 'Done']; +// forge's board: a support desk's words, done first, list by default. +const FORGE_BOARD = { columns: [ + { state: 'done', label: 'Solved' }, { state: 'todo', label: 'New ticket' }, { state: 'in-progress', label: 'With an agent' }, + { state: 'in-review', label: 'Awaiting customer' }, { state: 'blocked', label: 'On hold' }, +], layout: 'list' }; +// forge's billing project has a board of its own, which wins over the org's for that project. +const BILLING_BOARD = { columns: [ + { state: 'todo', label: 'Draft' }, { state: 'in-progress', label: 'Invoicing' }, { state: 'in-review', label: 'Checked' }, + { state: 'blocked', label: 'Disputed' }, { state: 'done', label: 'Paid' }, +], layout: 'kanban' }; +const XSS = ''; + +function orgs({ forgeBoard = FORGE_BOARD, billingBoard = BILLING_BOARD } = {}) { + const ORGS = [ + { id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true }, + { id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true }, + ]; + const DOCS = { + ddigital: { appearance: { theme: 'rosewood' }, roles: { editor: {} } }, + forge: { appearance: { theme: 'cobalt' }, roles: { coder: {} }, ...(forgeBoard ? { board: forgeBoard } : {}), + projects: { gateway: { root: 'code/gateway', repo: 'gateway' }, billing: { root: 'code/billing', repo: 'billing', ...(billingBoard ? { board: billingBoard } : {}) } } }, + }; + const PROJECTS = { + ddigital: [{ id: 'spring-launch', repo: 'spring', name: 'Spring launch', at: '2026-11-09T10:00:00.000Z' }], + forge: [{ id: 'api-gateway', repo: 'gateway', name: 'API gateway', at: '2026-11-09T10:00:00.000Z' }, { id: 'billing', repo: 'billing', name: 'Billing', at: '2026-11-08T10:00:00.000Z' }], + }; + const STORE = { + forge: [ + { n: 41, title: 'Rate limit the public API', state: 'todo', group: 'api-gateway' }, + { n: 42, title: 'Invoice export', state: 'in-progress', group: 'billing' }, + { n: 43, title: 'Retire the v1 gateway', state: 'done', group: 'api-gateway' }, + ], + ddigital: [{ n: 7, title: 'Launch email', state: 'in-review', group: 'spring-launch' }], + }; + const tasks = id => STORE[id].map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: 0, digest: D(t.n % 10), requirement: 'REQ-API-1', + fields: { title: t.title, state: t.state, group: t.group, assignees: [], due_date: null } })); + const options = id => ({ + bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => tasks(id), messages: async () => ({ messages: [], unread: 0 }) }, + chat: { projects: async () => PROJECTS[id], threads: async () => [] }, + }); + const healthy = async () => ({ healthy: true, pid: 4242, boot: null }); + return { + list: async () => ORGS, + members: async () => ORGS.map(o => ({ id: o.id, doc: DOCS[o.id], status: healthy, options: () => options(o.id) })), + resolve: async id => { + if (!ORGS.some(o => o.id === id)) return null; + return { busHealthy: true, reads: { business: async () => ({ business: { id, projects: PROJECTS[id].map(p => p.id) } }) }, ...options(id), taskWrite: async (verb, args) => ({ task_ref: args.task_ref, digest: D(9) }) }; + }, + }; +} + +async function open(t, fixture = orgs()) { + const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture, send: async () => ({}), log: () => {} }); + t.after(() => close(server)); + const b = await browser({ machine: false, args: ['--disable-extensions'] }); t.after(() => b.close()); + await b.viewport(1440, 900); + const url = `http://127.0.0.1:${server.address().port}/#/orgs/forge/tasks`; + await b.navigate(url); + const wait = waitFor(b); + await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=forge]")'); + return { b, wait, url }; +} +const heads = b => b.evaluate('[...document.querySelectorAll("[data-tasks-board] .tb-col h3")].map(h => h.textContent)'); +const layout = b => b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'); +const pick = (b, wait, org) => b.evaluate(`document.querySelector("[data-tb-org='${org}']").click()`) + .then(() => wait(`document.querySelector("[data-tb-org='${org}']").getAttribute("aria-pressed") === "true"`)); +const project = async (b, wait, value, count) => { + await b.evaluate(`(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = ${JSON.stringify(value)}; s.dispatchEvent(new Event("change", { bubbles: true })); })()`); + await wait(`document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === ${count}`); +}; + +// The red-before test: on BASE the view filtered to forge showed the fixed labels. +test('a view filtered to an organization with a board shows its labels', async t => { + const { b, wait } = await open(t, orgs({ forgeBoard: { ...FORGE_BOARD, layout: 'kanban' } })); + assert.deepEqual(await heads(b), FIXED); + await pick(b, wait, 'forge'); + assert.deepEqual(await heads(b), ['Solved', 'New ticket', 'With an agent', 'Awaiting customer', 'On hold']); +}); + +test('a filtered view uses the board\'s labels and order in the list, the filters and the dialog', async t => { + const { b, wait } = await open(t); + await pick(b, wait, 'forge'); + // forge's board says list; no viewer choice is saved, so the board's layout is the default. + assert.equal(await layout(b), 'list'); + // The list orders rows by the board's columns (Solved first) and labels each state cell with its label. + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-list] tbody tr")].map(r => r.dataset.tbTask)'), ['forge|task:43', 'forge|task:41', 'forge|task:42']); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-list] select.tb-move")].map(s => s.selectedOptions[0].textContent)'), ['Solved', 'New ticket', 'With an agent']); + assert.deepEqual(await b.evaluate('[...document.querySelector("[data-tb-list] select.tb-move").options].map(o => [o.value, o.textContent])'), + [['done', 'Solved'], ['todo', 'New ticket'], ['in-progress', 'With an agent'], ['in-review', 'Awaiting customer'], ['blocked', 'On hold']]); + // The state filter speaks the board's words too; its values stay the five fixed states. + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-filter=state] option")].map(o => [o.value, o.textContent])'), + [['', 'Any'], ['done', 'Solved'], ['todo', 'New ticket'], ['in-progress', 'With an agent'], ['in-review', 'Awaiting customer'], ['blocked', 'On hold']]); + // In Kanban the columns are the board's labels, in the board's order. + await b.evaluate('document.querySelector("[data-tb-layout=kanban]").click()'); + await wait('document.querySelector("[data-tasks-board]").dataset.layout === "kanban"'); + assert.deepEqual(await heads(b), ['Solved', 'New ticket', 'With an agent', 'Awaiting customer', 'On hold']); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-kanban] [data-tb-col]")].map(c => c.dataset.tbCol)'), ['done', 'todo', 'in-progress', 'in-review', 'blocked']); + // The edit dialog's state picker uses the same labels. + await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:41\']").click()'); + await wait('document.querySelector("#tb-dialog[open] select[name=state]")'); + assert.deepEqual(await b.evaluate('[...document.querySelector("#tb-dialog select[name=state]").options].map(o => o.textContent)'), ['Solved', 'New ticket', 'With an agent', 'Awaiting customer', 'On hold']); + assert.equal(await b.evaluate('document.querySelector("#tb-dialog select[name=state]").value'), 'todo'); +}); + +test('a project filter uses the project\'s board, and the org\'s when the project has none', async t => { + const { b, wait } = await open(t); + await b.evaluate('document.querySelector("[data-tb-layout=kanban]").click()'); + await wait('document.querySelector("[data-tasks-board]").dataset.layout === "kanban"'); + await project(b, wait, 'forge:billing', 1); + assert.deepEqual(await heads(b), ['Draft', 'Invoicing', 'Checked', 'Disputed', 'Paid']); + await project(b, wait, 'forge:api-gateway', 2); + assert.deepEqual(await heads(b), ['Solved', 'New ticket', 'With an agent', 'Awaiting customer', 'On hold']); + // ddigital holds no board: its project keeps the fixed labels. + await project(b, wait, 'ddigital:spring-launch', 1); + assert.deepEqual(await heads(b), FIXED); +}); + +test('the all-orgs view keeps today\'s labels, and so does an org without a board', async t => { + const { b, wait } = await open(t); + assert.equal(await b.evaluate('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed")'), 'true'); + assert.equal(await layout(b), 'kanban'); + assert.deepEqual(await heads(b), FIXED); + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-filter=state] option")].map(o => o.textContent)'), ['Any', ...FIXED]); + await pick(b, wait, 'ddigital'); + assert.deepEqual(await heads(b), FIXED); + // Back to every organization after a narrowed view: the fixed labels return. + await pick(b, wait, 'forge'); + await pick(b, wait, ''); + assert.equal(await layout(b), 'kanban'); + assert.deepEqual(await heads(b), FIXED); +}); + +test('a saved viewer layout wins over the board\'s default', async t => { + const { b, wait, url } = await open(t); + await pick(b, wait, 'forge'); + assert.equal(await layout(b), 'list'); + // The viewer picks Kanban, which is saved for this width; after a reload it still wins over forge's list. + await b.evaluate('document.querySelector("[data-tb-layout=kanban]").click()'); + await wait('document.querySelector("[data-tasks-board]").dataset.layout === "kanban"'); + assert.match(await b.evaluate('localStorage.getItem("mosaic-tasks-layout-v1")'), /"wide":"kanban"/); + await b.navigate(url.replace('/#', '/?reload=1#')); + await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=forge]")'); + await pick(b, wait, 'forge'); + assert.equal(await layout(b), 'kanban'); + assert.deepEqual(await heads(b), ['Solved', 'New ticket', 'With an agent', 'Awaiting customer', 'On hold']); +}); + +test('a board label holding markup renders as text', async t => { + const board = { columns: [{ state: 'todo', label: XSS }, ...FORGE_BOARD.columns.filter(c => c.state !== 'todo')], layout: 'kanban' }; + const { b, wait } = await open(t, orgs({ forgeBoard: board })); + await pick(b, wait, 'forge'); + assert.deepEqual((await heads(b)).slice(0, 2), [XSS, 'Solved']); + assert.equal(await b.evaluate('document.querySelector("[data-tb-col=todo]").getAttribute("aria-label")'), XSS); + assert.ok((await b.evaluate('[...document.querySelectorAll("[data-tb-filter=state] option")].map(o => o.textContent)')).includes(XSS)); + await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:41\']").click()'); + await wait('document.querySelector("#tb-dialog[open] select[name=state]")'); + assert.ok((await b.evaluate('[...document.querySelector("#tb-dialog select[name=state]").options].map(o => o.textContent)')).includes(XSS)); + assert.equal(await b.evaluate('document.querySelectorAll("img[src=x]").length'), 0); + await new Promise(r => setTimeout(r, 200)); + assert.equal(await b.evaluate('window.pwn === undefined'), true); +}); + +test('a board that is not exactly the five states is ignored, never half applied', async t => { + const broken = { columns: FORGE_BOARD.columns.slice(0, 4), layout: 'list' }; + const { b, wait } = await open(t, orgs({ forgeBoard: broken, billingBoard: null })); + await pick(b, wait, 'forge'); + assert.equal(await layout(b), 'kanban'); + assert.deepEqual(await heads(b), FIXED); +}); diff --git a/packages/webui/tests/workspace-read.test.mjs b/packages/webui/tests/workspace-read.test.mjs index 8df92a8c5..4ce150c48 100644 --- a/packages/webui/tests/workspace-read.test.mjs +++ b/packages/webui/tests/workspace-read.test.mjs @@ -71,7 +71,7 @@ test('merge: every item from two buses carries its org, and the snapshot names b // Archived projects are hidden by default; the default sort is newest activity first. assert.deepEqual(body.projects.map(p => `${p.org}/${p.key}`), ['acme/site', 'beta/shop']); const site = body.projects[0]; - assert.deepEqual({ ...site }, { org: 'acme', id: 'p-site', key: 'site', name: 'Website', status: 'active', state: 'working', git: null, + assert.deepEqual({ ...site }, { org: 'acme', id: 'p-site', key: 'site', name: 'Website', status: 'active', state: 'working', git: null, board: null, sessions: { working: 1, waiting: 1, idle: 0 }, openTasks: 1, needsYou: 1, lastActivity: '2026-11-09T12:00:00.000Z' }); const role = body.sessions.find(s => s.kind === 'role'), thread = body.sessions.find(s => s.kind === 'thread'); assert.equal(role.org, 'acme'); assert.equal(role.role, 'coder'); assert.equal(role.state, 'waiting'); assert.equal(role.inputNeeded, 1); @@ -87,6 +87,16 @@ test('merge: every item from two buses carries its org, and the snapshot names b assert.deepEqual(body.metrics.totals, { needsYou: 1, oldestNeedsYouAt: '2026-11-09T12:00:00.000Z', sessions: { working: 0, waiting: 1, idle: 0, live: 1 }, openTasks: 2, doneThisWeek: 1, projects: { active: 2, archived: 1 } }); }); +test('SP6 board: the org head carries its board, each project row its project board, an invalid org none', async () => { + const ORG_BOARD = { columns: [{ state: 'done', label: 'Solved' }, { state: 'todo', label: 'New' }, { state: 'in-progress', label: 'Working' }, { state: 'in-review', label: 'Waiting' }, { state: 'blocked', label: 'On hold' }], layout: 'list' }; + const SITE_BOARD = { columns: [{ state: 'todo', label: 'Draft' }, { state: 'in-progress', label: 'Editing' }, { state: 'in-review', label: 'Proof' }, { state: 'blocked', label: 'Held' }, { state: 'done', label: 'Live' }], layout: 'kanban' }; + const doc = { ...ACME_DOC, board: ORG_BOARD, projects: { site: { root: '/x', board: SITE_BOARD }, old: { root: '/y' } } }; + const broken = { id: 'gamma', invalid: true, reason: 'organization file is invalid', doc: { board: ORG_BOARD } }; + const body = await readWorkspace({ organizations: organizationsOf(member('acme', ACME, { doc }), member('beta', BETA), broken), session: principal, now }); + assert.deepEqual(body.orgs.map(o => [o.org, o.board]), [['acme', ORG_BOARD], ['beta', null], ['gamma', null]]); + assert.deepEqual(body.projects.map(p => [`${p.org}/${p.key}`, p.board]).sort(), [['acme/site', SITE_BOARD], ['beta/shop', null]]); +}); + test('sections: only the asked sections are read and returned', async () => { const asked = []; const tracked = member('acme', ACME, { over: Object.fromEntries(['inbox', 'agents', 'claims', 'threads', 'tasks', 'projects', 'usage', 'limits', 'decisions'].map(n => [n, () => { asked.push(n); return Promise.resolve(ACME[n] ?? (['usage', 'limits'].includes(n) ? {} : [])); }])) });
    OrgIdTaskProjectStateAssigneePriorityDue