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/<id>/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.
This commit is contained in:
1 parent
9a649abf85
commit
154c2216f3
15 files changed
+977
-37
No files matched your search
@@ -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 }),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -9,12 +9,22 @@
|
||||
// POST /api/orgs/<id>/board {revision, board, project?}: board.set.console (board null removes it)
|
||||
// POST /api/orgs/<id>/workflows/<name> {revision, workflow, project?}: workflow.set.console
|
||||
// DELETE /api/orgs/<id>/workflows/<name> {revision, project?}: workflow.set.console with workflow null
|
||||
// GET /api/orgs/<id>/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 });
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
|
||||
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/panes.css">
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/system-types.css"><script src="/system-types.js" defer></script><link rel="stylesheet" href="/autonomy.css"><script src="/autonomy.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/panes.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/system-types.css"><script src="/system-types.js" defer></script><link rel="stylesheet" href="/autonomy.css"><script src="/autonomy.js" defer></script><link rel="stylesheet" href="/packs.css"><script src="/packs.js" defer></script><script src="/workflows.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/panes.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<body>
|
||||
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
|
||||
@@ -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}
|
||||
@@ -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/<id>/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)} <span class="muted">from ${esc(item.name)}</span>` : esc(item.target);
|
||||
const diff = item.diff?.primitives?.length ? `<span class="muted"> differs in ${item.diff.primitives.map(esc).join(', ')}</span>` : '';
|
||||
return `<tr data-pack-item="${esc(item.path)}"><td>${esc(what)}</td><td>${name}</td><td><code>${esc(item.path)}</code></td><td data-pack-mark="${esc(item.mark)}">${esc(markText(item.mark))}${diff}</td></tr>`;
|
||||
}
|
||||
function hireBlock(h) {
|
||||
const flagged = h.flagged === null
|
||||
? '<p class="note" data-pack-noflag>No chief to compare against: every line will need the owner.</p>'
|
||||
: Array.isArray(h.flagged) && h.flagged.length
|
||||
? `<p>Flagged lines, which only the owner approves:</p><ul data-pack-flagged>${h.flagged.map(l => `<li><code>${esc(l.field)}</code> ${esc(l.value)} <span class="muted">${(l.reasons || []).map(r => r === 'above-chief' ? 'above the chief' : r === 'resolve-right' ? 'a resolve right' : r).map(esc).join(', ')}</span></li>`).join('')}</ul>`
|
||||
: '<p class="note">No flagged lines.</p>';
|
||||
const creds = Array.isArray(h.credentialLines) && h.credentialLines.length
|
||||
? `<p>Credential lines:</p><ul data-pack-credentials>${h.credentialLines.map(([service, scope]) => `<li><code>${esc(service)}</code> ${esc(scope)}</li>`).join('')}</ul>`
|
||||
: '<p class="note">No credential lines.</p>';
|
||||
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 ? '<p class="pack-nohire" data-pack-nohire>On this organization\'s no-hire list: the decision will say so.</p>' : '';
|
||||
return `<li class="pack-hire" data-pack-hire="${esc(h.name)}"><strong>${esc(h.name)}</strong> <span class="muted">role ${esc(h.definition)}</span><p>${esc(mark)}</p>${noHire}${flagged}${creds}</li>`;
|
||||
}
|
||||
function previewBlock(s) {
|
||||
const p = s.preview;
|
||||
if (!p) return '';
|
||||
const v = p.value;
|
||||
const items = Array.isArray(v.items) && v.items.length ? `<div class="ws-tbl"><table><thead><tr><th scope="col">Item</th><th scope="col">Name</th><th scope="col">Path</th><th scope="col">Plan</th></tr></thead><tbody>${v.items.map(itemRow).join('')}</tbody></table></div>` : '<p class="note">The pack has no items.</p>';
|
||||
const hires = Array.isArray(v.hires) && v.hires.length ? `<h4>Hires</h4><ul class="pack-hires">${v.hires.map(hireBlock).join('')}</ul>` : '<p class="note">The pack proposes no hires.</p>';
|
||||
const skills = v.skills?.notEnabled?.length ? `<p data-pack-skills>Skills this machine has not enabled: ${v.skills.notEnabled.map(n => `<code>${esc(n)}</code>`).join(', ')}</p>` : '';
|
||||
const chief = v.chief === false ? '<p class="note">The organization has no chief, so no line is compared against one.</p>' : '';
|
||||
const apply = s.writable ? `<button class="btn" type="button" data-pack-apply>Apply this plan</button>` : '<p class="note">This Console was started without send. Packs can be previewed, not applied.</p>';
|
||||
return `<div class="pack-preview" data-pack-preview="${esc(p.pack)}"><h4>Preview: ${esc(v.pack?.label ?? p.pack)}${v.project ? ` <span class="muted">for project ${esc(v.project)}</span>` : ''}</h4>${items}${hires}${skills}${chief}${apply}</div>`;
|
||||
}
|
||||
function packRow(org, s, entry) {
|
||||
const pack = entry.pack;
|
||||
const project = pack.scope === 'project'
|
||||
? s.projects === null ? '<p class="note">The project list could not be read.</p>' : s.projects.length
|
||||
? `<label>Project<select data-pack-project>${s.projects.map(id => `<option value="${esc(id)}">${esc(id)}</option>`).join('')}</select></label>`
|
||||
: '<p class="note">No code project to apply this pack to.</p>'
|
||||
: '';
|
||||
const canPreview = pack.scope !== 'project' || (s.projects && s.projects.length);
|
||||
return `<li class="pack-row" data-pack="${esc(entry.id)}"><strong>${esc(pack.label ?? entry.id)}</strong> <span class="muted"><code>${esc(entry.id)}</code> ${esc(pack.version)} · ${esc(entry.origin)} · ${pack.scope === 'project' ? 'project pack' : 'organization pack'}</span>
|
||||
<p>${esc(pack.description)}</p>${project}${canPreview ? '<button class="btn" type="button" data-pack-preview-button>Preview</button>' : ''}</li>`;
|
||||
}
|
||||
function section(org) {
|
||||
const s = states.get(org);
|
||||
if (!s || s.error) return `<section class="panel" data-packs data-packs-org="${esc(org)}"><h3>Starter packs</h3><p>${esc(s?.error ?? 'Starter packs could not be read.')}</p></section>`;
|
||||
const list = s.packs.length ? `<ul class="pack-list">${s.packs.map(e => packRow(org, s, e)).join('')}</ul>` : '<p class="note">No starter packs are installed.</p>';
|
||||
return `<section class="panel" data-packs data-packs-org="${esc(org)}"><h3>Starter packs</h3><p>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.</p>${list}${previewBlock(s)}<p role="status" data-packs-status>${esc(s.status)}</p></section>`;
|
||||
}
|
||||
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 };
|
||||
})();
|
||||
@@ -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 `<div class="tb-toolbar">
|
||||
@@ -144,7 +167,7 @@
|
||||
</article>`;
|
||||
}
|
||||
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 `<div class="tb-board" style="--tb-cols:${cols.length}" data-tb-kanban>${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 ? '<p class="tb-colnote">Vikunja does not tell the bus which bucket an open task is in.</p>' : ''}${inCol.map(card).join('') || '<p class="tb-empty">No tasks</p>'}</section>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
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 `<select class="tb-move" data-tb-move="${esc(key(t))}" data-focus-key="move:${esc(key(t))}" aria-label="${esc(`State of ${shortId(t.ref)}, ${t.title || 'Untitled'}`)}">${STATES.map(([v, l]) => opt(v, l, t.state)).join('')}</select>`;
|
||||
if (!taskWritable(t)) return esc(stateLabel(t.state) || 'Not read');
|
||||
return `<select class="tb-move" data-tb-move="${esc(key(t))}" data-focus-key="move:${esc(key(t))}" aria-label="${esc(`State of ${shortId(t.ref)}, ${t.title || 'Untitled'}`)}">${cols.map(([v, l]) => opt(v, l, t.state)).join('')}</select>`;
|
||||
};
|
||||
return `<div class="ws-tbl tb-list" data-tb-list><table><thead><tr><th scope="col">Org</th><th scope="col">Id</th><th scope="col">Task</th><th scope="col" class="hide-m">Project</th><th scope="col">State</th><th scope="col" class="hide-m">Assignee</th><th scope="col" class="hide-m">Priority</th><th scope="col" class="hide-m">Due</th></tr></thead><tbody>
|
||||
${sorted.map(t => `<tr class="tb-row ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${esc(key(t))}" data-org="${esc(t.org)}">
|
||||
@@ -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 @@
|
||||
? `<fieldset class="tb-roles"><legend>Assignees</legend>${roles.map(r => `<label><input type="checkbox" name="assignees" value="${esc(r)}"${picked.includes(r) ? ' checked' : ''}>${esc(r)}</label>`).join('')}</fieldset>`
|
||||
: '<p class="tb-field-note">Assignees: this organization reports no roles.</p>');
|
||||
const prioSelect = p => `<label>Priority<select name="priority">${[0, 1, 2, 3, 4, 5].map(n => `<option value="${n}"${n === (p ?? 0) ? ' selected' : ''}>${n ? `P${n}` : 'None'}</option>`).join('')}</select></label>`;
|
||||
const stateSelect = s => `<label>State<select name="state">${STATES.map(([v, l]) => `<option value="${v}"${v === s ? ' selected' : ''}>${l}</option>`).join('')}</select></label>`;
|
||||
const stateSelect = s => `<label>State<select name="state">${states().map(([v, l]) => `<option value="${esc(v)}"${v === s ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>`;
|
||||
function projectOptions(org, current) {
|
||||
return `<option value="">No project</option>${snap.projects.filter(p => p.org === org).map(p => `<option value="${esc(p.id)}"${p.id === current ? ' selected' : ''}>${esc(p.name || p.key || p.id)}</option>`).join('')}`;
|
||||
}
|
||||
@@ -329,7 +352,7 @@
|
||||
const facts = `<p class="tb-facts">${av(t.org, 'xs')}${esc(o.label)} · <span class="mono">${esc(shortId(t.ref))}</span> ${trk(t.tracker)}${t.requirement ? ` · <span class="mono">${esc(t.requirement)}</span>` : ''}${projectName(t) ? ` · ${esc(projectName(t))}` : ''}</p>`;
|
||||
if (!w) {
|
||||
return `<div class="tb-form" data-tb-form="view"><h2 id="tb-dialog-h">${title(t)}</h2>${facts}
|
||||
<dl class="tb-dl"><dt>State</dt><dd>${esc(STATE_LABEL[t.state] || 'Not read')}</dd><dt>Assignees</dt><dd>${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}</dd><dt>Priority</dt><dd>${prio(t) || 'none'}</dd><dt>Due</dt><dd>${due(t) || 'none'}</dd></dl>
|
||||
<dl class="tb-dl"><dt>State</dt><dd>${esc(stateLabel(t.state) || 'Not read')}</dd><dt>Assignees</dt><dd>${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}</dd><dt>Priority</dt><dd>${prio(t) || 'none'}</dd><dt>Due</dt><dd>${due(t) || 'none'}</dd></dl>
|
||||
<p class="tb-field-note">${esc(why)}</p>
|
||||
${refusal ? `<p id="tb-dialog-status" class="tb-form-status" role="status" data-tb-form-status>${esc(refusal)}</p>` : ''}
|
||||
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
|
||||
|
||||
@@ -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/<id>/workflows reads the org's and each code project's workflows
|
||||
// with the file revision; a save is POST .../workflows/<name> {revision, workflow, project?} and a delete
|
||||
// DELETE .../workflows/<name> {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)
|
||||
? `<code>${esc(role)}</code>`
|
||||
: `<code>${esc(role)}</code> <span class="wf-missing" data-wf-missing>${MISSING}</span>`;
|
||||
function stepItem(s, step) {
|
||||
const roles = Array.isArray(step?.roles) ? step.roles : [];
|
||||
return `<li data-wf-step><span>${esc(step?.title)}</span> <span class="muted">${esc(STATE_LABEL[step?.state] ?? step?.state)}</span> <span class="wf-roles">${roles.map(r => roleChip(s, r)).join(', ')}</span></li>`;
|
||||
}
|
||||
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
|
||||
? `<p>Delete this workflow? <button class="btn" type="button" data-wf-delete-yes>Delete</button> <button class="btn" type="button" data-wf-delete-no>Keep</button></p>`
|
||||
: `<p><button class="btn" type="button" data-wf-edit>Edit</button> <button class="btn" type="button" data-wf-delete>Delete</button></p>`;
|
||||
return `<li class="wf-item" data-wf="${esc(name)}" data-wf-scope="${esc(scope)}"><strong data-wf-title>${esc(w?.title || name)}</strong> <span class="muted"><code>${esc(name)}</code></span>
|
||||
${w?.description ? `<p>${esc(w.description)}</p>` : ''}<ol class="wf-steps">${steps.map(st => stepItem(s, st)).join('')}</ol>${actions}</li>`;
|
||||
}
|
||||
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 `<fieldset class="wf-step" data-wf-edit-step="${i}"><legend>Step ${i + 1}</legend>
|
||||
<label>Title<input name="title" maxlength="120" value="${esc(step.title)}" autocomplete="off"></label>
|
||||
<label>State<select name="state">${STATES.map(([v, l]) => `<option value="${v}"${step.state === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>
|
||||
<label>Roles, separated by commas<input name="roles" value="${esc(step.roles)}" autocomplete="off"></label>
|
||||
<p class="wf-note" data-wf-step-missing>${missing.length ? `${missing.map(r => `<code>${esc(r)}</code>`).join(', ')} ${MISSING}` : ''}</p>
|
||||
<button class="btn" type="button" data-wf-step-remove>Remove step</button></fieldset>`;
|
||||
}
|
||||
function editor(s) {
|
||||
const e = s.editing;
|
||||
if (!e) return '';
|
||||
const scopeField = e.isNew
|
||||
? `<label>Where<select name="scope">${scopes(s).map(([v, l]) => `<option value="${esc(v)}"${e.scope === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label><label>Name<input name="name" maxlength="64" value="${esc(e.name)}" autocomplete="off" placeholder="lowercase letters, digits, dot, dash"></label>`
|
||||
: `<p>${e.scope ? `Project ${esc(e.scope)}` : 'Organization'}, <code>${esc(e.name)}</code></p>`;
|
||||
return `<form class="wf-editor" data-wf-editor><h4>${e.isNew ? 'New workflow' : 'Edit workflow'}</h4>${scopeField}
|
||||
<label>Title<input name="title" maxlength="120" value="${esc(e.draft.title)}" autocomplete="off"></label>
|
||||
<label>Description<textarea name="description" maxlength="1000" rows="2">${esc(e.draft.description)}</textarea></label>
|
||||
${e.draft.steps.map((st, i) => stepRow(s, st, i)).join('')}
|
||||
<p><button class="btn" type="button" data-wf-step-add>Add step</button> <button class="btn" type="submit">Save workflow</button> <button class="btn" type="button" data-wf-cancel>Cancel</button></p>
|
||||
<p class="note">Roles name an organization role, a role template, or <code>human</code>.</p></form>`;
|
||||
}
|
||||
function section(org) {
|
||||
const s = states.get(org);
|
||||
if (!s || s.error) return `<section class="panel" data-workflows data-workflows-org="${esc(org)}"><h3>Workflows</h3><p>${esc(s?.error ?? 'Workflows could not be read.')}</p></section>`;
|
||||
const lists = scopes(s).map(([scope, label, flows]) => {
|
||||
const names = Object.keys(flows || {}).sort();
|
||||
return `<div class="wf-scope" data-wf-scope-list="${esc(scope)}"><h4>${esc(label)}</h4>${names.length ? `<ul class="wf-list">${names.map(n => workflowItem(org, s, scope, n, flows[n])).join('')}</ul>` : '<p class="note">No workflows.</p>'}</div>`;
|
||||
}).join('');
|
||||
const add = s.data.writable ? (s.editing ? '' : '<p><button class="btn" type="button" data-wf-new>New workflow</button></p>') : '<p class="note">Only the owner, on a Console started with send, can change workflows.</p>';
|
||||
return `<section class="panel" data-workflows data-workflows-org="${esc(org)}"><h3>Workflows</h3><p>A workflow lists the steps a task goes through, the state each step puts it in and the roles that do it.</p>${lists}${add}${editor(s)}<p role="status" data-workflows-status>${esc(s.status)}</p></section>`;
|
||||
}
|
||||
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 };
|
||||
})();
|
||||
@@ -207,7 +207,7 @@
|
||||
const org = list?.find(o => o.id === id && !o.invalid);
|
||||
if (!org) return `<article class="ws ws-dash" data-org-dashboard data-org-unavailable><div class="ws-vh"><h1 tabindex="-1">Organization unavailable</h1><p class="sub">${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}</p></div></article>`;
|
||||
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) ?? ''}
|
||||
</section>
|
||||
</div>
|
||||
</article>`;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)]),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <img src=x onerror=window.pwn=1>', 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/<id>/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);
|
||||
}
|
||||
});
|
||||
@@ -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 = '<img src=x onerror=window.__sp6pwn=1>';
|
||||
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, <b>x</b>';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`), '<b>x</b> 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);
|
||||
});
|
||||
@@ -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 = '<img src=x onerror=window.pwn=1>';
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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) ? {} : [])); }])) });
|
||||
|
||||
Reference in new issue
Block a user