Compare commits

...
8 Commits
Author SHA1 Message Date
jason.woltje a71da41146 fix(webui): U4b re-check fixes, conflict recovery and partial empty state (#1873)
A conflict on a task another writer moved out of the board's server
filter now reads that task from its organization unfiltered, so the
dialog reopens on the current task. An empty result names the
organizations whose tasks were not read instead of saying there are
no tasks.
2026-11-10 20:04:49 +00:00
jason.woltje 4c74e7ea49 fix(webui): U4b review fixes for the task board (#1873)
- Edit and archive require the digest the board read (expect); the route
  refuses a write without it.
- The edit dialog saves against the task it showed, so a poll that reads a
  newer task turns a stale save into task-conflict; the dialog reopens on the
  current task.
- An answer the board cannot read, or one marked sent=unknown, is shown as an
  open outcome, not as a refusal.
- The per-org cap applies after the filters and keeps the most recently
  changed tasks; projects, metrics and the inbox count every task. The board
  sends project, state, assignee and label filters to the server.
- A poll keeps the focus, caret and scroll the person has at the moment it
  answers, not when it started.
- An org whose tasks read fails shows "tasks not read" and is not writable.
- browser.test.mjs pins tasks-board.js in the script order.
2026-11-10 20:04:49 +00:00
jason.woltje ac3a863bec feat(webui): U4b one task board across organizations (#1873)
Replaces the Tasks placeholder with sample 06: every organization's
tasks read through its own bus and tagged with the org, Kanban and List
with the layout remembered per viewer, and org, project, state,
assignee and label filters. A down org keeps its U1 state.

Writes (create, move, edit, archive) go to POST /api/workspace/tasks,
behind the hook fence, owner only and --send only. Each write resolves
only the org it names and calls that org's bus with the person's store
verbs, so the bus's human check, tracker check and digest check all
apply. Refusals reach the person with what was and was not written.

A Vikunja organization's tasks are read through the tasks adapter and
shown read only; the store verbs refuse them with tracker-not-store.
2026-11-10 20:04:49 +00:00
jason.woltje af7d330101 docs: clarify host trust boundary 2026-11-10 19:57:49 +00:00
jason.woltje db75994f82 docs: define accounts host trust boundary 2026-11-10 19:57:49 +00:00
jason.woltje ac977d833b test(webui): retry the projects grouping click while the nav repaints (#1873)
The workspace nav repaints after async organization and project reads.
Under load (gate at load 14) a repaint landed between the flat-grouping
wait and the org click, and the click found no button. The click now
retries inside the wait until the page shows org grouping.
2026-11-10 13:46:18 -06:00
jason.woltje aa087e7380 fix(webui): U3 review fixes, figures from failed reads say not read (#1873)
A project row's sessions, open tasks and needs-you figures come from
reads beyond the project list; when those failed in the row's
organization the cells now say not read instead of none or 0, and a
note names the organization. Dashboard per-organization figures and
Done this week count only organizations that made the tile's reads, a
dashboard with no answering organization shows no totals, and empty
lists say not read when no organization could make the read. Sessions
now names each session's organization for screen readers. The
org-archive test waits for the setup steps, which render after the
editor title.
2026-11-10 13:35:49 -06:00
jason.woltje 68fd66903b feat(webui): U3 dashboard, projects and inbox across organizations (#1873)
In organization mode #/ is the cross-organization dashboard (sample 02),
#/projects lists every organization's projects with sort, filters and the
shared By org / Flat grouping (sample 04), and #/inbox lists decisions
across organizations with per-organization decide paths and terminal
commands carrying --org and the full id (sample 05). The per-organization
board moves to #/board and the organization's own project list to #/chat,
which keeps the project surface. Reads go through workspace-data.js; a
read an organization could not make says not read, never zero. Polling
repaints in place and keeps focus, caret and scroll.
2026-11-10 13:35:49 -06:00
26 changed files with 2485 additions and 48 deletions

No files matched your search

+68
View File
@@ -881,6 +881,58 @@ OIDC session or an account. The rule:
never on a deployed one.
- Dev mode keeps every CLI verb as it is.
### Host trust boundary
The host is the trust boundary. The instance uid can read and write the
instance's config, data root, bus files and credentials. That makes the uid
equivalent to root for this instance, though not root on the host. A CLI
token cannot protect the instance from someone who already has that uid:
they can read the token file, the bus database and every key, or change the
files directly. A token could help a remote admin CLI that has no host
access. That is a possible later feature, not part of Phase M.
Phase M narrows who is assigned the instance uid. In the current managed
host setup, both Case 1 and Case 2 use `mosaic`: the host run sheet creates
it as a system account with a non-login shell and no supplementary groups
or sudo rule (`docs/guides/host-run-sheet.md:19-26`). Case 1 acceptance
checks that the Console and bus run as the configured `--user`
(`scripts/case1-accept.sh:102-116,457-469`). Case 2's unit renderer uses
its `--user` for both services (`scripts/case2-unit.sh:136-145,514`); the
host setup runs that path as `mosaic`
(`docs/plans/2026-10-20_case2-rows.md:1197-1200`). These are today's
configured identities, not an installer guarantee: `case2-unit.sh` accepts
any existing non-root user with a matching home. The run sheet, rather than
the renderer, selects the dedicated account.
There is also a separate Console path. `webui-service.sh` installs a
systemd user unit, then asks the operator to start it with
`systemctl --user` (`scripts/webui-service.sh:1-7,19,413-418`). That Console
runs as the account whose user manager starts it, not as the managed
`mosaic` service account. It is a single-user development path, not a safe
accounts-mode deployment path. M3 follow-up 1 is to refuse a deployed or
accounts-mode Console service through this user-unit path.
The stack's own managed agent sessions run as the instance uid inside a
bubblewrap sandbox, not in containers (`scripts/case1-accept.sh:480-496`,
`docs/plans/2026-10-20_case2-rows.md:1031-1034,1197-1199`). The sandbox
limits their filesystem view. A sandbox escape has the same authority as
holding the instance uid. This is different from a development agent seat:
Phase M requires the instance uid to differ from every human login and
development seat uid. A non-login shell prevents direct login, but root or a
human with sudo or equivalent authority can still act as `mosaic`.
M2's accounts loader refuses a file with the wrong uid or group or other
write permission. M2 does not currently specify the config-directory owner
check for `mosaic accounts` or an OS-uid receipt. M3 pins both as integration
expectations. The recorded uid identifies the process, not
necessarily the person who invoked it. The receipt is writable by the
instance uid and is not tamper-evident against that uid. Stamped builds and
`.deployed` also refuse dev flags in M3.
The residual risk is accepted: host root, or the instance uid, owns the
instance. Accounts protect users from one another through the Console and
bus. They do not protect an instance from its host owner.
### Locks
Three locks exist after this design: the per-org lifecycle lock
@@ -1295,6 +1347,7 @@ Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off.
| Identity confusion across providers | Identity is `(provider, sub)` with the issuer from config. Email matches invites only. A subject belongs to one account. |
| Local CLI acting outside the check | In accounts mode `mosaic org` and `decide --org` refuse. `mosaic accounts` runs as the instance uid, which can already read every bus file, and has no rights verbs. |
| Tampering with accounts.json on disk | The file must belong to the instance uid and not be group or other writable. Anyone who can write it already runs as the instance user and can read every bus. That is outside this design, as it is for access.json. |
| Host root or the instance uid bypassing accounts | Accepted residual risk: either can read and change the instance config, data root, bus files and credentials. An administrator who can become the instance uid has the same authority. An OS-uid receipt identifies the process, not the human, and the instance uid can change the receipt. |
Not covered: a compromised identity provider, a stolen session cookie
before its 1 hour idle limit, and a compromised Console process, which holds
@@ -1397,6 +1450,21 @@ Acceptance tests, against a fake OIDC provider on port 0:
- `--dev-local-owner` from a checkout refuses with accounts.json present,
with `console.sso`, and with `.deployed`.
- `webui-service.sh render` and `case2-unit.sh` refuse a dev flag.
- `case2-unit.sh` accepts the host run sheet's `mosaic` account fixture only
when its shell is non-login, its primary group has no other members, and
it owns the config directory and data root. Fixtures for a human login and
a development seat with interactive shells are refused. Both rendered
system units use that same uid.
- `case1-accept.sh` F1 continues to verify that the running Console and bus
processes have the configured instance uid.
- M3 integration tests verify that `mosaic accounts` refuses when its
effective uid does not own the config directory and that its receipt
records the effective OS uid. The test documents that this is process
identity, not human attribution or tamper evidence.
- **M3 follow-up 1:** accounts mode refuses the `webui-service.sh` systemd
user-unit path. Managed accounts instances start through the system units
rendered by `case2-unit.sh`. Tests show the user-unit path cannot start a
deployed accounts instance while development mode remains available.
- The existing sso and dev-owner suites stay green.
Grant needed: `stack-copy.sh` writes a new file into every copy, and the
+5 -1
View File
@@ -12,6 +12,9 @@ export const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.
export const PROXY_VERBS = Object.freeze(['proxy.accounts', 'proxy.login.start', 'proxy.login.submit',
'proxy.login.status', 'proxy.login.cancel', 'proxy.apikey.add', 'proxy.priority.set', 'proxy.group.models.set', 'proxy.keys', 'plugins', 'plugins.set']);
export const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request'];
// U4b (#1873): the task board's writes, the person's built-in store verbs (broker tasks.*). The
// caller refuses any other, so the board's taskWrite never reaches unarchive or delete.
export const TASK_WRITE_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive']);
export const agentCaller = (call, verbs) => async (verb, args) => {
if (!verbs.includes(verb)) throw new Error(`the Console does not call ${verb}`);
return call(verb, args);
@@ -36,13 +39,14 @@ export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
projectFiles: args => call('project.files', args),
projectFile: args => call('project.file', args),
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
send: null, librarySync: null, libraryRevert: null, librarySave: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
send: null, librarySync: null, libraryRevert: null, librarySave: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null, taskWrite: null,
};
if (sendOn) {
o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.libraryRevert = args => call('library.revert', args); o.settingsWrite = args => call('limits.set', args);
o.librarySave = args => call('library.file.save', args);
o.projectSave = args => call('project.file.save', args); o.nameWrite = args => call('name.set', args); o.assistantStop = args => call('assistant.stop', args);
o.instancesWrite = args => call('instances.set', args); o.routesWrite = args => call('routes.set', args); o.agentWrite = agentCaller(call, AGENT_VERBS);
o.taskWrite = agentCaller(call, TASK_WRITE_VERBS);
}
return o;
}
+33 -18
View File
@@ -717,7 +717,7 @@
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.</p>
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section>
${threadSection}
<p class="small muted">Board seats stay on the <a href="#/">control board</a>. This view is about roles.</p>`;
<p class="small muted">Board seats stay on the <a href="${window.MosaicOrg ? '#/board' : '#/'}">control board</a>. This view is about roles.</p>`;
}
// ---------------------------------------------------------------- queue, business, settings
@@ -1391,7 +1391,7 @@
});
// Not found and states
function notFound(why) { return `${head('Not found')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="#/">Go to the Board</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
function notFound(why) { return `${head('Not found')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="#/">Go to the ${window.MosaicOrg ? 'Dashboard' : 'Board'}</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
const loadingView = (source = null) => `${head('Reading…')}<div class="skel-rows" aria-busy="true"><span class="sr-only">${source ? `Reading the ${esc(source.toLowerCase())}.` : 'Reading from the bus.'}</span><span class="skel"></span><span class="skel"></span><span class="skel"></span><span class="skel"></span></div>`;
const retry = '<div class="actions"><button type="button" class="btn" data-retry>Read again</button></div>';
// Why a read failed, in words, with the bus code as given. Never a path or a guess.
@@ -1442,20 +1442,16 @@
// ---------------------------------------------------------------- router
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
const routeOf = () => window.MosaicOrg?.route() ?? (location.hash.startsWith('#/') ? location.hash.slice(1) : '/');
// A one-line scope note right under a view's page heading.
function afterHead(html, note) {
const start = html.indexOf('<div class="page-head">'), end = start < 0 ? -1 : html.indexOf('</div></div>', start);
const line = `<p class="small muted ws-scope" data-not-built>${esc(note)}</p>`;
return end < 0 ? `${line}${html}` : `${html.slice(0, end + 12)}${line}${html.slice(end + 12)}`;
}
function view(get, path, q, opts, current) {
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
if (parts.includes(null)) return notFound();
const [a, b, c] = parts, n = parts.length;
if (n === 0 && window.MosaicOrg) return window.MosaicWorkspace.home();
if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q);
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
if (a === 'side-by-side' && n === 1 && window.MosaicOrg) return `${head('Side by side', 'Not built yet.')}${empty('Side by side is not built yet.', 'It arrives with a later unified workspace update. Projects and Agents work today.')}`;
if (a === 'inbox' && n === 1 && window.MosaicOrg) return inboxView(get).then(html => afterHead(html, 'The inbox across all organizations is not built yet. This is the selected organization\'s inbox.'));
if (a === 'tasks' && n === 1 && window.MosaicOrg) return tasksView(get).then(html => afterHead(html, 'Tasks across all organizations are not built yet. These are the selected organization\'s tasks.'));
if (a === 'inbox' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.inboxPage(q);
if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view();
if (a === 'inbox' && n === 1) return inboxView(get);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
if (a === 'messages' && n === 1) return messagesView(get, q);
@@ -1475,7 +1471,9 @@
let gen = 0, shown = null, timer, proxyCleanup=null, orgCleanup=null;
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
// A file open in the editor is never refreshed from under its draft; Reload is the person's to press.
function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), 10000); }
// The cross-organization pages (U3) poll at the workspace read's pollMs.
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/projects' || path === '/inbox');
function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 10000); }
// Keep focus, open <details> and the copy status across a refresh of the same page.
// In the send form, the focused field and its caret too (its values come from `draft`).
function keep() {
@@ -1489,6 +1487,7 @@
field: view.contains(a) ? a.dataset?.field : null, caret: view.contains(a) && typeof a.selectionStart === 'number' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null, send: view.contains(a) && a.hasAttribute?.('data-send'),
decide: view.contains(a) ? a.dataset?.decide ?? (a.id === 'decide-status' ? 'status' : null) : null,
open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '',
scroll: [document.scrollingElement?.scrollTop ?? 0, $('main').scrollTop, [...view.querySelectorAll('.ws-tbl, .ws-insp')].map(e => [e.scrollLeft, e.scrollTop])],
};
}
function restore(k) {
@@ -1502,7 +1501,7 @@
const setting=k.settingsField && [...$('bus-view').querySelectorAll('[data-instance-field],[data-assistant-field],[data-route],[data-setting]')].find(e=>e.dataset[k.settingsField.key]===k.settingsField.value && (!k.settingsField.form || e.closest('form')?.id===k.settingsField.form) && (!k.settingsField.instance || e.closest('[data-instance]')?.dataset.instance===k.settingsField.instance));
// The workspace screens mark their controls with data-focus-key: several Open links share an href.
const keyed = k.focusKey ? $('bus-view').querySelector(k.focusKey === 'bus-restart' ? '[data-bus-restart]' : `[data-focus-key="${CSS.escape(k.focusKey)}"]`) : null;
if (keyed) keyed.focus({ preventScroll: true });
if (keyed) { keyed.focus({ preventScroll: true }); if (k.caret && keyed.tagName === 'INPUT' && typeof keyed.setSelectionRange === 'function') try { keyed.setSelectionRange(k.caret[0], k.caret[1]); } catch {} }
else if (setting) { setting.focus({preventScroll:true}); if(k.caret && typeof setting.setSelectionRange==='function'){setting.setSelectionRange(k.caret[0],k.caret[1]);setting.scrollTop=k.caret[2];} }
else if (choice) choice.focus({preventScroll:true});
else if (dec) dec.focus({ preventScroll: true });
@@ -1511,23 +1510,35 @@
if (k.caret && el.tagName === 'TEXTAREA') { el.setSelectionRange(k.caret[0], k.caret[1]); el.scrollTop = k.caret[2]; }
}
else if (k.h1) $('bus-view').querySelector('h1')?.focus({ preventScroll: true });
// A repaint replaces the tables: put their horizontal scroll and the page's back.
if (k.scroll) {
const [top, main, inner] = k.scroll;
[...$('bus-view').querySelectorAll('.ws-tbl, .ws-insp')].forEach((e, i) => { if (inner[i]) { e.scrollLeft = inner[i][0]; e.scrollTop = inner[i][1]; } });
if (document.scrollingElement && document.scrollingElement.scrollTop !== top) document.scrollingElement.scrollTop = top;
if ($('main').scrollTop !== main) $('main').scrollTop = main;
}
}
async function render(navigated, opts = {}) {
// A refresh due while this render reads would replace it: a navigation would lose its heading focus.
clearTimeout(timer);
if (out()) return;
const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === '' || path === '/board';
const route = routeOf(), [path, query = ''] = route.split('?'), board = window.MosaicOrg ? path === '/board' : path === '/' || path === '' || path === '/board';
// On the cross-organization pages a filter or selection changes only the query: that is a
// repaint of the same page, not a navigation (no loading view, focus and scroll kept).
const samePage = wsPage(path) && typeof shown === 'string' && shown.split('?')[0] === path;
if (path !== '/settings/model-proxy' && proxyCleanup) {proxyCleanup();proxyCleanup=null;}
if ((navigated || path !== '/settings/organizations') && orgCleanup) {orgCleanup();orgCleanup=null;}
// Leaving a decision discards its confirmation immediately, including a preview in flight.
if (answer && (navigated || path !== `/inbox/${answer.id}`)) answer = null;
const g = ++gen, routeVersion = routesEditVersion;
const current = () => g === gen && route === routeOf() && !out();
// In organization mode #/ is the cross-organization dashboard; the processing controls stay on the board.
if (window.MosaicOrg) for (const id of ['processing-link', 'mobile-processing-link']) if ($(id) && $(id).getAttribute('href') !== '#/board') $(id).setAttribute('href', '#/board');
document.body.classList.toggle('on-bus', !board);
$('bus-view').hidden = board;
sections(path);
document.querySelectorAll('.rail a').forEach(a => { const target = a.getAttribute('href').slice(1); const active = target === '/projects' ? /^\/(projects|chat)(\/|$)/.test(path) : target === '/' ? path === '/' : path === target; if (active) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); });
const chatPath = path === '/projects' ? '/chat' : /^\/projects\/(threads|rooms)\//.test(path) ? '/chat' + path.slice('/projects'.length) : /^\/projects\/[^/]+$/.test(path) ? '/chat/projects/' + path.slice('/projects/'.length) : path;
const chatPath = path === '/projects' && !window.MosaicOrg ? '/chat' : /^\/projects\/(threads|rooms)\//.test(path) ? '/chat' + path.slice('/projects'.length) : /^\/projects\/[^/]+$/.test(path) ? '/chat/projects/' + path.slice('/projects/'.length) : path;
window.MosaicChat?.leave(chatPath);
if (path === '/business') { location.replace('#/settings/business'); return; }
if (path === '/settings/display') { location.replace('#/settings/appearance'); return; }
@@ -1597,11 +1608,11 @@
if (path === '/settings/model-proxy' && shown === route && !navigated) return;
if (path === '/settings/organizations' && shown?.split('?')[0] === path && !navigated) return;
if (($('agent-dialog')?.open || document.querySelector('.agent-more[aria-expanded=true]')) && !navigated) { schedule(); return; }
if (navigated || shown !== route) { unmountEditor(); $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
if (navigated || (shown !== route && !samePage)) { unmountEditor(); $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
let html, state = 'fresh';
const live = reader(true);
try {
if (path !== '/organization' && !(path === '/agents' && window.MosaicOrg) && path !== '/messages' && !['/settings','/settings/appearance','/settings/model-proxy','/settings/organizations'].includes(path)) {
if (path !== '/organization' && !wsPage(path) && !(path === '/agents' && window.MosaicOrg) && path !== '/messages' && !['/settings','/settings/appearance','/settings/model-proxy','/settings/organizations'].includes(path)) {
try { await live('messages'); }
catch (err) { messageCount = null; if (err.status === 403 || err.code === 'signed-out') throw err; }
}
@@ -1617,7 +1628,7 @@
freshBus(state, SOURCES[path.split('/')[1]]);
// A route edit after the markup was prepared must win over that pending redraw.
if(path==='/settings/routing' && routeVersion!==routesEditVersion){render(false);return;}
const k = !navigated && shown === route ? keep() : null;
const k = !navigated && (shown === route || samePage) ? keep() : null;
unmountEditor();
$('bus-view').innerHTML = html; shown = route;
syncAppearance(true);
@@ -1800,10 +1811,14 @@
}
if (!canSend && !isTester && !ssoOn) return;
const path = routeOf().split('?')[0];
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')||wsPage(path)) render(false); else if (window.MosaicOrg ? path === '/board' : path === '/' || path === '') paintHeld(heldErr);
};
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {});
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') applyConfig(e.detail); });
// The cross-organization pages ask for a repaint after a filter, sort or selection change,
// and report the inbox count across organizations for the sidebar.
document.addEventListener('mosaic-workspace-render', () => { if (wsPage(routeOf().split('?')[0])) render(false); });
document.addEventListener('mosaic-workspace-inbox', e => { const d = e.detail; if (d && Number.isFinite(d.open)) { inboxCount = { open: d.open, blocking: Number.isFinite(d.blocking) ? d.blocking : 0 }; sections(routeOf().split('?')[0]); } });
// This context comes from the conversations renderer after its authorized read.
let projectContext = null;
document.addEventListener('mosaic-project-context', event => {
+3 -1
View File
@@ -99,7 +99,9 @@
}
return data;
}
const legacyRoute = () => (window.MosaicOrg?.route() ?? location.hash.slice(1)).startsWith('/chat');
// In organization mode #/projects is the cross-organization page, so the organization's own
// project list lives at #/chat. That list keeps the project surface and its #/projects links.
const legacyRoute = () => { const r = window.MosaicOrg?.route() ?? location.hash.slice(1); return r.startsWith('/chat') && !(window.MosaicOrg && r.split('?')[0] === '/chat'); };
const projectPath = id => legacyRoute() ? `#/chat/projects/${id}` : `#/projects/${id}`;
const threadPath = id => legacyRoute() ? `#/chat/threads/${id}` : `#/projects/threads/${id}`;
const roomPath = id => legacyRoute() ? `#/chat/rooms/${id}` : `#/projects/rooms/${id}`;
+2 -1
View File
@@ -33,8 +33,9 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
return;
}
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
// /api/workspace reads and writes across organizations (U1, U4b), so it keeps its own path.
window.fetch = (input, init) => {
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/levels(?:\?|$))/.test(input))
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/levels(?:\?|$))/.test(input))
input = `/api/orgs/${id}/${input.slice('/api/'.length)}`;
return nativeFetch(input, init);
};
+2 -2
View File
@@ -1,13 +1,13 @@
<!doctype html>
<html lang="en" data-design="console" data-palette="harbor">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Dashboard · Mosaic Console</title>
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/inbox.css"><link rel="stylesheet" href="/requests.css"><link rel="stylesheet" href="/files.css"><link rel="stylesheet" href="/project-remote.css"><link rel="stylesheet" href="/shell.css">
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/inbox.css"><link rel="stylesheet" href="/requests.css"><link rel="stylesheet" href="/files.css"><link rel="stylesheet" href="/project-remote.css"><link rel="stylesheet" href="/shell.css"><link rel="stylesheet" href="/tasks-board.css">
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
<link rel="stylesheet" href="/project-archive.css"><link rel="stylesheet" href="/projects.css">
<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>
<script src="/workspace-data.js" defer></script><script src="/workspace.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><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="/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><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="/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>
+1 -1
View File
@@ -628,7 +628,7 @@
[$('org-quick-run'), 'click', quickRun],
// F2: the interview opens a conversation with the assistant; Open organization skips it.
[$('org-quick-interview'), 'click', async () => { const b = $('org-quick-interview'); b.disabled = true; if (!await window.MosaicOnboarding?.start({ ...quickDone, status })) b.disabled = false; }],
[$('org-quick-open-org'), 'click', () => { history.replaceState(null, '', `#/orgs/${encodeURIComponent(st.id)}/projects`); location.reload(); }],
[$('org-quick-open-org'), 'click', () => { history.replaceState(null, '', `#/orgs/${encodeURIComponent(st.id)}/chat`); location.reload(); }],
[$('org-open-form'), 'submit', e => { e.preventDefault(); const id = $('org-open-id').value; if (id) openExisting(id); }],
[$('org-form'), 'submit', save],
[$('org-form'), 'input', onFormInput],
+148
View File
@@ -538,6 +538,139 @@ html[data-organizations] .ws-off{display:block;margin-top:4px}
html[data-organizations] .ws-rules .ws-lvl{margin-right:0}
html[data-organizations] .ws-scope{margin:-8px 0 14px}
/* Across organizations (U3): the main dashboard, projects and inbox, samples 02, 04 and 05. */
body:has([data-ws-home]) #breadcrumbs,body:has([data-ws-projects]) #breadcrumbs,body:has([data-ws-inbox]) #breadcrumbs{display:none}
html[data-organizations] .ws-edge{box-shadow:inset 3px 0 0 var(--org)}
html[data-organizations] .ws-orgref{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;color:var(--strong);font-weight:600}
html[data-organizations] .ws-chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;padding:1px 7px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap;line-height:1.5}
html[data-organizations] .ws-chip.gated{background:color-mix(in srgb,var(--danger) 14%,var(--surface));color:var(--danger)}
html[data-organizations] .ws-chip.block{background:var(--danger);color:var(--surface)}
html[data-organizations] .ws-chip.outline{background:none;box-shadow:inset 0 0 0 1px var(--line);color:var(--text);font-family:var(--mono);font-weight:500;font-size:11.5px}
html[data-organizations] .ws-dot.s-ok{color:var(--success)}
html[data-organizations] .ws-tbl .ws-dot,html[data-organizations] .ws-sess .ws-dot{color:var(--text)}
html[data-organizations] .ws-tbl .ws-dot.s-working b,html[data-organizations] .ws-tbl .ws-dot.s-ok b{color:var(--success)}
html[data-organizations] .ws-tbl .ws-dot.s-waiting b{color:var(--warning)}
html[data-organizations] .ws-tbl .ws-dot.s-error b{color:var(--danger)}
html[data-organizations] .ws-tbl .ws-dot.s-idle b,html[data-organizations] .ws-tbl .ws-dot.s-offline b{color:var(--muted)}
html[data-organizations] .ws-g5{display:grid;gap:10px;grid-template-columns:repeat(5,minmax(0,1fr))}
html[data-organizations] .ws-tile .ws-big{font-size:24px;font-weight:800;color:var(--strong);letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.1}
html[data-organizations] .ws-tile .ws-big small{font-size:13px;font-weight:600;color:var(--muted);letter-spacing:0;margin-left:4px}
html[data-organizations] .ws-tile .ws-delta{font-size:12px;color:var(--muted)}
html[data-organizations] .ws-tile .ws-delta b{color:var(--success);font-weight:600}
html[data-organizations] .ws-tile.quiet{border-style:dashed;background:transparent}
html[data-organizations] .ws-bar{display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--raised);gap:2px}
html[data-organizations] .ws-bar i{display:block;background:var(--org,var(--action))}
html[data-organizations] .ws-by{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:12px;color:var(--text)}
html[data-organizations] .ws-by>span{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;font-variant-numeric:tabular-nums}
html[data-organizations] .ws-by .ws-av{width:14px;height:14px;border-radius:3px;font-size:7px}
html[data-organizations] .ws-orgcards{list-style:none;margin:0;padding:0;display:grid;gap:8px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}
html[data-organizations] .ws-orgcard a{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:2px 10px;align-items:center;padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);box-shadow:inset 3px 0 0 var(--org);color:var(--text);text-decoration:none;font-size:13px;height:100%}
html[data-organizations] .ws-orgcard a:hover{background:var(--raised)}
html[data-organizations] .ws-orgcard .ws-av{grid-row:1/3}
html[data-organizations] .ws-orgcard .nm{font-weight:700;color:var(--strong);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
html[data-organizations] .ws-orgcard .ws-dot{grid-column:2;grid-row:2;font-size:12.5px;color:var(--text)}
html[data-organizations] .ws-orgcard .ws-dot.s-working b{color:var(--success)}
html[data-organizations] .ws-orgcard .ws-dot.s-waiting b{color:var(--warning)}
html[data-organizations] .ws-orgcard .ws-dot.s-error b{color:var(--danger)}
html[data-organizations] .ws-orgcard .at{grid-column:3;grid-row:1;font:500 11.5px var(--mono);color:var(--muted)}
html[data-organizations] .ws-orgcard .why{grid-column:1/-1;font-size:12px;color:var(--muted)}
html[data-organizations] .ws-orgcard.off a{border-style:dashed}
html[data-organizations] .ws-home-split{margin-top:4px}
html[data-organizations] .ws-trail{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
html[data-organizations] .ws-trail li{display:grid;grid-template-columns:48px 20px minmax(0,1fr);gap:10px;padding:7px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);font-size:13px;align-items:center}
html[data-organizations] .ws-trail li:last-child{border-bottom:0}
html[data-organizations] .ws-trail time{font-family:var(--mono);font-size:12px;color:var(--muted)}
html[data-organizations] .ws-trail .what{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
html[data-organizations] .ws-trail .what a{color:var(--strong);text-decoration:none}
html[data-organizations] .ws-trail .what a:hover{text-decoration:underline}
html[data-organizations] .ws-trail .what b{font-weight:600}
html[data-organizations] .ws-trail .src{color:var(--muted)}
html[data-organizations] .ws-states{display:grid;gap:8px}
html[data-organizations] .ws-srow{display:grid;grid-template-columns:112px 24px minmax(0,1fr) auto;gap:10px;align-items:center;font-size:13px}
html[data-organizations] .ws-srow .sl{color:var(--strong);font-weight:600}
html[data-organizations] .ws-srow .sn{font:700 13px var(--mono);color:var(--strong);text-align:right}
html[data-organizations] .ws-srow .ws-bar{height:8px;max-width:100%}
html[data-organizations] .ws-needs{list-style:none;margin:0;padding:0;display:grid;gap:8px}
html[data-organizations] .ws-needs li{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:4px 10px;align-items:center;background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:9px 12px}
html[data-organizations] .ws-needs li>.ws-av{grid-row:1/3}
html[data-organizations] .ws-needs .q{color:var(--strong);font-weight:600;font-size:13.5px;text-decoration:none}
html[data-organizations] .ws-needs .q:hover{text-decoration:underline}
html[data-organizations] .ws-needs .src{grid-column:2;font-size:12px;color:var(--muted)}
html[data-organizations] .ws-needs .ws-chip{grid-row:1/3;grid-column:3}
html[data-organizations] .ws-more-line{margin:6px 0 0}
html[data-organizations] .ws-sess td.who{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:0;width:100%}
html[data-organizations] .ws-sess td.who .ws-orgref{vertical-align:middle}
html[data-organizations] .ws-sess td.st{white-space:nowrap}
/* Filters and tables on Projects and Inbox. */
html[data-organizations] .ws-toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;margin-bottom:12px}
html[data-organizations] .ws-toolbar .ws-right{align-items:center}
html[data-organizations] .ws-toolbar .ws-seg{margin-left:0}
html[data-organizations] .ws-fchips{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
html[data-organizations] .ws-fchips .f{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:999px;padding:2px 10px 2px 4px;font:600 12.5px/1.5 var(--font);color:var(--text);white-space:nowrap;cursor:pointer;box-shadow:none}
html[data-organizations] .ws-fchips .f.plain{padding-left:10px}
html[data-organizations] .ws-fchips .f:hover{background:var(--raised)}
html[data-organizations] .ws-fchips .f[aria-pressed="true"]{background:var(--strong);color:var(--surface);border-color:var(--strong)}
html[data-organizations] .ws-fchips .f .ws-av{width:18px;height:18px;border-radius:999px;font-size:8.5px;letter-spacing:0}
html[data-organizations] .ws-fchips .f .n{margin-left:2px;font:600 11px var(--mono);opacity:.8}
html[data-organizations] .ws-sel{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 0 0 8px;font-size:13px;color:var(--strong);white-space:nowrap}
html[data-organizations] .ws-sel .ws-label{font-size:10px}
html[data-organizations] .ws-sel select{border:0;background:transparent;color:var(--strong);font:500 13px/1.5 var(--font);padding:3px 6px 3px 2px;min-height:0;box-shadow:none;width:auto}
html[data-organizations] .ws-search{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 8px;color:var(--muted);font-size:13px;min-width:180px}
html[data-organizations] .ws-search svg.i{width:14px;height:14px;flex:none}
html[data-organizations] .ws-search input{border:0;background:transparent;color:var(--strong);font:500 13px/1.5 var(--font);padding:3px 0;min-height:0;box-shadow:none;width:100%;min-width:0}
html[data-organizations] .ws-search:focus-within,html[data-organizations] .ws-sel:focus-within{outline:2px solid var(--focus,var(--action));outline-offset:1px}
html[data-organizations] .ws-search input:focus,html[data-organizations] .ws-sel select:focus{outline:none}
html[data-organizations] .ws-tbl th.sorted{color:var(--strong)}
html[data-organizations] .ws-tbl th .ar{font-family:var(--mono);margin-left:3px;color:var(--action)}
html[data-organizations] .ws-tbl td.num,html[data-organizations] .ws-tbl th.num{text-align:right;font-variant-numeric:tabular-nums}
html[data-organizations] .ws-tbl tr.ws-edge td:first-child{box-shadow:inset 3px 0 0 var(--org)}
html[data-organizations] .ws-tbl tr.ws-edge{box-shadow:none}
html[data-organizations] .ws-tbl .nm{color:var(--strong);font-weight:600;text-decoration:none}
html[data-organizations] .ws-tbl a.nm:hover{text-decoration:underline}
html[data-organizations] .ws-tbl .ws-n{margin-left:0}
html[data-organizations] .ws-grp-row th{padding:8px 10px 6px;background:color-mix(in srgb,var(--raised) 45%,var(--surface));text-transform:none;letter-spacing:0;font-size:12.5px;color:var(--strong);box-shadow:inset 3px 0 0 var(--org)}
html[data-organizations] .ws-grp-row th>*{vertical-align:middle}
html[data-organizations] .ws-grp-row th>span{margin-left:8px}
html[data-organizations] .ws-tbl .ws-grp-row th>.ws-n{margin-left:8px}
html[data-organizations] .ws-unread td{font-size:12.5px}
html[data-organizations] .ws-unread-note{margin:-4px 0 10px}
html[data-organizations] .ws-av.mob{display:none}
html[data-organizations] .ws .show-m{display:none}
/* Inbox. */
html[data-organizations] .ws-ibx-grid{display:grid;gap:16px;grid-template-columns:minmax(0,1fr)}
html[data-organizations] .ws-ibx-grid.with-insp{grid-template-columns:minmax(0,1fr) minmax(300px,360px);align-items:start}
html[data-organizations] .ws-ibx-list>.ws-sec-h{margin-top:22px}
html[data-organizations] .ws-ibx td.src{white-space:nowrap}
html[data-organizations] .ws-ibx .q{color:var(--strong);font-weight:600;text-decoration:none}
html[data-organizations] .ws-ibx .q:hover{text-decoration:underline}
html[data-organizations] .ws-ibx .via{display:block;font-size:12px;color:var(--muted)}
html[data-organizations] .ws-ibx tr.on{background:color-mix(in srgb,var(--action) 10%,var(--surface))}
html[data-organizations] .ws-ibx tr.on td:first-child{box-shadow:inset 3px 0 0 var(--action)}
html[data-organizations] .ws-msrc{align-items:center;gap:5px;font-size:12px;color:var(--muted);margin-bottom:2px}
html[data-organizations] .ws-insp{border:1px solid var(--line);border-radius:10px;background:var(--surface);display:flex;flex-direction:column;min-width:0;position:sticky;top:12px;max-height:calc(100vh - 80px);overflow:auto}
html[data-organizations] .ws-insp header{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:1;box-shadow:inset 3px 0 0 var(--org)}
html[data-organizations] .ws-insp header .x{margin-left:auto;color:var(--muted);display:inline-flex}
html[data-organizations] .ws-insp header .x svg.i{width:18px;height:18px}
html[data-organizations] .ws-insp .body{padding:14px;display:grid;gap:14px}
html[data-organizations] .ws-insp h3{margin:0;font-size:16px;font-weight:700;color:var(--strong)}
html[data-organizations] .ws-insp .note{margin:0}
html[data-organizations] .ws-auth{border:1px solid var(--line);border-left:3px solid var(--org,var(--action));border-radius:0 6px 6px 0;padding:8px 10px;background:var(--canvas);font-size:13px;color:var(--strong)}
html[data-organizations] .ws-opts{list-style:none;margin:0;padding:0;display:grid;gap:8px}
html[data-organizations] .ws-opts li{border:1px solid var(--line);border-radius:8px;padding:9px 10px;display:grid;gap:6px;background:var(--canvas)}
html[data-organizations] .ws-opts .k{font:700 13px var(--mono);color:var(--strong)}
html[data-organizations] .ws-opts .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
html[data-organizations] .ws-opts b{font-weight:600;color:var(--strong)}
html[data-organizations] [data-ws-cmds]{display:grid;gap:6px}
html[data-organizations] .ws-cmd{display:flex;gap:6px;align-items:center;min-width:0}
html[data-organizations] .ws-cmd code{flex:1;min-width:0;overflow-x:auto;white-space:nowrap;background:var(--surface);border:1px solid var(--line);border-radius:4px;padding:3px 6px;font-size:12px;color:var(--strong)}
html[data-organizations] .ws-cmd .ws-btn{padding:4px 6px}
html[data-organizations] #copy-status:empty{display:none}
@media (max-width:1100px){
html[data-organizations] .ws-g5{grid-template-columns:repeat(3,minmax(0,1fr))}
html[data-organizations] .ws-ibx-grid.with-insp{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .ws-insp{position:static;max-height:none}
}
@media (pointer:coarse){html[data-organizations] .ws-nav,html[data-organizations] .s1-section.ws-nav{min-height:40px}}
@media (max-width:759px){
html[data-organizations] .content:has(.ws){padding:14px 16px 24px}
@@ -559,4 +692,19 @@ html[data-organizations] .ws-scope{margin:-8px 0 14px}
html[data-organizations] .ws-levels>.span{grid-column:auto}
html[data-organizations] .ws-levels>.last.lh{border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
html[data-organizations] .ws-levels .mlabel{display:flex}
html[data-organizations] .ws-g5{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .ws-g5.keep2{grid-template-columns:repeat(2,minmax(0,1fr))}
html[data-organizations] .ws-g5.keep2>:last-child:nth-child(odd){grid-column:1/-1}
html[data-organizations] .ws-orgcards{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .ws-srow{grid-template-columns:92px 20px minmax(0,1fr)}
html[data-organizations] .ws-srow .ws-by{grid-column:1/-1}
html[data-organizations] .ws-trail li{grid-template-columns:42px 18px minmax(0,1fr);gap:8px}
html[data-organizations] .ws-trail .what{white-space:normal}
html[data-organizations] .ws-toolbar .ws-right{margin-left:0}
html[data-organizations] .ws-search{min-width:0;flex:1 1 100%}
html[data-organizations] .ws .show-m,html[data-organizations] .ws-tbl .t2.show-m{display:block}
html[data-organizations] .ws span.show-m{display:inline-flex}
html[data-organizations] .ws span.ws-msrc.show-m{display:flex}
html[data-organizations] .ws-av.mob{display:inline-grid;margin-right:6px;vertical-align:-3px}
html[data-organizations] .ws-tbl .t2.mono.hide-m{display:none}
}
+97
View File
@@ -0,0 +1,97 @@
/* U4b (#1873): the task board across organizations, ported from the U0 sample 06 (workspace.css
EXT 7 and the board rules). Organization mode only; the org colour comes from .ws-o (shell.css). */
html[data-organizations] .tb .ws-vh .ws-seg{margin-left:0}
html[data-organizations] .tb-trk{display:inline-flex;align-items:center;gap:4px;font:600 10.5px var(--mono);letter-spacing:.02em;padding:0 5px;border-radius:3px;border:1px solid var(--line);color:var(--muted);white-space:nowrap}
html[data-organizations] .tb-trk.vik{border-style:dashed;color:var(--accent);border-color:color-mix(in srgb,var(--accent) 60%,var(--line))}
html[data-organizations] .tb-trackers{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center;font-size:12.5px;color:var(--text);padding:8px 12px;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface);margin-bottom:12px}
html[data-organizations] .tb-trackers>span{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap}
html[data-organizations] .tb-trackers .note{font-size:12.5px}
html[data-organizations] .tb-tracker .ws-dot{font-weight:600}
html[data-organizations] .tb-reason{color:var(--muted)}
html[data-organizations] .tb-notice{margin:0 0 10px;font-size:13px;color:var(--muted)}
html[data-organizations] .tb-notice:empty{display:none}
html[data-organizations] .tb-notice.is-error{color:var(--danger);font-weight:600}
/* Filter bar. */
html[data-organizations] .tb-toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;margin-bottom:12px}
html[data-organizations] .tb-right{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
html[data-organizations] .tb-chips{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
html[data-organizations] .tb-f{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:999px;padding:2px 10px 2px 4px;font:600 12.5px/1.5 var(--font);color:var(--text);white-space:nowrap;cursor:pointer;box-shadow:none}
html[data-organizations] .tb-f.plain{padding-left:10px}
html[data-organizations] .tb-f[aria-pressed=true]{background:var(--strong);color:var(--surface);border-color:var(--strong)}
html[data-organizations] .tb-f .ws-av{width:18px;height:18px;border-radius:999px;font-size:8.5px;letter-spacing:0}
html[data-organizations] .tb-f .n{margin-left:2px;color:inherit;opacity:.8}
html[data-organizations] .tb-sel{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 0 0 8px;font-size:13px;color:var(--strong);white-space:nowrap}
html[data-organizations] .tb-sel .ws-label{font-size:10px}
html[data-organizations] .tb-sel select{border:0;background:none;color:var(--strong);font:500 13px/1.5 var(--font);padding:3px 6px;min-height:0;max-width:16em;box-shadow:none}
html[data-organizations] .tb-search{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 0 0 8px;color:var(--muted);min-width:180px}
html[data-organizations] .tb-search svg.i,html[data-organizations] .tb-who svg.i{width:13px;height:13px}
html[data-organizations] .tb-search input{border:0;background:none;color:var(--strong);font:13px/1.5 var(--font);padding:3px 8px 3px 0;min-height:0;width:100%;min-width:0;box-shadow:none}
html[data-organizations] .tb-sel:focus-within,html[data-organizations] .tb-search:focus-within{outline:2px solid var(--action);outline-offset:1px}
html[data-organizations] .tb-sel select:focus,html[data-organizations] .tb-search input:focus{outline:none}
html[data-organizations] .tb-count{margin:0 0 8px;font-size:13px;color:var(--muted)}
/* Kanban. */
html[data-organizations] .tb-board{display:grid;grid-template-columns:repeat(var(--tb-cols,5),minmax(0,1fr));gap:12px;align-items:start}
html[data-organizations] .tb-col{background:color-mix(in srgb,var(--raised) 55%,var(--canvas));border:1px solid var(--line);border-radius:var(--r-lg);padding:8px;display:grid;gap:8px;min-width:0}
html[data-organizations] .tb-col.is-over{outline:2px dashed var(--action);outline-offset:-2px}
html[data-organizations] .tb-col-h{display:flex;align-items:center;gap:8px;padding:2px 4px}
html[data-organizations] .tb-col-h h3{margin:0;font-size:13px;font-weight:700;color:var(--strong)}
html[data-organizations] .tb-col-h .n{font-size:12px;font-weight:600;color:var(--muted)}
html[data-organizations] .tb-col-h .mix{margin-left:auto;display:flex;gap:2px}
html[data-organizations] .tb-colnote,html[data-organizations] .tb-empty{margin:0;padding:2px 4px;font-size:12px;color:var(--muted)}
html[data-organizations] .tb-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-table);padding:8px 10px 8px 12px;display:grid;gap:5px;min-width:0;box-shadow:inset 3px 0 0 var(--org)}
html[data-organizations] .tb-card[draggable=true]{cursor:grab}
html[data-organizations] .tb-card.is-dragging{opacity:.5}
html[data-organizations] .tb-card .top{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
html[data-organizations] .tb-card .top .id{font:500 11.5px var(--mono);color:var(--muted)}
html[data-organizations] .tb-card .top .tb-trk{margin-left:auto}
html[data-organizations] .tb-card .tt{color:var(--strong);font-weight:600;font-size:13px;line-height:1.35;min-width:0}
html[data-organizations] .tb-card .bot{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);flex-wrap:wrap}
html[data-organizations] .tb-card.dim .tt{color:var(--text)}
html[data-organizations] .tb-open{all:unset;cursor:pointer;overflow-wrap:anywhere}
html[data-organizations] .tb-open:hover{text-decoration:underline}
html[data-organizations] .tb-open:focus-visible{outline:2px solid var(--action);outline-offset:2px;border-radius:2px}
html[data-organizations] .tb-who{display:inline-flex;align-items:center;gap:4px}
html[data-organizations] .tb-chip{display:inline-flex;align-items:center;font-size:11.5px;font-weight:600;padding:0 6px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap}
html[data-organizations] .tb-chip.warn{background:color-mix(in srgb,var(--warning) 16%,var(--surface));color:var(--warning)}
/* List. */
html[data-organizations] .tb-row td:first-child{box-shadow:inset 3px 0 0 var(--org)}
html[data-organizations] .tb-row.dim td{color:var(--muted)}
html[data-organizations] .tb-list .tb-open.t1{display:inline;color:var(--strong)}
html[data-organizations] .tb-list .t2.show-m{display:none}
html[data-organizations] .tb-move{font:500 13px/1.4 var(--font);color:var(--strong);background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:2px 4px;min-height:0}
/* The create and edit dialog (on <body>, outside the repainted view). */
html[data-organizations] .tb-dialog{border:1px solid var(--line);border-radius:12px;background:var(--surface);color:var(--text);padding:0;width:min(520px,calc(100vw - 32px));max-height:calc(100vh - 48px);box-shadow:0 12px 40px rgba(0,0,0,.25)}
html[data-organizations] .tb-dialog::backdrop{background:rgba(0,0,0,.35)}
html[data-organizations] .tb-form{display:grid;gap:10px;padding:18px 20px;font-size:14px}
html[data-organizations] .tb-form h2{margin:0;font-size:17px;font-weight:700;color:var(--strong);overflow-wrap:anywhere}
html[data-organizations] .tb-form label{display:grid;gap:4px;font-size:12.5px;font-weight:600;color:var(--strong)}
html[data-organizations] .tb-form input:not([type=checkbox]),html[data-organizations] .tb-form select{font:14px/1.4 var(--font);color:var(--strong);background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:5px 8px;min-width:0}
html[data-organizations] .tb-row2{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
html[data-organizations] .tb-roles{border:1px solid var(--line);border-radius:var(--r);padding:6px 10px 8px;margin:0;display:flex;flex-wrap:wrap;gap:4px 14px}
html[data-organizations] .tb-roles legend{font-size:12.5px;font-weight:600;color:var(--strong);padding:0 4px}
html[data-organizations] .tb-roles label{display:inline-flex;align-items:center;gap:6px;font-weight:500;color:var(--text)}
html[data-organizations] .tb-field-note,html[data-organizations] .tb-facts{margin:0;font-size:12.5px;color:var(--muted)}
html[data-organizations] .tb-facts{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
html[data-organizations] .tb-dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:4px 14px;margin:0;font-size:13px}
html[data-organizations] .tb-dl dt{color:var(--muted)}
html[data-organizations] .tb-dl dd{margin:0;color:var(--strong)}
html[data-organizations] .tb-form-status{margin:0;font-size:13px;color:var(--danger);font-weight:600}
html[data-organizations] .tb-form-status:empty{display:none}
html[data-organizations] .tb-actions{display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end;align-items:center}
html[data-organizations] .tb-gap{flex:1}
html[data-organizations] .tb-form .ws-btn.primary{background:var(--action);color:var(--onAction);border-color:var(--action)}
html[data-organizations] .tb-form .ws-btn.primary:hover:not(:disabled){background:color-mix(in srgb,var(--action) 88%,var(--strong))}
html[data-organizations] .tb-form .ws-btn.danger{color:var(--danger)}
@media (max-width:759px){
html[data-organizations] .tb-board{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .tb-right{margin-left:0;flex:1 1 100%}
html[data-organizations] .tb-search{min-width:0;flex:1}
html[data-organizations] .tb-list .t2.show-m{display:block}
html[data-organizations] .tb-row2{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
html[data-organizations] .tb-sel select{max-width:11em}
}
+496
View File
@@ -0,0 +1,496 @@
// U4b (#1873, PRD 1.20): one task board across every organization (sample 06).
//
// Reads: GET /api/workspace?sections=tasks,projects, where each org's tasks come from its own tracker
// module (the built-in store or Vikunja), merged and tagged with the org. The project, state, assignee
// and label filters go to the server, which applies them before its per-org cap, so a filter reaches
// every task an org holds; the org chips, the search, Nobody and No project narrow in the page. An org whose bus is down,
// slow or invalid keeps its U1 state on the Trackers line and the others still render.
// Writes: POST /api/workspace/tasks, one org per request, through that org's bus and its person
// verbs (tasks.create, tasks.edit, tasks.archive). The bus runs its own human check; a refusal is
// shown as the bus answered it. Vikunja tasks are read only here: the bus refuses them
// (tracker-not-store), so the board offers no control for them.
// bus.js calls view() for #/tasks in organization mode and repaints it on its own 10 s poll.
(() => {
'use strict';
if (!document.documentElement.hasAttribute('data-organizations')) return;
const data = window.MosaicWorkspaceData;
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const LAYOUT = 'mosaic-tasks-layout-v1';
const load = key => { try { return localStorage.getItem(key); } catch { return null; } };
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);
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'] };
const phone = () => window.matchMedia('(max-width:759px)').matches;
// 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: '' };
// `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;
const mark = () => window.MosaicWorkspace?.orgMark;
const orgOf = id => snap?.orgs.find(o => o.id === id) || { id, label: id, theme: null };
const av = (id, size = '') => mark()?.av(orgOf(id), size) ?? '';
const colours = id => mark()?.colours(orgOf(id)) ?? '';
const icon = id => window.MosaicWorkspace?.icon(id) ?? '';
const key = t => `${t.org}|${t.ref}`;
// Ids keep their tracker's form: #N for the built-in store, V-N for Vikunja.
const shortId = ref => { let m = /^task:(\d+)$/.exec(ref); if (m) return `#${m[1]}`; m = /^vikunja:\d+\/(\d+)$/.exec(ref); return m ? `V-${m[1]}` : ref; };
const trk = tracker => (tracker === 'vikunja' ? '<span class="tb-trk vik">Vikunja</span>' : '<span class="tb-trk">built-in</span>');
const stateOf = t => (STATE_LABEL[t.state] ? t.state : UNREAD);
// An org can answer while its tasks read fails (errors.tasks): its tasks are unavailable, not zero.
const tasksRead = o => o?.state === 'ok' && !o.errors?.tasks;
const orgWritable = o => canWrite && tasksRead(o) && o.tracker === 'built-in';
const taskWritable = t => t.writable === true && typeof t.digest === 'string' && orgWritable(orgOf(t.org));
const projectName = t => t.project?.name || t.project?.key || null;
const dueText = iso => {
const d = new Date(iso);
if (!Number.isFinite(d.getTime())) return null;
const opts = { day: 'numeric', month: 'short', timeZone: 'UTC', ...(d.getUTCFullYear() !== new Date().getUTCFullYear() ? { year: 'numeric' } : {}) };
return d.toLocaleDateString('en-GB', opts);
};
const overdue = t => t.dueDate && t.state !== 'done' && Date.parse(t.dueDate) < Date.now();
// ---------- reads ----------
const PROJECT_SELECTOR = /^[a-z0-9][a-z0-9._-]{0,63}:[A-Za-z0-9._-]{1,128}$/, ROLE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
function readQuery() {
const q = new URLSearchParams({ sections: 'tasks,projects' });
if (PROJECT_SELECTOR.test(filter.project)) q.set('project', filter.project);
if (filter.state) q.set('task-state', filter.state === UNREAD ? 'open' : filter.state);
if (ROLE.test(filter.assignee)) q.set('assignee', filter.assignee);
if (/^[1-9]\d{0,14}$/.test(filter.label)) q.set('label', filter.label);
return q.toString();
}
async function read() {
const [response, list] = await Promise.all([
fetch(`/api/workspace?${readQuery()}`, { cache: 'no-store', redirect: 'manual' }),
data.organizations().then(b => (Array.isArray(b) ? b : null)).catch(() => null),
mark()?.ready().catch(() => null),
window.MosaicConsole?.ready,
]);
canWrite = window.MosaicConsole?.config?.orgSetup === true;
if (response.type === 'opaqueredirect' || !response.ok) {
const body = await response.json().catch(() => null);
throw Object.assign(new Error('read failed'), { status: response.status, message: typeof body?.message === 'string' ? body.message : null });
}
const body = await response.json();
if (!Array.isArray(body?.orgs) || !Array.isArray(body.tasks) || !Array.isArray(body.projects)) throw new Error('read failed');
const named = new Map((list || []).map(o => [o.id, o]));
const orgs = body.orgs.map(o => ({ ...o, id: o.org, label: named.get(o.org)?.label || o.org, theme: named.get(o.org)?.theme ?? o.theme ?? null, roles: Array.isArray(o.roles) ? o.roles : [] }));
return { orgs, tasks: body.tasks, projects: body.projects, summary: body.summary, readAt: body.readAt };
}
// Only the latest read lands: a filter change and the poll can both be reading.
let readSeq = 0;
const reload = async () => { const n = ++readSeq, next = await read(); if (n !== readSeq) return false; snap = next; prune(); return true; };
async function refilter() { paint(); try { if (await reload()) paint(); } catch { /* the next poll shows the failed read */ } }
// A filter naming an org or project that is gone falls back to all.
function prune() {
if (filter.org && !snap.orgs.some(o => o.id === filter.org)) filter.org = '';
if (filter.project && filter.project !== '-' && !snap.projects.some(p => `${p.org}:${p.id}` === filter.project)) filter.project = '';
}
// ---------- filters ----------
function filtered({ q = true } = {}) {
const needle = filter.q.trim().toLowerCase();
return snap.tasks.filter(t => (!filter.org || t.org === filter.org)
&& (!filter.project || (filter.project === '-' ? !t.project : `${t.org}:${t.project?.id}` === filter.project))
&& (!filter.assignee || (filter.assignee === '-' ? !t.assignees.length : t.assignees.includes(filter.assignee)))
&& (!filter.state || stateOf(t) === filter.state)
&& (!filter.label || t.labels.map(String).includes(filter.label))
&& (!q || !needle || `${t.title ?? ''} ${shortId(t.ref)} ${projectName(t) ?? ''}`.toLowerCase().includes(needle)));
}
const select = (name, label, options, focus) => `<label class="tb-sel"><span class="ws-label">${esc(label)}</span><select data-tb-filter="${name}" data-focus-key="${focus}">${options}</select></label>`;
const opt = (value, text, current) => `<option value="${esc(value)}"${value === current ? ' selected' : ''}>${esc(text)}</option>`;
function toolbar() {
const counts = new Map(snap.orgs.map(o => [o.id, 0]));
for (const t of snap.tasks) counts.set(t.org, (counts.get(t.org) ?? 0) + 1);
const chip = (id, inner, label) => `<button type="button" class="tb-f${id ? '' : ' plain'}" data-tb-org="${esc(id)}" aria-pressed="${filter.org === id}" data-focus-key="tb-org:${esc(id)}"${label ? ` aria-label="${esc(label)}"` : ''}>${inner}</button>`;
const chips = [chip('', `All <span class="n">${snap.tasks.length}</span>`)].concat(snap.orgs.map(o => chip(o.id,
`${av(o.id)}${esc(o.label)}<span class="n">${tasksRead(o) ? counts.get(o.id) : 'unavailable'}</span>`,
`${o.label}, ${tasksRead(o) ? `${counts.get(o.id)} tasks` : 'tasks unavailable'}`))).join('');
const scope = snap.orgs.filter(o => !filter.org || o.id === filter.org);
const projectOpts = opt('', `All ${snap.projects.filter(p => !filter.org || p.org === filter.org).length}`, filter.project) + opt('-', 'No project', filter.project)
+ scope.map(o => { const ps = snap.projects.filter(p => p.org === o.id); return ps.length ? `<optgroup label="${esc(o.label)}">${ps.map(p => opt(`${p.org}:${p.id}`, p.name || p.key || p.id, filter.project)).join('')}</optgroup>` : ''; }).join('');
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 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">
<div class="tb-chips" role="group" aria-label="Organization">${chips}</div>
${select('project', 'Project', projectOpts, 'tb-project')}${select('assignee', 'Assignee', assigneeOpts, 'tb-assignee')}${select('state', 'State', stateOpts, 'tb-state')}${labelSel}
<div class="tb-right"><label class="tb-search">${icon('search')}<input type="search" placeholder="Find a task" aria-label="Find a task" data-tb-q data-focus-key="tb-q" value="${esc(filter.q)}" autocomplete="off"></label></div>
</div>`;
}
// ---------- results ----------
const who = t => (t.assignees.length ? `<span class="tb-who">${icon('people')}${esc(t.assignees.join(', '))}</span>` : '');
const prio = t => (Number.isSafeInteger(t.priority) && t.priority > 0 ? `<span class="tb-chip${t.priority >= 4 ? ' warn' : ''}" title="Priority ${t.priority} of 5">P${t.priority}</span>` : '');
const due = t => { const d = t.dueDate && dueText(t.dueDate); return d ? `<span class="tb-chip${overdue(t) ? ' warn' : ''}" title="Due ${esc(t.dueDate.slice(0, 10))} (UTC)">${overdue(t) ? 'overdue' : 'due'} ${esc(d)}</span>` : ''; };
const title = t => esc(t.title || 'Untitled');
function card(t) {
const w = taskWritable(t), k = esc(key(t));
return `<article class="tb-card ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${k}" data-org="${esc(t.org)}"${w ? ' draggable="true"' : ''}>
<div class="top">${av(t.org, 'xs')}<span class="ws-sr">${esc(orgOf(t.org).label)}</span><span class="id">${esc(shortId(t.ref))}</span>${trk(t.tracker)}</div>
<div class="tt"><button type="button" class="tb-open" data-tb-open="${k}" data-focus-key="open:${k}">${title(t)}</button></div>
<div class="bot"><span>${projectName(t) ? esc(projectName(t)) : '<span class="muted">No project</span>'}</span>${who(t)}${prio(t)}${due(t)}</div>
</article>`;
}
function kanban(rows) {
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}"` : '';
return `<section class="tb-col"${drop} aria-label="${esc(l)}"><div class="tb-col-h"><h3>${esc(l)}</h3><span class="n">${inCol.length}</span>${orgs.length ? `<span class="mix" title="Organizations in this column">${orgs.map(o => av(o, 'xs')).join('')}</span>` : ''}</div>
${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 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>`;
};
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)}">
<td>${av(t.org)}<span class="ws-sr">${esc(orgOf(t.org).label)}</span></td>
<td class="mono">${esc(shortId(t.ref))}<span class="t2">${t.tracker === 'vikunja' ? 'Vikunja' : 'built-in'}</span></td>
<td><button type="button" class="tb-open t1" data-tb-open="${esc(key(t))}" data-focus-key="lopen:${esc(key(t))}">${title(t)}</button><span class="t2 show-m">${esc(projectName(t) || 'No project')}</span></td>
<td class="hide-m">${projectName(t) ? esc(projectName(t)) : '<span class="muted">none</span>'}</td>
<td>${stateCell(t)}</td>
<td class="hide-m">${t.assignees.length ? esc(t.assignees.join(', ')) : '<span class="muted">nobody</span>'}</td>
<td class="hide-m">${prio(t) || '<span class="muted">none</span>'}</td>
<td class="hide-m">${due(t) || '<span class="muted">none</span>'}</td>
</tr>`).join('')}</tbody></table></div>`;
}
function results() {
const rows = filtered(), all = snap.tasks.length;
// Project, state, assignee and label filter on the server, so the board never holds the unfiltered
// total; with one of those on, the line says how many match rather than inventing an "of N".
const onServer = new URLSearchParams(readQuery()).size > 1, n = rows.length;
const line = onServer ? `<p class="tb-count">Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.</p>`
: n !== all ? `<p class="tb-count">Showing ${n} of ${all} tasks.</p>` : '';
// An empty result is only exhaustive when every organization's tasks were read.
const unread = snap.orgs.filter(o => !tasksRead(o));
const none = what => (unread.length ? `${what} in the organizations that answered. Tasks from ${unread.map(o => o.label).join(', ')} could not be read.` : `${what}.`);
const body = !snap.orgs.length ? '<p class="ws-panel tight note">No organizations yet.</p>'
: !snap.orgs.some(tasksRead) ? '<p class="ws-panel tight note">No organization answered with its tasks, so no tasks could be read.</p>'
: !all && !onServer ? `<p class="ws-panel tight note">${esc(none('No tasks yet'))}</p>`
: !rows.length ? `<p class="ws-panel tight note">${esc(none('No tasks match these filters'))}</p>`
: layoutNow() === 'list' ? list(rows) : kanban(rows);
return `<div data-tb-results>${line}${body}</div>`;
}
// ---------- page ----------
function trackers() {
const items = snap.orgs.map(o => {
const s = ORG_STATE[o.state] ?? (o.state === 'ok' && o.errors?.tasks ? ['s-error', '▲', 'tasks not read'] : null);
const why = ORG_STATE[o.state] ? o.reason : o.errors?.tasks;
const st = s ? ` <span class="ws-dot ${s[0]}"><b aria-hidden="true">${s[1]}</b>${esc(s[2])}</span>${why ? `<span class="tb-reason">${esc(why)}</span>` : ''}` : '';
const cut = o.tasksHeld ? `<span class="tb-reason">shows the 500 most recently changed of ${esc(o.tasksHeld)} matching tasks</span>` : '';
return `<span class="tb-tracker${s ? ' is-down' : ''}" data-tb-tracker="${esc(o.id)}" data-state="${esc(o.state)}">${av(o.id)}${esc(o.label)} ${o.tracker ? trk(o.tracker) : ''}${st}${cut}</span>`;
}).join('');
return `<div class="tb-trackers"><span class="ws-label">Trackers</span>${items}<span class="note">Ids keep their tracker's form: V-12 is Vikunja, #41 is built in.</span></div>`;
}
function head() {
const lay = layoutNow();
const writableOrgs = snap ? snap.orgs.filter(orgWritable) : [];
const why = !canWrite ? 'This Console takes no task changes.' : !writableOrgs.length ? 'No organization with a built-in task store answered.' : '';
const seg = `<span class="ws-seg" role="group" aria-label="Layout"><button type="button" data-tb-layout="kanban" aria-pressed="${lay === 'kanban'}" data-focus-key="tb-layout-kanban">Kanban</button><button type="button" data-tb-layout="list" aria-pressed="${lay === 'list'}" data-focus-key="tb-layout-list">List</button></span>`;
const add = `<button type="button" class="ws-btn" data-tb-new data-focus-key="tb-new"${why ? ` disabled title="${esc(why)}"` : ''}>${icon('plus')}New task</button>`;
let sub = '';
if (snap) {
const n = snap.tasks.length, m = snap.orgs.filter(tasksRead).length, of = snap.summary?.of ?? snap.orgs.length;
sub = `${n} ${n === 1 ? 'task' : 'tasks'} from ${m} of ${of} ${of === 1 ? 'organization' : 'organizations'}. Each organization keeps its own tasks; a change made here goes only to that organization's bus.`;
if (snap.orgs.some(o => o.tracker === 'vikunja')) sub += ' Vikunja tasks are read only here.';
if (!canWrite) sub += ' This Console takes no task changes, so the board is read only.';
}
return `<div class="ws-vh"><h1 tabindex="-1">Tasks</h1><div class="ws-right">${seg}${add}</div>${sub ? `<p class="sub">${sub}</p>` : ''}</div>`;
}
const noticeLine = () => (notice ? `<p class="tb-notice${notice.error ? ' is-error' : ''}" role="${notice.error ? 'alert' : 'status'}" data-tb-notice>${esc(notice.text)}</p>` : '<p class="tb-notice" role="status" data-tb-notice></p>');
function markup() {
return `<section class="ws tb" data-tasks-board data-layout="${layoutNow()}">${head()}${noticeLine()}${trackers()}${toolbar()}${results()}</section>`;
}
const failed = err => `<section class="ws tb" data-tasks-board><div class="ws-vh"><h1 tabindex="-1">Tasks</h1><p class="sub">The task board could not be read${err?.message && err.message !== 'read failed' ? `: ${esc(err.message)}` : ''}. It tries again on the next refresh.</p></div></section>`;
// Kept across a repaint: focus by data-focus-key, the search caret and the sideways scroll of the
// board and the list. bus.js restores focus by key; the caret and scroll are put back here.
function snapshotView(root) {
const a = document.activeElement;
return {
focus: root && root.contains(a) ? a.dataset?.focusKey || null : null,
caret: root && root.contains(a) && a.matches?.('[data-tb-q]') ? [a.selectionStart, a.selectionEnd] : null,
scroll: root ? [...root.querySelectorAll('.tb-board,.tb-list')].map(e => [e.matches('.tb-board') ? 'board' : 'list', e.scrollLeft]) : [],
};
}
// With focus false (after bus.js's own repaint, which puts focus back by key) the caret is put back
// only if focus is still on the search box it was taken from.
function putBack(root, kept, { focus = true } = {}) {
if (!root || !kept) return;
for (const [which, left] of kept.scroll) { const e = root.querySelector(which === 'board' ? '.tb-board' : '.tb-list'); if (e) e.scrollLeft = left; }
const el = kept.focus ? root.querySelector(`[data-focus-key="${CSS.escape(kept.focus)}"]`) : null;
if (focus && el && document.activeElement !== el) el.focus({ preventScroll: true });
if (el && document.activeElement === el && kept.caret && el.matches('[data-tb-q]')) el.setSelectionRange(kept.caret[0], kept.caret[1]);
}
// Repaint in place, for the board's own changes (filters, layout, a write).
function paint(part = 'all') {
const root = document.querySelector('[data-tasks-board]');
if (!root || !snap) return;
const kept = snapshotView(root);
if (part === 'results') root.querySelector('[data-tb-results]').outerHTML = results();
else root.outerHTML = markup();
putBack(document.querySelector('[data-tasks-board]'), kept);
}
// bus.js's view for #/tasks. A drag in progress holds the poll's repaint until it ends.
async function view() {
if (dragging) await Promise.race([dragEnded, new Promise(r => setTimeout(r, 30000))]);
try { await reload(); } catch (err) { snap = null; return failed(err); }
// Taken after the read, just before bus.js replaces the view, so focus, caret or scroll moved while
// the read was in flight is what is kept. bus.js puts focus back by key; this puts back the rest.
const kept = snapshotView(document.querySelector('[data-tasks-board]'));
setTimeout(() => putBack(document.querySelector('[data-tasks-board]'), kept, { focus: false }), 0);
return markup();
}
// ---------- writes ----------
const UNKNOWN = Object.freeze({ ok: false, error: 'outcome-unknown', unknown: true, message: 'The Console\'s answer could not be read. The change may or may not have been written; the board shows it on its next read.' });
async function write(body) {
let response, answer = null;
try {
response = await fetch('/api/workspace/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });
answer = await response.json().catch(() => null);
} catch { return UNKNOWN; }
if (response.type === 'opaqueredirect') return { ok: false, error: 'signed-out', message: 'The Console asked to sign in again. Nothing was written.' };
if (response.ok && answer?.ok) return answer;
// A refusal names its code. An answer that cannot be read, or one that says the outcome is open,
// is not a refusal: the change may have reached the bus.
if (typeof answer?.error !== 'string' || answer.sent === 'unknown') return { ...UNKNOWN, message: answer?.sent === 'unknown' && answer.message ? answer.message : UNKNOWN.message };
return { ok: false, error: answer.error, message: answer.message || `The Console refused the change (${response.status}). Nothing was written.` };
}
const find = k => snap?.tasks.find(t => key(t) === k) || null;
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();
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}` };
try { await reload(); } catch {}
paint();
}
// ---------- dialog (create and edit) ----------
function dialogEl() {
let d = document.getElementById('tb-dialog');
if (!d) {
d = document.createElement('dialog');
d.id = 'tb-dialog'; d.className = 'tb-dialog'; d.setAttribute('aria-labelledby', 'tb-dialog-h');
document.body.append(d);
d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
}
return d;
}
const dateValue = iso => (typeof iso === 'string' && /^\d{4}-\d\d-\d\d/.test(iso) ? iso.slice(0, 10) : '');
const roleBoxes = (roles, picked) => (roles.length
? `<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>`;
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('')}`;
}
function createForm(orgId) {
const orgs = snap.orgs.filter(orgWritable), org = orgs.find(o => o.id === orgId) || orgs[0];
return `<form method="dialog" class="tb-form" data-tb-form="create">
<h2 id="tb-dialog-h">New task</h2>
<label>Organization<select name="org" data-tb-create-org>${orgs.map(o => `<option value="${esc(o.id)}"${o.id === org.id ? ' selected' : ''}>${esc(o.label)}</option>`).join('')}</select></label>
<label>Project<select name="group">${projectOptions(org.id, filter.project.startsWith(`${org.id}:`) ? filter.project.slice(org.id.length + 1) : '')}</select></label>
<label>Title<input name="title" required maxlength="250" autocomplete="off"></label>
<label>Requirement<input name="requirement" required pattern="${REQUIREMENT}" placeholder="REQ-WEB-1" autocomplete="off" aria-describedby="tb-req-note"></label>
<p class="tb-field-note" id="tb-req-note">The requirement this task serves, written as REQ-AREA-N.</p>
<div class="tb-row2">${stateSelect('todo')}${prioSelect(0)}<label>Due date<input type="date" name="due_date"></label></div>
<div data-tb-roles>${roleBoxes(org.roles, [])}</div>
<p class="tb-form-status" role="alert" data-tb-form-status></p>
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Create task</button></div>
</form>`;
}
function editForm(t) {
const o = orgOf(t.org), w = taskWritable(t);
const why = t.tracker === 'vikunja' ? 'This task is in Vikunja. The board reads it but does not change it; change it in Vikunja.'
: !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.' : !w ? 'The bus did not give this task a version to check a change against.' : '';
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>
<p class="tb-field-note">${esc(why)}</p>
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
}
return `<form method="dialog" class="tb-form" data-tb-form="edit" data-tb-key="${esc(key(t))}">
<h2 id="tb-dialog-h">Edit ${esc(shortId(t.ref))}</h2>${facts}
<label>Title<input name="title" required maxlength="250" value="${esc(t.title ?? '')}" autocomplete="off"></label>
<div class="tb-row2">${stateSelect(t.state)}${prioSelect(t.priority)}<label>Due date<input type="date" name="due_date" value="${dateValue(t.dueDate)}"></label></div>
${roleBoxes([...new Set([...o.roles, ...t.assignees])].sort(), t.assignees)}
<p class="tb-form-status" role="alert" data-tb-form-status></p>
<div class="tb-actions"><button type="button" class="ws-btn danger" data-tb-archive>Archive</button><span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Save</button></div>
</form>`;
}
function openDialog(html, back, task = null) {
const d = dialogEl();
d.innerHTML = html; d.dataset.return = back || ''; opened = task ? structuredClone(task) : null;
if (!d.open) d.showModal();
d.querySelector('input:not([type=checkbox]),select,button')?.focus();
}
const formStatus = (form, text) => { const s = form.querySelector('[data-tb-form-status]'); if (s) s.textContent = text; };
const due0 = v => (v ? `${v}T00:00:00.000Z` : null);
const picked = form => [...form.querySelectorAll('input[name=assignees]:checked')].map(i => i.value).sort();
function editBody(form, t) {
const f = new FormData(form), body = { op: 'edit', org: t.org, task_ref: t.ref, expect: t.digest };
const titleV = String(f.get('title') ?? ''), state = String(f.get('state')), priority = Number(f.get('priority')), dueV = due0(String(f.get('due_date') || '')), who = picked(form);
if (titleV !== (t.title ?? '')) body.title = titleV;
if (state !== t.state) body.state = state;
if (priority !== (t.priority ?? 0)) body.priority = priority;
if (dueV !== (t.dueDate ? `${dateValue(t.dueDate)}T00:00:00.000Z` : null)) body.due_date = dueV;
if (who.join('\n') !== [...t.assignees].sort().join('\n')) body.assignees = who;
return body;
}
// The competing write may have moved the task out of the board's filters, so the conflict read
// asks for its one organization unfiltered. The task was just changed, so the cap keeps it.
async function current(t) {
try {
const r = await fetch(`/api/workspace?${new URLSearchParams({ sections: 'tasks', org: t.org })}`, { cache: 'no-store', redirect: 'manual' });
if (r.type === 'opaqueredirect' || !r.ok) return null;
const b = await r.json();
return (Array.isArray(b?.tasks) && b.tasks.find(x => x.org === t.org && x.ref === t.ref)) || null;
} catch { return null; }
}
async function submit(form) {
const kind = form.dataset.tbForm, btns = form.querySelectorAll('button');
if (!form.reportValidity()) return;
let body, t = null;
if (kind === 'create') {
const f = new FormData(form);
body = { op: 'create', org: String(f.get('org')), title: String(f.get('title')), requirement: String(f.get('requirement')), state: String(f.get('state')), priority: Number(f.get('priority')), assignees: picked(form) };
const group = String(f.get('group') || ''); if (group) body.group = group;
const d = due0(String(f.get('due_date') || '')); if (d) body.due_date = d;
} else {
t = opened && key(opened) === form.dataset.tbKey ? opened : null;
if (!t) { formStatus(form, 'The task is no longer on the board. Nothing was written.'); return; }
body = editBody(form, t);
if (Object.keys(body).length === 4) { formStatus(form, 'Nothing changed.'); return; }
}
btns.forEach(b => { b.disabled = true; }); formStatus(form, 'Sending…');
const r = await write(body);
btns.forEach(b => { b.disabled = false; });
if (!r.ok) {
formStatus(form, r.message);
// The task changed under the form: the form shows the current task and keeps the refusal.
if (r.error === 'task-conflict' && t) {
try { await reload(); } catch {}
const now = find(key(t)) || await current(t);
if (now) { const msg = r.message; openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, msg); }
paint();
} else if (r.unknown) { try { await reload(); } catch {} paint(); }
return;
}
const label = orgOf(body.org).label;
notice = { text: kind === 'create' ? `${shortId(r.task_ref || '')} created in ${label}.` : `${shortId(t.ref)} saved in ${label}.` };
dialogEl().close();
try { await reload(); } catch {}
paint();
}
async function archive(form) {
const t = opened && key(opened) === form.dataset.tbKey ? opened : null;
if (!t) return;
if (!form.querySelector('[data-tb-archive]').hasAttribute('data-armed')) {
form.querySelector('[data-tb-archive]').setAttribute('data-armed', '');
form.querySelector('[data-tb-archive]').textContent = 'Archive: press again to confirm';
return;
}
const r = await write({ op: 'archive', org: t.org, task_ref: t.ref, expect: t.digest });
if (!r.ok) { formStatus(form, r.message); if (r.unknown) { try { await reload(); } catch {} paint(); } return; }
notice = { text: `${shortId(t.ref)} archived in ${orgOf(t.org).label}.` };
dialogEl().close();
try { await reload(); } catch {}
paint();
}
// ---------- events ----------
const inBoard = e => e.target.closest?.('[data-tasks-board]');
document.addEventListener('click', e => {
const d = e.target.closest?.('#tb-dialog');
if (d) {
if (e.target.closest('[data-tb-cancel]')) { e.preventDefault(); d.close(); }
else if (e.target.closest('[data-tb-archive]')) { e.preventDefault(); archive(e.target.closest('form')); }
return;
}
if (!inBoard(e) || !snap) return;
const lay = e.target.closest('[data-tb-layout]');
if (lay) { layouts[phone() ? 'phone' : 'wide'] = lay.dataset.tbLayout; save(LAYOUT, JSON.stringify(layouts)); paint(); return; }
const chip = e.target.closest('[data-tb-org]');
if (chip) {
filter.org = chip.dataset.tbOrg;
if (filter.project && filter.project !== '-' && filter.org && !filter.project.startsWith(`${filter.org}:`)) { filter.project = ''; refilter(); } else paint();
return;
}
const open = e.target.closest('[data-tb-open]');
if (open) { const t = find(open.dataset.tbOpen); if (t) openDialog(editForm(t), open.dataset.focusKey, t); return; }
const add = e.target.closest('[data-tb-new]');
if (add && !add.disabled) openDialog(createForm(filter.org), 'tb-new');
});
document.addEventListener('change', e => {
if (e.target.matches?.('[data-tb-create-org]')) {
const form = e.target.closest('form'), org = orgOf(e.target.value);
form.querySelector('select[name=group]').innerHTML = projectOptions(org.id, '');
form.querySelector('[data-tb-roles]').innerHTML = roleBoxes(org.roles, []);
return;
}
if (!inBoard(e) || !snap) return;
const f = e.target.dataset?.tbFilter;
if (f) { filter[f] = e.target.value; refilter(); return; }
if (e.target.matches('[data-tb-move]')) move(e.target.dataset.tbMove, e.target.value);
});
document.addEventListener('input', e => {
if (!e.target.matches?.('[data-tb-q]') || !inBoard(e) || !snap) return;
filter.q = e.target.value; paint('results');
});
document.addEventListener('submit', e => {
const form = e.target.closest?.('#tb-dialog form');
if (!form) return;
e.preventDefault(); submit(form);
});
// Drag and drop between columns: an enhancement over the list's State control and the edit form.
document.addEventListener('dragstart', e => {
const c = e.target.closest?.('[data-tasks-board] .tb-card[draggable=true]');
if (!c) return;
dragging = c.dataset.tbTask; dragEnded = new Promise(r => { c.addEventListener('dragend', r, { once: true }); });
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragging);
c.classList.add('is-dragging');
});
document.addEventListener('dragend', () => { dragging = null; document.querySelectorAll('.tb-col.is-over').forEach(c => c.classList.remove('is-over')); });
document.addEventListener('dragover', e => {
const col = dragging && e.target.closest?.('[data-tasks-board] [data-tb-col]');
if (!col) return;
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
document.querySelectorAll('.tb-col.is-over').forEach(c => { if (c !== col) c.classList.remove('is-over'); });
col.classList.add('is-over');
});
document.addEventListener('drop', e => {
const col = e.target.closest?.('[data-tasks-board] [data-tb-col]');
if (!col || !dragging) return;
e.preventDefault();
const k = dragging; dragging = null;
move(k, col.dataset.tbCol);
});
// The layout default follows the width when the viewer has not picked one for it.
window.matchMedia('(max-width:759px)').addEventListener('change', () => paint());
window.MosaicTasksBoard = Object.freeze({ view });
})();
+12 -2
View File
@@ -4,7 +4,8 @@
(() => {
const read = async path => {
const response = await fetch(path, { cache: 'no-store', redirect: 'manual' });
if (!response.ok || response.type === 'opaqueredirect') throw new Error('Read unavailable');
// The status travels with the failure, so a caller can tell a refusal (403) from a bus that is down.
if (!response.ok || response.type === 'opaqueredirect') { const error = new Error('Read unavailable'); error.status = response.type === 'opaqueredirect' ? 0 : response.status; throw error; }
return response.json();
};
const orgPath = (id, path) => `/api/orgs/${encodeURIComponent(id)}/${path}`;
@@ -20,7 +21,16 @@
const business = id => read(orgPath(id, 'business'));
const settings = id => read(orgPath(id, 'settings'));
const projectFiles = (id, project) => read(orgPath(id, `project-files?project=${encodeURIComponent(project)}`));
// U1's cross-organization read (GET /api/workspace). Only the keys it accepts are sent, each once:
// an unknown or repeated key is a 400 there.
const WORKSPACE_KEYS = ['sections', 'org', 'status', 'state', 'name', 'sort', 'order', 'project'];
const workspace = (params = {}) => {
const q = new URLSearchParams();
for (const key of WORKSPACE_KEYS) { const v = params[key]; if (v !== undefined && v !== null && v !== '') q.set(key, Array.isArray(v) ? v.join(',') : String(v)); }
const qs = q.toString();
return read(`/api/workspace${qs ? `?${qs}` : ''}`);
};
// The three agent levels (root, organization, project) for every organization on the list, owner only.
const agentLevels = () => read('/api/agents/levels');
window.MosaicWorkspaceData = Object.freeze({ organizations, themes, projects, threads, instances, agents, claims, queue, business, settings, projectFiles, agentLevels });
window.MosaicWorkspaceData = Object.freeze({ organizations, themes, projects, threads, instances, agents, claims, queue, business, settings, projectFiles, workspace, agentLevels });
})();
+350 -2
View File
@@ -128,7 +128,7 @@
if (toggle) { expanded = !expanded; save(ORGS_OPEN, expanded ? 'open' : 'closed'); paint(); document.querySelector('[data-org-toggle]')?.focus(); return; }
if (e.target.closest?.('[data-projects-toggle]')) { projectsOpen = !projectsOpen; save(PROJECTS_OPEN, projectsOpen ? 'open' : 'closed'); paint(); document.querySelector('[data-projects-toggle]')?.focus(); return; }
const group = e.target.closest?.('#workspace-nav [data-group]');
if (group) { grouping = group.dataset.group === 'flat' ? 'flat' : 'org'; save(GROUPING, grouping); paint(); document.querySelector(`#workspace-nav [data-group="${grouping}"]`)?.focus(); }
if (group) { grouping = group.dataset.group === 'flat' ? 'flat' : 'org'; save(GROUPING, grouping); paint(); document.querySelector(`#workspace-nav [data-group="${grouping}"]`)?.focus(); if (route().split('?')[0] === '/projects') document.dispatchEvent(new CustomEvent('mosaic-workspace-render')); }
});
window.addEventListener('hashchange', () => { if (orgs) paint(); });
document.addEventListener('mosaic-org-list', e => refresh(e.detail?.rows));
@@ -389,6 +389,354 @@
box.innerHTML = agentsAvailable(select.value);
});
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, refresh: () => refresh() });
// ---------- across organizations (U3: samples 02, 04 and 05) ----------
// One read, GET /api/workspace (U1), backs the three pages. Each organization carries a state and
// the time it was read: one that is down, starting or slow is named with that state, and the rest
// still render. Filters live in the page's hash query, so a poll repaints the same view and a
// link reproduces it. Numbers come only from the read; a field no read gives is one honest line.
let pollMs = 10000;
const took = body => { const n = Number(body?.pollMs); if (Number.isFinite(n) && n >= 1000) pollMs = n; return body; };
const selHref = path => `#/orgs/${enc(window.MosaicOrg?.id || '')}${path}`;
const ORG_STATE = { ok: ['s-working', '●', 'running'], starting: ['s-waiting', '◐', 'starting'], down: ['s-offline', '○', 'down'], timeout: ['s-error', '▲', 'not answering'], invalid: ['s-error', '▲', 'invalid'] };
const orgState = o => ORG_STATE[o?.state] || ['s-unknown', '?', 'not reported'];
const msOf = iso => { const t = Date.parse(iso || ''); return Number.isFinite(t) ? t : null; };
const hm = iso => { const t = msOf(iso); return t === null ? '' : new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); };
// Today's times as HH:MM, older ones as a short date.
const when = iso => { const t = msOf(iso); if (t === null) return ''; const d = new Date(t); return d.toDateString() === new Date().toDateString() ? hm(iso) : d.toLocaleDateString([], { month: 'short', day: 'numeric' }); };
const span = iso => { const t = msOf(iso); if (t === null) return ''; const m = Math.max(0, Math.floor((Date.now() - t) / 60000)); return m < 1 ? 'under a minute' : m < 60 ? `${m} min` : m < 2880 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} days`; };
const ago = iso => { const s = span(iso); return !s ? '' : s === 'under a minute' ? 'just now' : `${s} ago`; };
const num = v => (Number.isFinite(v) ? v : 0);
const arr = v => (Array.isArray(v) ? v : []);
// The organizations the read names, with their colours, marks and states.
function orgsOf(body) {
const rows = arr(body?.orgs).filter(o => o && typeof o.org === 'string');
// The read names an organization by id; the sidebar's list carries its display label.
const known = id => (Array.isArray(orgs) ? orgs.find(x => x?.id === id) : null);
const byId = new Map(rows.map(o => [o.org, { id: o.org, label: known(o.org)?.label || o.label || o.org, theme: o.theme ?? known(o.org)?.theme ?? null }]));
const meta = id => byId.get(id) || { id, label: id };
return {
rows, meta, name: id => label(meta(id)), style: id => colours(meta(id)),
ref: (id, cls = '') => `<span class="ws-orgref${cls}">${av(meta(id))}${esc(label(meta(id)))}</span>`,
unread: rows.filter(o => o.state !== 'ok'),
};
}
const stateWords = o => `${esc((Array.isArray(orgs) ? orgs.find(x => x?.id === o.org)?.label : null) || o.label || o.org)} ${orgState(o)[2]}${o.at ? `, read ${hm(o.at)}` : ''}`;
const andList = items => (items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`);
// An organization that answered can still fail one read (errors in GET /api/workspace). The page
// names that organization and the reason rather than count the read as zero.
const READ_WHY = { 'channel-disabled': 'the chat channel is off', 'outcome-unknown': 'no answer', timeout: 'no answer in time' };
const missOf = (r, names) => { const n = r?.state === 'ok' && r.errors ? names.find(x => r.errors[x]) : null; return n ? READ_WHY[r.errors[n]] || String(r.errors[n]) : null; };
const missed = (o, names) => o.rows.filter(r => missOf(r, names)).map(r => `${esc(o.name(r.org))} (${esc(missOf(r, names))})`);
const missLine = (o, names, key, lead = 'Not read in') => { const m = missed(o, names); return m.length ? `<p class="note ws-miss" data-ws-miss="${key}">${lead} ${andList(m)}.</p>` : ''; };
// The organizations that answered and made every read a figure is built from. Only these count
// toward it: a failed read reaches the merge as an empty list, so its zero is not a figure.
const readers = (o, names) => new Set(o.rows.filter(r => r.state === 'ok' && !missOf(r, names)).map(r => r.org));
// Some organizations exist, and none of them could make these reads.
const noneRead = (o, names) => o.rows.length > 0 && !readers(o, names).size;
function failedPage(cls, attr, title, err) {
const why = err?.status === 403 ? 'This Console refused the read across organizations.' : err?.status === 0 ? 'You are signed out. Reload the page to sign in again.' : err?.status === 400 ? 'The read refused these filters. Clear them and try again.' : 'The read across organizations did not answer. The page tries again on its next refresh.';
return `<section class="ws ${cls}" ${attr} data-ws-failed><div class="ws-vh"><h1 tabindex="-1">${title}</h1><p class="sub">${why}</p></div></section>`;
}
// The Inbox count in the navigation follows the cross-organization read wherever it was made.
const tellInbox = rows => { if (Array.isArray(rows)) document.dispatchEvent(new CustomEvent('mosaic-workspace-inbox', { detail: { open: rows.length, blocking: rows.filter(d => d?.blocking).length } })); };
// A stacked bar and its per-organization figures, one segment per organization.
const bar = (o, parts, style = '') => `<div class="ws-bar"${style ? ` style="${style}"` : ''} aria-hidden="true">${parts.filter(p => p.n > 0).map(p => `<i class="ws-o" style="${o.style(p.id)};flex:${p.n}"></i>`).join('')}</div>`;
const by = (o, parts, fmt = n => n) => `<div class="ws-by">${parts.map(p => `<span>${av(o.meta(p.id))}<span class="ws-sr">${esc(o.name(p.id))}</span>${esc(fmt(p.n))}</span>`).join('')}</div>`;
const CLASS_CHIP = { gated: 'gated', 'within-role': 'within', routine: 'routine', 'cross-role': 'cross' };
const classChip = c => (c ? `<span class="ws-chip ${CLASS_CHIP[c] || ''}">${esc(c)}</span>` : '');
const itemKey = d => `${d.org}:${d.id}`;
const roleOf = d => d.session?.role || d.raisedBy || '';
const fromWords = (o, d, withOrg = true) => [withOrg ? o.name(d.org) : '', d.project?.name || 'No project', roleOf(d)].filter(Boolean).map(esc).join(' · ');
// ---- main dashboard (sample 02) ----
async function home() {
let body;
try { [body] = await Promise.all([data.workspace({ sections: ['projects', 'sessions', 'inbox', 'tasks', 'metrics'] }), ensureThemes()]); took(body); }
catch (err) { return failedPage('ws-home', 'data-ws-home', 'Dashboard', err); }
const o = orgsOf(body), m = body.metrics, t = m?.totals, per = arr(m?.orgs);
const inbox = arr(body.inbox), sessions = arr(body.sessions), projectRows = arr(body.projects), tasks = arr(body.tasks);
if (!noneRead(o, ['inbox'])) tellInbox(body.inbox);
const sessN = s => num(s?.working) + num(s?.waiting) + num(s?.idle) + num(s?.live);
// The reads each tile's figures come from (workspace-read.mjs orgRows).
const SRC = { needs: ['inbox'], sessions: ['agents', 'claims'], tasks: ['tasks'], projects: ['projects'] };
// Per-organization figures name only the organizations that made the tile's reads.
const parts = (key, f) => { const ok = readers(o, SRC[key]); return per.filter(x => ok.has(x.org)).map(x => ({ id: x.org, n: num(f(x)) })); };
const answered = o.rows.filter(r => r.state === 'ok').length;
const tile = (key, name, big, small, ps, delta) => {
const m = missed(o, SRC[key]);
if (!readers(o, SRC[key]).size) return `<div class="ws-tile quiet" data-ws-tile="${key}" data-ws-miss="${key}"><span class="ws-label">${name}</span><span class="ws-empty-t">Not read</span><span class="ws-delta">${andList(m)}</span></div>`;
return `<div class="ws-tile" data-ws-tile="${key}"><span class="ws-label">${name}</span><span class="ws-big">${big}${small ? `<small>${small}</small>` : ''}</span>${ps ? bar(o, ps) + by(o, ps) : ''}<span class="ws-delta">${delta}</span>${m.length ? `<span class="ws-delta ws-miss" data-ws-miss="${key}">Not read in ${andList(m)}</span>` : ''}</div>`;
};
const tiles = t && answered ? `<div class="ws-g5 keep2" data-ws-tiles>
${tile('needs', 'Needs you', num(t.needsYou), t.needsYou === 1 ? 'item' : 'items', parts('needs', x => x.needsYou), t.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting')}
${tile('sessions', 'Sessions', sessN(t.sessions), `${num(t.sessions?.working)} working`, parts('sessions', x => sessN(x.sessions)), `${num(t.sessions?.waiting)} waiting on you · ${num(t.sessions?.idle)} idle${num(t.sessions?.live) ? ` · ${num(t.sessions.live)} live ${t.sessions.live === 1 ? 'thread' : 'threads'}` : ''}`)}
${tile('tasks', 'Open tasks', num(t.openTasks), '', parts('tasks', x => x.openTasks), `<b>${num(t.doneThisWeek)}</b> done this week`)}
${tile('projects', 'Projects', num(t.projects?.active) + num(t.projects?.archived), `${num(t.projects?.active)} active`, parts('projects', x => num(x.projects?.active) + num(x.projects?.archived)), `${num(t.projects?.archived)} archived`)}
<div class="ws-tile quiet" data-ws-tile="spend"><span class="ws-label">Model spend</span><span class="ws-empty-t">No spend figures yet</span><span class="ws-delta">No read prices model use today.</span></div>
</div>` : '<p class="ws-panel tight note" data-ws-tiles>No organization answered, so there are no totals yet.</p>';
const cards = o.rows.map(r => {
const [cls, glyph, word] = orgState(r), id = r.org;
const extra = r.state === 'ok' ? '' : `<span class="why">${esc(r.reason || '')}${r.boot?.at ? `${r.reason ? ' · ' : ''}last start ${esc(when(r.boot.at))}` : ''}</span>`;
return `<li class="ws-orgcard ws-o${r.state === 'ok' ? '' : ' off'}" style="${o.style(id)}" data-ws-orgcard="${esc(id)}" data-state="${esc(r.state)}"><a href="${esc(orgHref(id))}/organization" data-focus-key="${esc(`orgcard:${id}`)}">${av(o.meta(id), 'md')}<span class="nm">${esc(o.name(id))}</span>${dot(cls, glyph, word, true)}<span class="at">read ${esc(hm(r.at))}</span>${extra}</a></li>`;
}).join('');
const recent = projectRows.filter(p => p.lastActivity).slice(0, 6);
const STATE_WORD = { working: 'has a session working', waiting: 'is waiting on you', idle: 'has an idle session', none: 'no live session' };
const trail = recent.length ? `<ol class="ws-trail" data-ws-recent>${recent.map(p => `<li class="ws-o" style="${o.style(p.org)}"><time datetime="${esc(p.lastActivity)}">${esc(when(p.lastActivity))}</time>${av(o.meta(p.org))}<span class="what"><a href="${esc(orgHref(p.org))}/projects/${enc(p.id)}" data-focus-key="${esc(`recent:${p.org}:${p.id}`)}"><b>${esc(p.name || p.key || p.id)}</b></a> ${STATE_WORD[p.state] || ''}${p.needsYou ? `, ${plural(p.needsYou, 'item')} for you` : ''} <span class="src">· ${esc(o.name(p.org))}</span></span></li>`).join('')}</ol>` : `<p class="ws-panel tight note" data-ws-recent>${noneRead(o, ['projects']) ? 'Projects not read' : 'No project activity yet'}</p>`;
const open = tasks.filter(x => !x.done);
const buckets = new Map();
for (const x of open) { const b = typeof x.bucket === 'string' && x.bucket ? x.bucket : ''; if (!buckets.has(b)) buckets.set(b, new Map()); const g = buckets.get(b); g.set(x.org, (g.get(x.org) || 0) + 1); }
const srows = [...buckets.entries()].map(([b, g]) => ({ name: b ? titleCase(b) : 'No bucket', g, n: [...g.values()].reduce((a, c) => a + c, 0) })).sort((a, b) => b.n - a.n);
const taskOrgs = readers(o, ['tasks']);
if (t && taskOrgs.size) srows.push({ name: 'Done this week', g: new Map(per.filter(x => taskOrgs.has(x.org)).map(x => [x.org, num(x.doneThisWeek)])), n: num(t.doneThisWeek) });
const max = Math.max(1, ...srows.map(s => s.n));
const states = srows.length ? `<div class="ws-panel tight ws-states" data-ws-states>${srows.map(s => { const ps = [...s.g.entries()].filter(([, n]) => n > 0).map(([id, n]) => ({ id, n })); return `<div class="ws-srow"><span class="sl">${esc(s.name)}</span><span class="sn">${s.n}</span>${bar(o, ps, `width:${Math.max(4, Math.round((s.n / max) * 90))}%`)}${by(o, ps)}</div>`; }).join('')}</div>` : `<p class="ws-panel tight note" data-ws-states>${noneRead(o, ['tasks']) ? 'Tasks not read' : 'No open tasks'}</p>`;
const needs = inbox.slice(0, 5);
const needsList = needs.length ? `<ul class="ws-needs" data-ws-needs>${needs.map(d => `<li class="ws-edge ws-o" style="${o.style(d.org)}">${av(o.meta(d.org), 'md')}<a class="q" href="${esc(selHref(`/inbox?d=${enc(itemKey(d))}`))}" data-focus-key="${esc(`need:${itemKey(d)}`)}">${esc(d.question || `Decision ${d.id}`)}</a><span class="src">${fromWords(o, d)}</span>${classChip(d.class)}</li>`).join('')}</ul>${inbox.length > needs.length ? `<p class="note ws-more-line">${inbox.length - needs.length} more in the inbox</p>` : ''}` : `<p class="ws-panel tight note" data-ws-needs>${noneRead(o, ['inbox']) ? 'Decisions not read' : 'Nothing needs you'}</p>`;
const now = sessions.filter(s => s.kind === 'thread' || s.state === 'working' || s.state === 'waiting');
const idle = sessions.length - now.length;
const who = s => (s.kind === 'thread' ? s.instance || s.thread || 'thread' : s.role || 'agent');
const what = s => s.project?.name || s.task?.title || '';
const st = s => { const [cls, glyph, word] = s.kind === 'thread' ? ['s-working', '●', 'live'] : ACTIVITY[s.state] || ['s-unknown', '?', s.state || 'not reported']; return `${dot(cls, glyph, word === 'waiting on you' ? 'waiting' : word, true)}${s.state === 'waiting' ? ' <span class="muted">on you</span>' : s.since ? ` <span class="muted">${esc(span(s.since))}</span>` : ''}`; };
const sessTable = now.length ? `<div class="ws-tbl"><table class="ws-sess" data-ws-sessions><thead><tr><th scope="col">Agent and project</th><th scope="col">Status</th></tr></thead><tbody>${now.slice(0, 8).map(s => `<tr><td class="who"><span class="ws-orgref">${av(o.meta(s.org), 'xs')}<span class="ws-sr">${esc(o.name(s.org))}: </span>${esc(who(s))}</span>${what(s) ? `<span class="muted"> · ${esc(what(s))}</span>` : ''}</td><td class="st">${st(s)}</td></tr>`).join('')}</tbody></table></div>${now.length > 8 ? `<p class="note ws-more-line">${now.length - 8} more on Agents</p>` : ''}` : `<p class="ws-panel tight note" data-ws-sessions>${noneRead(o, ['agents', 'claims']) ? 'Sessions not read' : `No session working right now${idle ? `; ${plural(idle, 'idle session')}` : ''}`}</p>`;
const total = t ? num(t.projects?.active) + num(t.projects?.archived) : null;
const allRead = !o.unread.length;
const sub = !o.rows.length ? 'You belong to no organization yet.'
: allRead ? `All ${plural(o.rows.length, 'organization')}${missed(o, ['projects']).length ? '' : ` and ${plural(total ?? 0, 'project')}`}. Totals add up every organization you belong to; each tile breaks them down by organization.`
: `${body.summary?.read ?? o.rows.length - o.unread.length} of ${plural(o.rows.length, 'organization')} answered. Totals add up the ones that answered; ${andList(o.unread.map(stateWords))}.`;
return `<article class="ws ws-home" data-ws-home>
<div class="ws-vh"><h1 tabindex="-1">Dashboard</h1><p class="sub">${sub}</p></div>
${tiles}
${secH('Organizations', `read ${esc(hm(body.readAt))}`)}
<ul class="ws-orgcards" data-ws-orgs>${cards || '<li class="ws-line">No organizations yet</li>'}</ul>
<div class="ws-split ws-home-split">
<section aria-label="Projects and tasks">
${secH('Recent project activity', 'latest change per project; there is no activity feed yet', `<a href="${esc(selHref('/projects'))}" data-focus-key="home-projects">All projects</a>`)}
${trail}${missLine(o, ['projects'], 'recent')}
${secH('Tasks by state', 'open tasks, every tracker', `<a href="${esc(selHref('/tasks'))}" data-focus-key="home-tasks">Open tasks</a>`)}
${states}${missLine(o, ['tasks'], 'states')}
</section>
<section aria-label="Needs you and sessions">
${secH('Needs you', '', `<a href="${esc(selHref('/inbox'))}" data-focus-key="home-inbox">Inbox</a>`)}
${needsList}${missLine(o, ['inbox'], 'needs')}
${secH('Sessions now', '', `<a href="${esc(selHref('/agents'))}" data-focus-key="home-agents">Agents</a>`)}
${sessTable}${missLine(o, ['agents', 'claims'], 'sessions')}
</section>
</div>
</article>`;
}
// ---- projects across organizations (sample 04, REQ-WS-5) ----
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const listParam = (q, key, ok) => { const v = [...new Set(String(q?.get?.(key) || '').split(',').map(s => s.trim()).filter(ok))]; return v.length ? v : null; };
// The hash query, cleaned to what the API accepts, so a hand-edited link never reads as "all".
function projectQuery(q) {
const sort = ['activity', 'name', 'org', 'status'].includes(q?.get?.('sort')) ? q.get('sort') : 'activity';
const order = ['asc', 'desc'].includes(q?.get?.('order')) ? q.get('order') : sort === 'activity' ? 'desc' : 'asc';
const name = String(q?.get?.('name') || '').trim().slice(0, 200);
return {
org: listParam(q, 'org', id => ORG_ID.test(id)), status: listParam(q, 'status', s => ['active', 'archived'].includes(s)) || ['active'],
state: listParam(q, 'state', s => ['working', 'waiting', 'idle', 'none'].includes(s)), name: name || null, sort, order,
};
}
const SORT_WORD = { activity: 'latest activity', name: 'name', org: 'organization', status: 'status' };
const STATUS_OPTS = [['active', 'Active'], ['archived', 'Archived'], ['active,archived', 'Active and archived']];
const STATE_OPTS = [['', 'Any session state'], ['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['none', 'No session']];
function orgChips(o, counts, chosen, total, src = []) {
const all = !chosen;
return `<div class="ws-fchips" role="group" aria-label="Organization"><button type="button" class="f plain" data-ws-org="" data-focus-key="chip:all" aria-pressed="${all}">All <span class="n">${total}</span></button>${o.rows.map(r => `<button type="button" class="f ws-o" style="${o.style(r.org)}" data-ws-org="${esc(r.org)}" data-focus-key="${esc(`chip:${r.org}`)}" aria-pressed="${!!chosen?.includes(r.org)}">${av(o.meta(r.org))}${esc(o.name(r.org))}<span class="n">${r.state === 'ok' && !missOf(r, src) ? counts.get(r.org) || 0 : `<span class="ws-sr">, </span>${r.state === 'ok' ? 'not read' : esc(orgState(r)[2])}`}</span></button>`).join('')}</div>`;
}
async function projectsPage(q) {
const f = projectQuery(q);
const params = { sections: ['projects'], org: f.org, status: f.status, state: f.state, name: f.name, sort: f.sort, order: f.order };
let body, all;
try {
[body, all] = await Promise.all([data.workspace(params), f.org ? data.workspace({ ...params, org: null }) : null, ensureThemes()]);
took(body);
} catch (err) { return failedPage('ws-projects', 'data-ws-projects', 'Projects', err); }
const every = orgsOf(all || body), o = orgsOf(body);
const counts = new Map();
for (const p of arr((all || body).projects)) counts.set(p.org, (counts.get(p.org) || 0) + 1);
const rows = arr(body.projects);
const flat = grouping === 'flat';
const cols = flat ? 8 : 7;
const aria = key => (f.sort === key ? ` aria-sort="${f.order === 'desc' ? 'descending' : 'ascending'}" class="sorted` : ' class="');
const ar = key => (f.sort === key ? `<span class="ar" aria-hidden="true">${f.order === 'desc' ? '↓' : '↑'}</span>` : '');
const head = `<tr>${flat ? `<th scope="col"${aria('org')} hide-m">Organization${ar('org')}</th>` : ''}<th scope="col"${aria('name')}">Project${ar('name')}</th><th scope="col"${aria('status')}">Status${ar('status')}</th><th scope="col" class="hide-m">Sessions</th><th scope="col" class="num hide-m">Open tasks</th><th scope="col" class="num">Needs you</th><th scope="col"${aria('activity')} hide-m">Last activity${ar('activity')}</th><th scope="col" class="hide-m">Git</th></tr>`;
const SESS = [['working', 's-working', '●', 'working'], ['waiting', 's-waiting', '◐', 'waiting'], ['idle', 's-idle', '–', 'idle']];
// A row's figures come from more reads than its project list (workspace-read.mjs orgRows). A
// figure whose reads failed in the row's organization says "not read", never 0 or none.
const DEP = { sessions: ['agents', 'claims', 'threads', 'tasks'], tasks: ['tasks'], needs: ['inbox', 'tasks'], activity: ['tasks', 'inbox', 'agents', 'claims', 'threads'] };
const notRead = (p, key) => missOf(o.rows.find(r => r.org === p.org), DEP[key]);
const nr = why => `<span class="muted" title="${esc(why)}">not read</span>`;
const sessCell = p => { const s = p.sessions || {}, have = SESS.filter(([k]) => num(s[k]) > 0); if (!have.length) return '<span class="muted">none</span>'; const [k, cls, glyph, word] = have[0]; return `${dot(cls, glyph, `${num(s[k])} ${word}`, true)}${have.length > 1 ? `<span class="t2">${have.slice(1).map(([k2, , , w2]) => `${num(s[k2])} ${w2}`).join(' · ')}</span>` : ''}`; };
const row = p => {
const href = `${orgHref(p.org)}/projects/${enc(p.id)}`, active = p.status === 'active';
return `<tr class="ws-edge ws-o" style="${o.style(p.org)}" data-ws-project="${esc(`${p.org}:${p.id}`)}">
${flat ? `<td class="hide-m">${o.ref(p.org)}</td>` : ''}
<td><a class="t1 nm" href="${esc(href)}" data-focus-key="${esc(`prow:${p.org}:${p.id}`)}">${av(o.meta(p.org), 'xs mob')}${esc(p.name || p.key || p.id)}<span class="ws-sr"> in ${esc(o.name(p.org))}</span></a>${p.key ? `<span class="t2 mono hide-m">${esc(p.key)}</span>` : ''}<span class="t2 show-m">${p.lastActivity ? esc(ago(p.lastActivity)) : 'No activity yet'}</span></td>
<td>${active ? dot('s-ok', '●', 'active', true) : dot('s-idle', '–', 'archived', true)}</td>
<td class="hide-m">${notRead(p, 'sessions') ? nr(notRead(p, 'sessions')) : sessCell(p)}</td>
<td class="num hide-m">${notRead(p, 'tasks') ? nr(notRead(p, 'tasks')) : num(p.openTasks)}</td>
<td class="num">${notRead(p, 'needs') ? nr(notRead(p, 'needs')) : p.needsYou ? `<span class="ws-n hot">${num(p.needsYou)}</span>` : '<span class="muted">0</span>'}</td>
<td class="hide-m">${p.lastActivity ? `<time datetime="${esc(p.lastActivity)}">${esc(ago(p.lastActivity))}</time>${notRead(p, 'activity') ? '<span class="t2">some reads missing</span>' : ''}` : notRead(p, 'activity') ? nr(notRead(p, 'activity')) : '<span class="muted">none yet</span>'}</td>
<td class="hide-m">${p.git ? `<span class="ws-chip outline">${esc(p.git)}</span>` : '<span class="muted">none</span>'}</td>
</tr>`;
};
const unreadRow = r => `<tr class="ws-unread"><td colspan="${cols}">${dot(...orgState(r), true)} <span class="muted">Not read${r.at ? ` at ${esc(hm(r.at))}` : ''}${r.reason ? `: ${esc(r.reason)}` : ''}</span></td></tr>`;
let tbody;
if (flat) tbody = rows.length ? rows.map(row).join('') : `<tr><td colspan="${cols}" class="muted">${noneRead(o, ['projects']) ? 'Projects not read' : f.org || f.state || f.name || f.status.join() !== 'active' ? 'No projects match these filters' : 'No projects yet'}</td></tr>`;
else {
const groups = (f.sort === 'org' && f.order === 'desc' ? [...o.rows].reverse() : o.rows);
tbody = groups.map(r => {
const mine = rows.filter(p => p.org === r.org);
const h = `<tr class="ws-grp-row ws-o" style="${o.style(r.org)}" data-ws-group-org="${esc(r.org)}"><th colspan="${cols}" scope="colgroup">${av(o.meta(r.org))}<span>${esc(o.name(r.org))}</span>${r.state === 'ok' && !missOf(r, ['projects']) ? `<span class="ws-n">${mine.length}</span>` : ''}</th></tr>`;
const why = missOf(r, ['projects']);
return h + (r.state !== 'ok' ? unreadRow(r) : why ? `<tr class="ws-unread"><td colspan="${cols}">${dot('s-unknown', '?', 'not read', true)} <span class="muted">Projects not read: ${esc(why)}</span></td></tr>` : mine.length ? mine.map(row).join('') : `<tr><td colspan="${cols}" class="muted">No projects match</td></tr>`);
}).join('') || `<tr><td colspan="${cols}" class="muted">No organizations yet</td></tr>`;
}
const unreadNote = flat && o.unread.length ? `<p class="note ws-unread-note" data-ws-unread>Not in this list: ${andList(o.unread.map(stateWords))}.</p>` : '';
// Projects that were read can still lack figures from the other reads; the cells say "not read".
const missNote = (flat ? missLine(o, ['projects'], 'projects') : '') + missLine({ ...o, rows: o.rows.filter(r => !missOf(r, ['projects'])) }, DEP.activity, 'figures', 'Some figures not read in');
const shown = new Set(rows.map(p => p.org)).size;
const sub = `${plural(rows.length, 'project')} in ${plural(shown, 'organization')}, sorted by ${SORT_WORD[f.sort]}${f.sort === 'activity' ? (f.order === 'desc' ? ', newest first' : ', oldest first') : f.order === 'desc' ? ', descending' : ''}. Each row names its organization by mark and colour edge.`;
const statusVal = f.status.join(',');
const stateVal = f.state?.length === 1 ? f.state[0] : '';
return `<section class="ws ws-projects" data-ws-projects>
<div class="ws-vh"><h1 tabindex="-1">Projects</h1><div class="ws-right"><a class="ws-btn" href="${esc(selHref('/chat'))}" data-focus-key="proj-new">${icon('plus')}New project</a></div><p class="sub">${sub}</p></div>
<div class="ws-toolbar">
<label class="ws-search">${icon('search', 'sm')}<span class="ws-sr">Filter projects by name</span><input type="search" data-ws-name data-focus-key="proj-name" placeholder="Filter by name" maxlength="200" autocomplete="off" value="${esc(String(q?.get?.('name') || '').slice(0, 200))}"></label>
${orgChips(every, counts, f.org, arr((all || body).projects).length, ['projects'])}
<label class="ws-sel"><span class="ws-label">Status</span><select data-ws-status data-focus-key="proj-status">${STATUS_OPTS.map(([v, l]) => `<option value="${v}"${v === statusVal ? ' selected' : ''}>${l}</option>`).join('')}</select></label>
<label class="ws-sel"><span class="ws-label">State</span><select data-ws-state data-focus-key="proj-state">${STATE_OPTS.map(([v, l]) => `<option value="${v}"${v === stateVal ? ' selected' : ''}>${l}</option>`).join('')}</select></label>
<div class="ws-right"><span class="ws-label hide-m" id="ws-sort-l">Sort</span><span class="ws-seg" role="group" aria-labelledby="ws-sort-l">${['activity', 'name', 'org', 'status'].map(s => `<button type="button" data-ws-sort="${s}" data-focus-key="sort:${s}" aria-pressed="${f.sort === s}"${f.sort === s ? ` title="Reverse the order"` : ''}>${titleCase(s === 'org' ? 'org' : s)}${f.sort === s ? `<span class="ws-sr">, ${f.order === 'desc' ? 'descending' : 'ascending'}</span>` : ''}</button>`).join('')}</span>
<span class="ws-seg" role="group" aria-label="Project layout"><button type="button" data-ws-group="org" data-focus-key="pgroup-org" aria-pressed="${!flat}">By org</button><button type="button" data-ws-group="flat" data-focus-key="pgroup-flat" aria-pressed="${flat}">Flat</button></span></div>
</div>
${unreadNote}${missNote}
<div class="ws-tbl"><table class="ws-ptable" data-ws-table data-grouping="${flat ? 'flat' : 'org'}"><caption class="ws-sr">Projects across organizations</caption><thead>${head}</thead><tbody>${tbody}</tbody></table></div>
</section>`;
}
// ---- inbox across organizations (sample 05) ----
async function inboxPage(q) {
const org = listParam(q, 'org', id => ORG_ID.test(id));
const want = String(q?.get?.('d') || '');
let body, all;
try {
[body, all] = await Promise.all([data.workspace({ sections: ['inbox'], org }), org ? data.workspace({ sections: ['inbox'] }) : null, ensureThemes()]);
took(body);
} catch (err) { return failedPage('ws-inbox', 'data-ws-inbox', 'Inbox', err); }
const every = orgsOf(all || body), o = orgsOf(body);
const everyRows = arr((all || body).inbox);
if (!noneRead(every, ['inbox'])) tellInbox(everyRows);
const counts = new Map();
for (const d of everyRows) counts.set(d.org, (counts.get(d.org) || 0) + 1);
const rows = arr(body.inbox);
const picked = want === 'none' ? null : rows.find(d => itemKey(d) === want) || (want ? null : rows[0] || null);
const qs = d => { const p = new URLSearchParams(); if (org) p.set('org', org.join(',')); p.set('d', d ? itemKey(d) : 'none'); return `/inbox?${p}`; };
const row = d => {
const on = picked && itemKey(d) === itemKey(picked);
return `<tr class="ws-row ws-edge ws-o${on ? ' on' : ''}" style="${o.style(d.org)}" data-ws-item="${esc(itemKey(d))}">
<td class="src hide-m">${o.ref(d.org)}<span class="via">${fromWords(o, d, false)}</span></td>
<td><span class="ws-msrc show-m">${av(o.meta(d.org), 'xs')}${fromWords(o, d)}</span><a class="q" href="${esc(selHref(qs(d)))}" data-ws-select="${esc(itemKey(d))}" data-focus-key="${esc(`item:${itemKey(d)}`)}"${on ? ' aria-current="true"' : ''}>${esc(d.question || `Decision ${d.id}`)}</a><span class="via">decision <span class="mono">${esc(d.id)}</span>${d.taskRef ? ` · task ${esc(d.taskRef)}` : ''}</span><span class="show-m">${classChip(d.class)}${d.blocking ? ' <span class="ws-chip block">blocking</span>' : ''}</span></td>
<td class="hide-m">${classChip(d.class)}${d.blocking ? ' <span class="ws-chip block">blocking</span>' : ''}</td>
<td class="num">${d.at ? `<time datetime="${esc(d.at)}">${esc(span(d.at))}</time>` : '<span class="muted">not reported</span>'}</td>
</tr>`;
};
const unread = (o.unread.length ? `<p class="note ws-unread-note" data-ws-unread>Not in this list: ${andList(o.unread.map(stateWords))}.</p>` : '') + missLine(o, ['inbox'], 'inbox');
const table = rows.length ? `<div class="ws-tbl"><table class="ws-ibx" data-ws-items><caption class="ws-sr">Items that need you</caption><thead><tr><th scope="col" class="hide-m">Source</th><th scope="col">Needs</th><th scope="col" class="hide-m">Class</th><th scope="col" class="num">Waiting</th></tr></thead><tbody>${rows.map(row).join('')}</tbody></table></div>` : `<p class="ws-panel tight note" data-ws-items>${noneRead(o, ['inbox']) ? 'Decisions not read' : org ? 'Nothing needs you in these organizations' : 'Nothing needs you'}</p>`;
let insp = '';
if (picked) {
const d = picked, id = esc(d.id);
const decideHref = `${orgHref(d.org)}/inbox/${enc(d.id)}`;
const opts = arr(d.options);
const optList = opts.length ? `<ul class="ws-opts">${opts.map(x => `<li><div class="row"><span class="k">${esc(x.key)}</span><b>${esc(x.text || '')}</b></div><div class="row"><a class="ws-btn" href="${esc(decideHref)}" data-focus-key="${esc(`answer:${itemKey(d)}:${x.key}`)}">${d.console ? `Answer ${esc(x.key)}…` : 'Open the decision'}<span class="ws-sr"> on ${esc(o.name(d.org))}'s bus</span></a></div></li>`).join('')}</ul>` : `<p class="note">No options were given. <a href="${esc(decideHref)}" data-focus-key="${esc(`answer:${itemKey(d)}`)}">Open the decision</a></p>`;
const cmds = arr(d.decide).filter(c => typeof c?.command === 'string' && c.command);
const term = cmds.length ? `<div data-ws-cmds><span class="ws-label">Answer from a terminal</span>${cmds.map(c => `<div class="ws-cmd"><code>${esc(c.command)}</code><button type="button" class="ws-btn" data-copy="${esc(c.command)}" data-focus-key="${esc(`copy:${itemKey(d)}:${c.key}`)}" aria-label="Copy the command for option ${esc(c.key)}" title="Copy">${icon('copy')}</button></div>`).join('')}<p class="note">The inbox mixes organizations, so the copied command always names the organization's bus.</p><p class="small muted" id="copy-status" role="status"></p></div>` : '<p class="note" data-ws-cmds>No terminal command for this item: its id or options cannot be passed to a shell safely.</p>';
const by = roleOf(d);
insp = `<aside class="ws-insp ws-o" style="${o.style(d.org)}" aria-label="Decision ${id}" data-ws-insp="${esc(itemKey(d))}">
<header>${av(o.meta(d.org))}<span class="ws-label">Decision <span class="mono">${id}</span></span>${classChip(d.class)}<a class="x" href="${esc(selHref(qs(null)))}" data-ws-select="none" data-focus-key="insp-close" aria-label="Close the decision">${icon('close')}</a></header>
<div class="body">
<h3>${esc(d.question || `Decision ${d.id}`)}</h3>
<div class="ws-auth">${esc(o.name(d.org))} · ${esc(d.project?.name || 'No project')}${by ? ` · raised by <b>${esc(by)}</b>` : ''}${d.at ? ` at ${esc(hm(d.at))}` : ''}.${d.class === 'gated' ? ' Only you can answer a gated decision.' : ''}</div>
${d.task?.title || d.taskRef ? `<p class="note">Task ${esc(d.taskRef || '')}${d.task?.title ? ` · ${esc(d.task.title)}` : ''}</p>` : ''}
${optList}
${term}
<div><span class="ws-label">History</span><p class="note">Not built yet.</p></div>
</div>
</aside>`;
}
const orgsWith = new Set(rows.map(d => d.org)).size;
const sub = rows.length ? `${plural(rows.length, 'item')} need${rows.length === 1 ? 's' : ''} you, from ${plural(orgsWith, 'organization')}. Each row names where it came from. An item leaves only when it is answered.` : `${noneRead(o, ['inbox']) ? 'No organization\'s inbox could be read.' : missed(o, ['inbox']).length || o.unread.length ? 'Nothing needs you in the organizations that were read.' : 'Nothing needs you right now.'} An item leaves only when it is answered.`;
return `<section class="ws ws-inbox" data-ws-inbox>
<div class="ws-vh"><h1 tabindex="-1">Inbox</h1><p class="sub">${sub}</p></div>
<div class="ws-toolbar">
${orgChips(every, counts, org, everyRows.length, ['inbox'])}
<div class="ws-right"><span class="ws-seg" role="group" aria-label="State" aria-describedby="ws-tabs-why"><button type="button" aria-pressed="true" data-focus-key="tab-needs">Needs you</button><button type="button" aria-pressed="false" disabled title="Not built yet">Seen</button><button type="button" aria-pressed="false" disabled title="Not built yet">Answered</button></span><span class="note" id="ws-tabs-why">Seen and Answered are not built yet.</span></div>
</div>
${unread}
<div class="ws-ibx-grid${insp ? ' with-insp' : ''}"><div class="ws-ibx-list">${table}
${secH('Answered today', 'kept for 24 hours, then in the trail')}
<p class="ws-panel tight note" data-ws-answered>Answered and expired items are not built yet.</p></div>
${insp}
</div>
</section>`;
}
// ---- controls: filters write the hash query and repaint in place ----
const pathOf = () => route().split('?')[0];
const queryOf = () => new URLSearchParams(route().split('?')[1] || '');
function setQuery(change, render = true) {
const q = queryOf();
for (const [k, v] of Object.entries(change)) { if (v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length)) q.delete(k); else q.set(k, Array.isArray(v) ? v.join(',') : String(v)); }
const s = q.toString();
history.replaceState(history.state, '', `${selHref(pathOf())}${s ? `?${s}` : ''}`);
if (render) document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
}
function setGrouping(g) {
grouping = g === 'flat' ? 'flat' : 'org'; save(GROUPING, grouping); paint();
if (pathOf() === '/projects') document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
}
document.addEventListener('click', e => {
const page = e.target.closest?.('[data-ws-projects],[data-ws-inbox]');
if (!page) return;
const chip = e.target.closest('[data-ws-org]');
if (chip) {
const id = chip.dataset.wsOrg, now = listParam(queryOf(), 'org', x => ORG_ID.test(x)) || [];
const next = !id ? [] : now.includes(id) ? now.filter(x => x !== id) : [...now, id];
setQuery(page.hasAttribute('data-ws-inbox') ? { org: next, d: null } : { org: next });
return;
}
const sort = e.target.closest('[data-ws-sort]');
if (sort) {
const f = projectQuery(queryOf()), s = sort.dataset.wsSort;
setQuery(s === f.sort ? { sort: s, order: f.order === 'desc' ? 'asc' : 'desc' } : { sort: s, order: null });
return;
}
const grp = e.target.closest('[data-ws-group]');
if (grp) { setGrouping(grp.dataset.wsGroup); return; }
const pick = e.target.closest('[data-ws-select]');
if (pick && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) { e.preventDefault(); setQuery({ d: pick.dataset.wsSelect }); }
});
document.addEventListener('change', e => {
if (e.target.matches?.('[data-ws-projects] [data-ws-status]')) setQuery({ status: e.target.value === 'active' ? null : e.target.value });
else if (e.target.matches?.('[data-ws-projects] [data-ws-state]')) setQuery({ state: e.target.value || null });
});
// Typing writes the hash at once, so a poll repaint keeps what was typed; the read waits a beat.
let typing;
document.addEventListener('input', e => {
if (!e.target.matches?.('[data-ws-projects] [data-ws-name]')) return;
setQuery({ name: e.target.value.slice(0, 200) || null }, false);
clearTimeout(typing); typing = setTimeout(() => document.dispatchEvent(new CustomEvent('mosaic-workspace-render')), 250);
});
// U4b: the org mark (colour and initials) for the task board (tasks-board.js).
const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials });
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, orgMark, refresh: () => refresh(), home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
refresh();
})();
+5 -2
View File
@@ -22,6 +22,7 @@ import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
import { consoleRestartHTTP } from './console-restart-http.mjs';
import { workspaceHTTP } from './workspace-read.mjs';
import { agentLevelsHTTP } from './agent-levels-read.mjs';
import { TASK_WRITE_ROUTE, workspaceTasksHTTP } from './workspace-tasks.mjs';
import { actor } from './action-trail.mjs';
import { devHostName } from '../../cli/src/dev-local-owner.mjs';
@@ -59,12 +60,12 @@ const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js', 'tasks-board.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
['/files.css', ['files.css', 'text/css; charset=utf-8']],
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css', 'tasks-board.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
['/display.js', ['display.js', 'text/javascript; charset=utf-8']],
['/inbox.js', ['inbox.js', 'text/javascript; charset=utf-8']],
['/inbox.css', ['inbox.css', 'text/css; charset=utf-8']],
@@ -811,6 +812,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
registered.add('GET', '/api/workspace', (req, res, context) => workspaceHTTP(req, res, { ...context, organizations, log, ...(workspaceTimeoutMs === undefined ? {} : { timeoutMs: workspaceTimeoutMs }) }));
// #1873 A1: the agent levels behind the Agents grid. Read-only; owner only, like the workspace.
registered.add('GET', '/api/agents/levels', (req, res, context) => agentLevelsHTTP(req, res, { ...context, organizations, levels: agentLevels, log, ...(workspaceTimeoutMs === undefined ? {} : { timeoutMs: workspaceTimeoutMs }) }));
// U4b (#1873): the task board's writes, each to the one organization it names (workspace-tasks.mjs).
registered.add('POST', TASK_WRITE_ROUTE, (req, res, context) => workspaceTasksHTTP(req, res, { ...context, organizations, trail, log }));
const upstream = boardURL(board);
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
+53 -8
View File
@@ -23,6 +23,16 @@ const PROJECT_SELECTOR = /^([a-z0-9][a-z0-9._-]{0,63}):([A-Za-z0-9._-]{1,128})$/
const SHELL_SAFE = /^[A-Za-z0-9._:-]{1,128}$/;
const WEEK_MS = 7 * 24 * 3600 * 1000;
const PER_ORG_CONCURRENCY = 3;
// U4b (#1873): the task board's states, the built-in store's (broker STORE_STATES), in board order.
export const TASK_STATES = Object.freeze(['todo', 'in-progress', 'in-review', 'blocked', 'done']);
// U4b: at most this many tasks per organization reach one answer. The bus's tasks read has no
// cursor for the merged form, so the cap bounds the answer and the board's DOM. It applies last:
// projects, metrics, sessions and the inbox count every task, and the filters run before it, so a
// filter reaches every task the org holds. An org past it keeps its most recently changed tasks
// and says how many matched (`tasksHeld`); the board says the list is cut.
export const TASKS_PER_ORG = 500;
const ROLE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const DIGEST = /^[0-9a-f]{64}$/;
// The broker reads each section needs. Each name is one human-side read the org pages already make.
const NEEDS = {
@@ -57,7 +67,7 @@ function oneOf(name, value, allowed) {
// Strict: an unknown or repeated key is refused, so a misspelled filter never reads as "all".
export function parseWorkspaceQuery(search = '') {
const params = new URLSearchParams(search), seen = new Set();
const q = { sections: new Set(WORKSPACE_SECTIONS), orgs: null, status: new Set(['active']), state: null, name: null, sort: 'activity', order: null, project: null };
const q = { sections: new Set(WORKSPACE_SECTIONS), orgs: null, status: new Set(['active']), state: null, name: null, sort: 'activity', order: null, project: null, taskState: null, assignee: null, label: null };
for (const [key, value] of params) {
if (seen.has(key)) bad(`${key} is given twice`);
seen.add(key);
@@ -77,6 +87,17 @@ export function parseWorkspaceQuery(search = '') {
const m = PROJECT_SELECTOR.exec(value);
if (!m) bad('project takes <org>:<project id or key>');
q.project = { org: m[1], key: m[2] };
// U4b: the task filters. A Vikunja task whose bucket the bus does not name has state null and
// matches only `open`.
} else if (key === 'task-state') q.taskState = oneOf('task-state', value, [...TASK_STATES, 'open']);
else if (key === 'assignee') {
const names = list(value);
if (!names.length || names.some(n => !ROLE_NAME.test(n))) bad('assignee takes role names, comma separated');
q.assignee = new Set(names);
} else if (key === 'label') {
const ids = list(value).map(Number);
if (!ids.length || ids.some(n => !Number.isSafeInteger(n) || n < 1)) bad('label takes label ids, comma separated');
q.label = new Set(ids);
} else bad(`unknown query key ${key}`);
}
q.order ??= q.sort === 'activity' ? 'desc' : 'asc';
@@ -172,7 +193,8 @@ function validRead(name, value) {
if (name === 'claims') return strings(v, ['thread', 'instance', 'holder_run', 'harness', 'at']);
if (name === 'inbox') return string(v.id) && strings(v, ['question', 'class', 'raised_by_role', 'task_ref', 'at']) && optional(v.options, a => records(a) && a.every(o => strings(o, ['key', 'text'])));
if (name === 'agents') return strings(v, ['role', 'holder_run', 'harness', 'state', 'at']) && optional(v.working, w => record(w) && strings(w, ['since'])) && ['inputNeeded', 'stranded'].every(k => optional(v[k], records)) && (v.inputNeeded ?? []).every(d => strings(d, ['task_ref', 'at']));
if (name === 'tasks') return strings(v, ['task_ref', 'project_key', 'at']) && optional(v.fields, f => record(f) && strings(f, ['title', 'bucket']) && ['assignees', 'labels'].every(k => optional(f[k], Array.isArray)));
// A Vikunja row's bucket is the bucket's numeric id (tasks digest.mjs taskFields); a store row's is its state.
if (name === 'tasks') return strings(v, ['task_ref', 'project_key', 'at']) && optional(v.fields, f => record(f) && strings(f, ['title']) && optional(f.bucket, b => string(b) || Number.isSafeInteger(b)) && ['assignees', 'labels'].every(k => optional(f[k], Array.isArray)));
if (name === 'decisions') return strings(v, ['at']) && optional(v.closed, c => record(c) && strings(c, ['op', 'at']));
return true;
});
@@ -188,14 +210,25 @@ function orgRows(org, data, doc, nowMs) {
const byId = new Map(projects.map(p => [p.id, p]));
const ref = p => (p ? { id: p.id, key: p.repo ?? null, name: p.name ?? null } : null);
const threadProject = new Map(arr(data.threads).map(t => [t.id, byId.get(t.project) ?? null]));
const taskRows = arr(data.tasks).filter(t => t && typeof t.task_ref === 'string' && !t.fields?.gone);
const taskRows = arr(data.tasks).filter(t => t && typeof t.task_ref === 'string' && !t.fields?.gone && t.archived !== true);
const bots = new Map(Object.entries(doc?.roles ?? {}).filter(([, r]) => r?.tracker?.botId !== undefined).map(([role, r]) => [r.tracker.botId, role]));
const tasks = taskRows.map(t => {
const f = t.fields ?? {};
// U4b: a built-in store row (source 'store') carries role names, its state and its group; the
// board files a store task under the org project whose id or key is its group. A Vikunja row
// carries bot ids, which map to roles through the org file, and a numeric bucket the bus does
// not name, so its state is only known when it is done.
const store = t.source === 'store';
const state = store ? (TASK_STATES.includes(f.state) ? f.state : null) : (f.done ? 'done' : null);
const group = store && typeof f.group === 'string' ? f.group : null;
const project = store ? (group === null ? null : byId.get(group) ?? byKey.get(group) ?? null) : (typeof t.project_key === 'string' ? byKey.get(t.project_key) : null);
return {
org: id, ref: t.task_ref, tracker: org.tracker, title: f.title ?? null, done: Boolean(f.done), bucket: f.bucket ?? null,
assignees: arr(f.assignees).filter(u => bots.has(u)).map(u => bots.get(u)).sort(), labels: arr(f.labels), priority: t.priority ?? null,
requirement: t.requirement ?? null, project: ref(typeof t.project_key === 'string' ? byKey.get(t.project_key) : null), changedAt: t.at ?? null,
org: id, ref: t.task_ref, tracker: store ? 'built-in' : t.source === undefined ? org.tracker : 'vikunja', title: f.title ?? null, done: Boolean(f.done), bucket: f.bucket ?? null,
state, assignees: store ? arr(f.assignees).filter(r => typeof r === 'string').sort() : arr(f.assignees).filter(u => bots.has(u)).map(u => bots.get(u)).sort(),
labels: arr(f.labels), priority: t.priority ?? null, dueDate: typeof f.due_date === 'string' ? f.due_date : null,
requirement: t.requirement ?? null, project: ref(project), group, changedAt: t.at ?? null,
// The digest a write sends back as `expect`; only a store task is written from the board.
digest: store && typeof t.digest === 'string' && DIGEST.test(t.digest) ? t.digest : null, writable: store,
};
});
const taskByRef = new Map(tasks.map(t => [t.ref, t]));
@@ -303,8 +336,20 @@ export async function readWorkspace({ organizations, session, query = parseWorks
if (query.sections.has('inbox')) body.inbox = merged.inbox.sort((a, b) => (a.class === 'gated' ? 0 : 1) - (b.class === 'gated' ? 0 : 1) || (a.at ?? '').localeCompare(b.at ?? ''));
if (query.sections.has('tasks')) {
const p = query.project;
body.tasks = merged.tasks.filter(t => !p || (t.org === p.org && (t.project?.id === p.key || t.project?.key === p.key)))
.sort((a, b) => a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref));
const matched = merged.tasks.filter(t => (!p || (t.org === p.org && (t.project?.id === p.key || t.project?.key === p.key)))
&& (!query.taskState || query.taskState.has(t.state ?? 'open'))
&& (!query.assignee || t.assignees.some(a => query.assignee.has(a)))
&& (!query.label || t.labels.some(l => query.label.has(l))));
// The cap, per org, after the filters: the most recently changed tasks stay.
const byOrg = Map.groupBy(matched, t => t.org), kept = [];
for (const r of read) {
const rows = byOrg.get(r.org.org) ?? [];
if (rows.length > TASKS_PER_ORG) {
r.org.tasksHeld = rows.length;
kept.push(...rows.sort((a, b) => (b.changedAt ?? '').localeCompare(a.changedAt ?? '') || b.ref.localeCompare(a.ref, undefined, { numeric: true })).slice(0, TASKS_PER_ORG));
} else kept.push(...rows);
}
body.tasks = kept.sort((a, b) => a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref, undefined, { numeric: true }));
}
if (query.sections.has('metrics')) {
const m = merged.metrics;
+133
View File
@@ -0,0 +1,133 @@
// U4b (#1873, PRD 1.20): the task board's writes, POST /api/workspace/tasks.
//
// One write goes to one organization's bus. The body names the org; the org is resolved for this
// session through the same admission the org pages use (organizations.mjs `resolve`), and the write
// goes out on that org's own transport (`taskWrite`, console-bus.mjs) and nowhere else. The verbs are
// the person's built-in store verbs (tasks.create, tasks.edit, tasks.archive): the bus runs its own
// human check on every one, so a Console started by an agent gets human-required and writes nothing.
//
// The route table's hook fence has already required a signed-in session on the page's own name, the
// exact Origin and Sec-Fetch-Site: same-origin. This handler adds the owner, --send and a strict JSON
// body. A refusal is answered with the bus's code and a fixed message; nothing is retried.
import { actor } from './action-trail.mjs';
import { TASK_STATES } from './workspace-read.mjs';
export const TASK_WRITE_ROUTE = '/api/workspace/tasks';
const MAX_BODY = 16384;
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const STORE_REF = /^task:[1-9][0-9]{0,14}$/;
const DIGEST = /^[0-9a-f]{64}$/;
const REQUIREMENT = /^REQ-[A-Z]+-[1-9][0-9]*$/;
const ROLE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const DUE = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/;
// Each op: its bus verb, the keys it takes and the ones it needs. The board edits title, state,
// assignees, priority and due date; a create also names the requirement and the project (group).
const OPS = {
create: { verb: 'tasks.create', keys: ['title', 'requirement', 'group', 'state', 'assignees', 'priority', 'due_date'], need: ['title', 'requirement'] },
// An edit or archive names the digest the board read, so a change made since is a task-conflict
// and never a silent overwrite. The bus checks it; the route refuses a write without one.
edit: { verb: 'tasks.edit', keys: ['task_ref', 'expect', 'title', 'state', 'assignees', 'priority', 'due_date'], need: ['task_ref', 'expect'] },
archive: { verb: 'tasks.archive', keys: ['task_ref', 'expect'], need: ['task_ref', 'expect'] },
};
const FIELD_CHECKS = {
task_ref: v => typeof v === 'string' && STORE_REF.test(v),
expect: v => typeof v === 'string' && DIGEST.test(v),
title: v => typeof v === 'string' && v.trim().length >= 1 && v.length <= 250 && !v.includes('\0'),
requirement: v => typeof v === 'string' && REQUIREMENT.test(v),
group: v => v === null || (typeof v === 'string' && v.length >= 1 && v.length <= 64 && !/[\u0000-\u001f\u007f]/.test(v)),
state: v => TASK_STATES.includes(v),
assignees: v => Array.isArray(v) && v.length <= 50 && v.every(r => typeof r === 'string' && ROLE_NAME.test(r)),
priority: v => Number.isSafeInteger(v) && v >= 0 && v <= 5,
due_date: v => v === null || (typeof v === 'string' && DUE.test(v) && Number.isFinite(Date.parse(v)) && new Date(v).toISOString() === v),
};
// The answers a person reads on the board, by bus code. Each says whether anything was written.
const REFUSALS = {
'human-required': [403, 'The bus takes task changes only from a person at the Console. Nothing was written.'],
'tracker-not-store': [409, 'This organization keeps its tasks in Vikunja. The board does not write to Vikunja; nothing was written.'],
'task-conflict': [409, 'The task changed after the board read it. Nothing was written; the board now shows the current task.'],
'task-archived': [409, 'The task is archived. Nothing was written.'],
'task-already-archived': [409, 'The task was already archived. Nothing was written.'],
'task-not-found': [404, 'The task is not on this organization\'s bus. Nothing was written.'],
'task-position-full': [409, 'This project has no room for another task position. Nothing was written.'],
'invalid-state': [400, 'The bus does not have that state. Nothing was written.'],
'unknown-role': [400, 'An assignee is not a role in this organization. Nothing was written.'],
'invalid-request': [400, 'The bus refused the change as invalid. Nothing was written.'],
'credential-leak': [400, 'The change holds part of a stored credential, so the bus refused it. Nothing was written.'],
'no-bus-host': [503, 'This organization\'s bus is not running. Nothing was written.'],
'outcome-unknown': [503, 'The bus did not answer. The change may or may not have been written; the board shows it on its next read.'],
};
const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large', 'outcome-unknown-revoked'];
const json = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
};
const one = (req, name) => (req.headersDistinct?.[name]?.length === 1 ? req.headersDistinct[name][0] : null);
class BodyError extends Error { constructor(status, message) { super(message); this.status = status; } }
async function readBody(req) {
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(one(req, 'content-type') ?? '')) throw new BodyError(415, 'JSON body required');
let size = 0;
const chunks = [];
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY) throw new BodyError(413, 'body too large');
chunks.push(chunk);
}
let value;
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new BodyError(400, 'invalid JSON'); }
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BodyError(400, 'the body is an object');
return value;
}
// Strict: an unknown key, a missing required key or a malformed value is refused before any bus.
export function parseTaskWrite(body) {
const { op, org, ...rest } = body;
const spec = OPS[op];
if (!spec) throw new BodyError(400, `op takes ${Object.keys(OPS).join(', ')}`);
if (typeof org !== 'string' || !ORG_ID.test(org)) throw new BodyError(400, 'org takes an organization id');
for (const key of Object.keys(rest)) if (!spec.keys.includes(key)) throw new BodyError(400, `${op} does not take ${key}`);
for (const key of spec.need) if (!Object.hasOwn(rest, key)) throw new BodyError(400, `${op} needs ${key}`);
for (const [key, value] of Object.entries(rest)) if (!FIELD_CHECKS[key](value)) throw new BodyError(400, `${key} is not valid`);
if (op === 'edit' && !Object.keys(rest).some(k => !['task_ref', 'expect'].includes(k))) throw new BodyError(400, 'edit needs a field to change');
return { op, org, verb: spec.verb, args: rest };
}
// `organizations.resolve(id, session)` admits only an org this session belongs to and answers its
// options, `taskWrite` among them. Nothing here holds another org's transport.
export async function workspaceTasksHTTP(req, res, { session = null, owner = false, send = false, organizations = null, trail = null, log = () => {} }) {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if (!organizations || typeof organizations.resolve !== 'function') return json(res, 404, { error: 'org-unavailable' });
if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'the task board\'s changes are the owner\'s' });
if (!send) return json(res, 403, { error: 'read-only', message: 'start the Console with --send to make changes' });
let write;
try { write = parseTaskWrite(await readBody(req)); }
catch (e) { if (e instanceof BodyError) return json(res, e.status, { error: 'invalid-request', message: e.message, sent: false }); throw e; }
let selected;
try { selected = await organizations.resolve(write.org, session); }
catch (e) {
if (e?.code === 'org-archived') return json(res, 409, { error: 'org-archived', message: 'organization is archived', sent: false });
return json(res, 404, { error: 'org-unavailable', sent: false });
}
if (!selected) return json(res, 404, { error: 'org-unavailable', sent: false });
if (selected.busHealthy === false) return json(res, 503, { error: 'no-bus-host', message: REFUSALS['no-bus-host'][1], sent: false });
if (typeof selected.taskWrite !== 'function') return json(res, 403, { error: 'read-only', message: 'this organization takes no task changes from this Console', sent: false });
const note = outcome => {
try { trail?.({ by: actor(session), org: write.org, action: write.verb, ...(write.args.task_ref ? { task: write.args.task_ref } : {}), outcome }); }
catch { log(`${new Date().toISOString()} trail write failed for ${write.verb}`); }
};
let result;
try { result = await selected.taskWrite(write.verb, write.args); }
catch (e) {
const code = typeof e?.code === 'string' && /^[a-z-]{1,64}$/.test(e.code) ? e.code : 'outcome-unknown';
const open = OPEN.includes(code);
const [status, message] = REFUSALS[open ? 'outcome-unknown' : code] ?? [502, `The bus refused the change (${code}). Nothing was written.`];
log(`${new Date().toISOString()} task board: ${write.verb} on ${write.org} refused ${code}`);
note(open ? 'unknown' : `refused ${code}`);
return json(res, status, { error: open ? 'outcome-unknown' : code, message, sent: open ? 'unknown' : false });
}
note('written');
const task = result && typeof result === 'object' && typeof result.task_ref === 'string' ? result.task_ref : write.args.task_ref ?? null;
return json(res, 200, { ok: true, org: write.org, op: write.op, task_ref: task, digest: typeof result?.digest === 'string' ? result.digest : null });
}
+2 -2
View File
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
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.
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', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
// 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.
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', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.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);
@@ -131,6 +131,7 @@ test('owner can start a down bus after setup recorded done and sees recovery gui
const f = await fixture(t);
await f.b.evaluate(`(()=>{const old=window.fetch;window.fetch=async (input,init={})=>{const u=String(input);if(u.endsWith('/setup') && (!init.method || init.method==='GET'))return new Response(JSON.stringify({notifications:{state:'done'},queue:{state:'done'},projects:[],bus:{state:'done',verb:'mosaic bus start alpha'}}),{status:200});if(u.endsWith('/setup/bus') && init.method==='POST')return new Response(JSON.stringify({error:'stale-lock',message:'stale bus host lock'}),{status:409});return old(input,init)}})()`);
await f.open('alpha');
// The setup steps render after the editor title, once the setup and organization list reads answer.
await f.wait(`!!document.querySelector('[data-step="bus"] [data-run="bus"]:not(:disabled)')`);
await f.b.evaluate(`document.querySelector('[data-step="bus"] [data-run="bus"]').click()`);
await f.wait(`document.querySelector('[data-step="bus"]').innerText.includes('stale bus host lock')`);
@@ -31,7 +31,7 @@ test('an allowed LAN name loads the org page and creates a project', { timeout:
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const input = (selector, value) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
const click = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`);
await b.navigate(`http://lan.test:${port}/#/orgs/alpha/projects`);
await b.navigate(`http://lan.test:${port}/#/orgs/alpha/chat`);
assert.deepEqual(await b.evaluate('({ host: location.host, secure: isSecureContext, uuid: typeof crypto.randomUUID })'), { host: `lan.test:${port}`, secure: false, uuid: 'undefined' });
await wait('window.MosaicOrg?.id === "alpha" && !!document.querySelector("#chat-repo option[value=work]")');
await click('.project-create summary');
@@ -31,7 +31,7 @@ const bannerText = "document.getElementById('org-list-banner')?.textContent || '
test('a registry error shows on a selected org page and Retry recovers', async t => {
const f = await setup(t), file = join(f.ctx.configDir, 'org-runtime.json');
const original = await fs.readFile(file, 'utf8'); await fs.writeFile(file, '{');
await f.b.navigate(`${f.base}/#/orgs/alpha/`);
await f.b.navigate(`${f.base}/#/orgs/alpha/board`);
await wait(f.b, `(${bannerText}).includes('Organization registry could not be read') && document.querySelector('#held [data-retry]')`);
const before = f.requests(); await fs.writeFile(file, original);
await f.b.evaluate("document.querySelector('#held [data-retry]').click()");
@@ -44,7 +44,7 @@ test('a broken org banner is replaced, not stacked, and clears after repair', as
const f = await setup(t), file = join(f.ctx.configDir, 'org-runtime.json');
const original = await fs.readFile(file, 'utf8'), reg = JSON.parse(original);
reg.orgs.beta.digest = '0'.repeat(64); await fs.writeFile(file, JSON.stringify(reg));
await f.b.navigate(`${f.base}/#/orgs/alpha/`);
await f.b.navigate(`${f.base}/#/orgs/alpha/board`);
await wait(f.b, `(${bannerText}).includes('beta: invalid') && document.querySelector('#held [data-retry]')`);
await f.b.evaluate('window.MosaicRefreshOrganizations()');
await wait(f.b, `(${bannerText}).includes('beta: invalid')`);
+1 -1
View File
@@ -49,7 +49,7 @@ try {
await wait('document.querySelector("#org-setup") && !document.querySelector("#org-setup").hidden');
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/alpha\/settings\/organizations\?org=alpha&step=setup$/);
await b.screenshot(join(shots, '03-resume.png'));
await b.evaluate(`location.hash='#/orgs/alpha/projects'`);
await b.evaluate(`location.hash='#/orgs/alpha/chat'`);
await wait('!!document.querySelector("#chat-repo option[value=work]")');
await click('.project-create summary');
await input('#chat-project-name', 'Work'); await input('#chat-repo', 'work');
@@ -118,7 +118,7 @@ test('an organization offers its request status, and a conversation whose messag
test('an organization project overview reports no active request instead of unavailable', { timeout: 60000 }, async t => {
const { base, diagnostics } = await consoleFor(t);
const { b, wait } = await page(t, base, diagnostics);
await b.navigate(`${base}#/orgs/alpha/projects`);
await b.navigate(`${base}#/orgs/alpha/chat`);
await wait(`document.querySelector('[data-project-status="${P}"] .project-card-status-line')`);
assert.match(await b.evaluate(`document.querySelector('[data-project-status="${P}"]').textContent`), /^No active requests · 1 conversation/);
// The overview renders after its reads settle, so the metrics are final once they exist.
@@ -123,7 +123,7 @@ test('project creation offers repositories from the scoped business read', async
};
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/projects`);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/chat`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#chat-repo option[value=work]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('repository choice missing')})()`);
assert.equal(await b.evaluate(`document.querySelector('#chat-repo option[value=work]').value`), 'work');
});
@@ -269,7 +269,7 @@ test('a stopped organization bus has truthful chat guidance', async t => {
};
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, send: async () => ({}), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/projects`);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/chat`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.chat-area')?.innerText.includes('The bus is stopped'))return;await new Promise(r=>setTimeout(r,30))}throw Error('stopped-bus guidance missing')})()`);
const text = await b.evaluate(`document.querySelector('.chat-area').innerText`);
assert.match(text, /bus is stopped.*Start the bus/i);
@@ -0,0 +1,313 @@
// U4b (#1873, PRD 1.20): the task board across organizations (sample 06) in a real browser.
// Three orgs: forge keeps its tasks in the built-in store, ddigital in Vikunja, home's bus is down.
// Every bus is a fake in this file; each org's taskWrite records what reached that org only.
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);
function boardOrgs({ refuse = null, tasksFail = [] } = {}) {
const ORGS = [
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
];
const DOCS = {
ddigital: { appearance: { theme: 'rosewood' }, tracker: { kind: 'vikunja' }, roles: { copywriter: { tracker: { botId: 7 } }, editor: { tracker: { botId: 8 } } } },
forge: { appearance: { theme: 'cobalt' }, roles: { coder: {}, reviewer: {} } },
home: { appearance: { theme: 'grove' } },
};
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 = [
{ n: 41, title: 'Rate limit the public API', state: 'todo', group: 'api-gateway', assignees: ['coder'], priority: 4, due: '2026-11-20T00:00:00.000Z' },
{ n: 42, title: 'Invoice export', state: 'in-progress', group: 'billing', assignees: [], priority: 0, due: null },
{ n: 43, title: 'Retire the v1 gateway', state: 'done', group: 'api-gateway', assignees: ['reviewer'], priority: 2, due: null },
].map(t => ({ ...t, rev: 0 }));
// A task's digest changes with every write to it, as the store's does.
const digest = t => D((t.n + t.rev) % 10);
const TASKS = {
forge: () => store.map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: t.priority, digest: digest(t), requirement: 'REQ-API-1',
fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: t.due } })),
ddigital: () => [
{ task_ref: 'vikunja:3/112', project_key: 'spring', priority: 3, at: '2026-11-09T11:00:00.000Z', fields: { title: 'Launch email, draft 2', done: false, assignees: [7], bucket: 12, labels: ['launch'] } },
{ task_ref: 'vikunja:3/118', project_key: 'spring', priority: 0, at: '2026-11-08T11:00:00.000Z', fields: { title: 'Teaser reel', done: true, assignees: [8], bucket: 14, labels: [] } },
],
};
const writes = [];
const taskWrite = org => async (verb, args) => {
writes.push([org, verb, args]);
if (refuse) throw Object.assign(new Error('refused'), { code: refuse });
const t = store.find(s => `task:${s.n}` === args.task_ref);
if (t && args.expect && args.expect !== digest(t)) throw Object.assign(new Error('conflict'), { code: 'task-conflict' });
if (verb === 'tasks.edit' && t) { for (const f of ['title', 'state', 'assignees']) if (args[f] !== undefined) t[f] = args[f]; t.rev++; }
return { task_ref: args.task_ref, digest: t ? digest(t) : D(9) };
};
const options = id => ({
bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => { if (tasksFail.includes(id)) throw Object.assign(new Error('tasks'), { code: 'tracker-unavailable' }); return TASKS[id](); }, messages: async () => ({ messages: [], unread: 0 }) },
chat: { projects: async () => PROJECTS[id], threads: async () => [] },
});
const healthy = async () => ({ healthy: true, pid: 4242, boot: null });
const down = async () => ({ healthy: false, pid: null, boot: null });
return {
writes, store,
list: async () => ORGS,
members: async () => ORGS.map(o => ({ id: o.id, doc: DOCS[o.id], status: o.busHealthy ? healthy : down, options: () => options(o.id) })),
resolve: async id => {
const org = ORGS.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] }, taskWrite: taskWrite(id) };
return { busHealthy: true, reads: { business: async () => ({ business: { id, projects: PROJECTS[id].map(p => p.id) } }) }, ...options(id), taskWrite: taskWrite(id) };
},
};
}
async function open(t, { refuse = null, tasksFail = [], send = true, width = 1440, height = 900, orgs = boardOrgs({ refuse, tasksFail }) } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, ...(send ? { send: async () => ({}) } : {}), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(width, height);
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=home]")');
return { b, wait, orgs, url };
}
const cards = b => b.evaluate('[...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask)');
test('the board merges every organization\'s tasks, tags each with its org, and a down org keeps its state', async t => {
const { b } = await open(t);
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'kanban');
assert.deepEqual((await cards(b)).sort(), ['ddigital|vikunja:3/112', 'ddigital|vikunja:3/118', 'forge|task:41', 'forge|task:42', 'forge|task:43']);
// Each card carries its org's mark and the id in its tracker's form.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-card")].map(c => [c.dataset.org, c.querySelector(".id").textContent, !!c.querySelector(".ws-av"), c.querySelector(".tb-trk").textContent]).sort()'),
[['ddigital', 'V-112', true, 'Vikunja'], ['ddigital', 'V-118', true, 'Vikunja'], ['forge', '#41', true, 'built-in'], ['forge', '#42', true, 'built-in'], ['forge', '#43', true, 'built-in']]);
// Columns: the store's states, plus one for the Vikunja task whose bucket the bus does not name.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-col h3")].map(h => h.textContent)'), ['To do', 'In progress', 'Review', 'Blocked', 'Done', 'State not read']);
assert.deepEqual(await b.evaluate('[...document.querySelector("[data-tb-col=todo] [data-tb-task=\'forge|task:41\'] .bot").children].map(e => e.textContent.trim())'), ['API gateway', 'coder', 'P4', 'due 20 Nov']);
// Only built-in store cards can be dragged; a Vikunja card is read only.
assert.equal(await b.evaluate('document.querySelector("[data-tb-task=\'forge|task:41\']").draggable'), true);
assert.equal(await b.evaluate('document.querySelector("[data-tb-task=\'ddigital|vikunja:3/112\']").draggable'), false);
// home's bus is down: it shows its state and reason, and the other orgs still render.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-tracker]")].map(e => [e.dataset.tbTracker, e.dataset.state])'), [['ddigital', 'ok'], ['forge', 'ok'], ['home', 'down']]);
assert.match(await b.evaluate('document.querySelector("[data-tb-tracker=home]").textContent'), /Home.*bus down.*the bus is not running/s);
assert.match(await b.evaluate('document.querySelector("[data-tb-org=home]").textContent'), /Home\s*unavailable/);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /5 tasks from 2 of 3 organizations.*Vikunja tasks are read only here/);
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /not built yet/);
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), false);
});
test('org and project filters narrow the board', async t => {
const { b, wait } = await open(t);
await b.evaluate('document.querySelector("[data-tb-org=forge]").click()');
await wait('document.querySelector("[data-tb-org=forge]").getAttribute("aria-pressed") === "true"');
assert.deepEqual((await cards(b)).sort(), ['forge|task:41', 'forge|task:42', 'forge|task:43']);
assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 3 of 5 tasks.');
// The project list follows the org filter.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-filter=project] option")].map(o => o.value).sort()'), ['', '-', 'forge:api-gateway', 'forge:billing']);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
assert.deepEqual(await cards(b), ['forge|task:42']);
assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 1 task that matches the filters.');
// Back to every org; the project filter stays on forge's project.
await b.evaluate('document.querySelector("[data-tb-org=\'\']").click()');
await wait('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed") === "true"');
assert.deepEqual(await cards(b), ['forge|task:42']);
// A project of another org, then the assignee filter on a Vikunja role.
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "ddigital:spring-launch"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 2');
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=assignee]"); s.value = "copywriter"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
assert.deepEqual(await cards(b), ['ddigital|vikunja:3/112']);
});
test('the layout toggle is remembered across a reload', async t => {
const { b, wait } = await open(t);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "list" && document.querySelector("[data-tb-list]")');
assert.equal(await b.evaluate('document.querySelector("[data-tb-layout=list]").getAttribute("aria-pressed")'), 'true');
await b.evaluate('location.reload()');
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'list');
assert.equal(await b.evaluate('document.querySelectorAll("[data-tb-list] tr.tb-row").length'), 5);
});
test('a phone opens on List with no sideways scroll, and Kanban is one tap away', async t => {
const { b, wait } = await open(t, { width: 390, height: 844 });
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'list');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
await b.evaluate('document.querySelector("[data-tb-layout=kanban]").click()');
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "kanban"');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
// One column on a phone.
assert.equal(await b.evaluate('getComputedStyle(document.querySelector(".tb-board")).gridTemplateColumns.split(" ").length'), 1);
});
test('a move writes only to its own org, and a polled repaint keeps focus', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
// Vikunja rows have no State control; a store row does.
assert.equal(await b.evaluate('document.querySelector("[data-tb-move^=ddigital]")'), null);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:41\']"); s.focus(); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('/moved to Review in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:41', expect: D(1), state: 'in-review' }]]);
assert.equal(await b.evaluate('document.querySelector("[data-tb-move=\'forge|task:41\']").value'), 'in-review');
// The search keeps focus and its caret through the poll's repaint.
await b.evaluate('(() => { const q = document.querySelector("[data-tb-q]"); q.focus(); q.value = "inv"; q.dispatchEvent(new Event("input", { bubbles: true })); q.setSelectionRange(1, 1); })()');
await wait('document.querySelectorAll("[data-tb-list] tr.tb-row").length === 1');
// bus.js repaints the view on its own 10 s poll; wait for that repaint to replace the board.
assert.equal(await b.evaluate('(async () => { const before = document.querySelector("[data-tasks-board]"); for (let i = 0; i < 140 && document.querySelector("[data-tasks-board]") === before; i++) await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 50)); return document.querySelector("[data-tasks-board]") !== before; })()'), true);
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement?.selectionStart, document.querySelector("[data-tb-q]").value]'), ['tb-q', 1, 'inv']);
});
test('a write the bus refuses for want of a person is shown, and nothing else is written', async t => {
const { b, wait, orgs } = await open(t, { refuse: 'human-required' });
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'forge|task:42\']")');
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:42\']"); s.value = "done"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelector("[data-tb-notice][role=alert]")');
assert.match(await b.evaluate('document.querySelector("[data-tb-notice]").textContent'), /#42 was not moved\. The bus takes task changes only from a person at the Console\. Nothing was written\./);
assert.deepEqual(orgs.writes.map(w => w[0]), ['forge']);
// The board still shows the task where the bus has it.
assert.equal(await b.evaluate('document.querySelector("[data-tb-move=\'forge|task:42\']").value'), 'in-progress');
// The edit dialog shows the same refusal.
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
await wait('/only from a person/.test(document.querySelector("#tb-dialog [data-tb-form-status]").textContent)');
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]);
});
test('a Console without --send shows a read-only board and the write route refuses', async t => {
const { b, orgs } = await open(t, { send: false });
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), true);
assert.equal(await b.evaluate('document.querySelectorAll(".tb-card[draggable=true]").length'), 0);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /board is read only/);
const status = await b.evaluate('fetch("/api/workspace/tasks", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ op: "edit", org: "forge", task_ref: "task:41", state: "done" }) }).then(r => r.status)');
assert.equal(status, 403);
assert.deepEqual(orgs.writes, []);
});
// bus.js repaints the view on its own 10 s poll; this resolves once that repaint has replaced the board.
const POLLED = '(async () => { const before = document.querySelector("[data-tasks-board]"); for (let i = 0; i < 140 && document.querySelector("[data-tasks-board]") === before; i++) await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 50)); return document.querySelector("[data-tasks-board]") !== before; })()';
test('an edit dialog left open while another writer changes the task saves against what it showed and gets a conflict', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
// Another writer renames the task and assigns it; a poll then reads the new version.
const t42 = orgs.store.find(s => s.n === 42);
Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 });
assert.equal(await b.evaluate(POLLED), true);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export');
// Save sends the digest and the changes against the task as the dialog showed it.
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
await wait('/changed after the board read it/.test(document.querySelector("#tb-dialog [data-tb-form-status]")?.textContent)');
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]]);
assert.equal(t42.title, 'Invoice export (renamed elsewhere)');
// The dialog reopens on the current task, so the next save is checked against what it now shows.
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']);
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), false);
});
test('a poll that answers after focus has moved leaves focus where the person put it', async t => {
const { b, wait } = await open(t);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
// Hold the next workspace read until focus has moved from the search to a row's State control.
await b.evaluate(`(() => {
const real = window.fetch;
window.__held = null;
window.fetch = (u, o) => String(u).startsWith('/api/workspace?') && !window.__held
? new Promise(go => { window.__held = () => real(u, o).then(go); })
: real(u, o);
document.querySelector("[data-tb-q]").focus();
})()`);
// bus.js polls every 10 s, longer than waitFor allows.
assert.equal(await b.evaluate('(async () => { for (let i = 0; i < 140 && !window.__held; i++) await new Promise(r => setTimeout(r, 100)); return !!window.__held; })()'), true);
await b.evaluate('(window.__board = document.querySelector("[data-tasks-board]"), document.querySelector("[data-tb-move=\'forge|task:43\']").focus())');
await b.evaluate('window.__held()');
await wait('document.querySelector("[data-tasks-board]") !== window.__board');
await b.evaluate('new Promise(r => setTimeout(r, 100))');
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'move:forge|task:43');
});
test('an org whose tasks read fails shows its tasks as not read, not as none', async t => {
const { b } = await open(t, { tasksFail: ['forge'] });
assert.deepEqual((await cards(b)).sort(), ['ddigital|vikunja:3/112', 'ddigital|vikunja:3/118']);
assert.match(await b.evaluate('document.querySelector("[data-tb-tracker=forge]").textContent'), /Forge.*tasks not read.*tracker-unavailable/s);
assert.match(await b.evaluate('document.querySelector("[data-tb-org=forge]").textContent'), /Forge\s*unavailable/);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /2 tasks from 1 of 3 organizations/);
// No org that can take a write is left, so New task is off.
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), true);
});
test('a board with no tasks read at all says so instead of showing an empty board', async t => {
const { b } = await open(t, { tasksFail: ['forge', 'ddigital'] });
assert.deepEqual(await cards(b), []);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]").textContent'), /No organization answered with its tasks, so no tasks could be read\./);
});
test('a committed write whose answer cannot be read is shown as unknown, not as refused', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
// The write reaches the bus; a proxy then replaces the answer with a page that is not JSON.
await b.evaluate(`(() => { const real = window.fetch; window.fetch = async (u, o) => { const r = await real(u, o); return String(u) === '/api/workspace/tasks' ? new Response('<html>Bad gateway</html>', { status: 502, headers: { 'content-type': 'text/html' } }) : r; }; })()`);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:41\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('/may or may not have been written/.test(document.querySelector("[data-tb-notice]").textContent)');
const text = await b.evaluate('document.querySelector("[data-tb-notice]").textContent');
assert.doesNotMatch(text, /was not moved|Nothing was written/);
assert.match(text, /^#41: /);
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:41', expect: D(1), state: 'in-review' }]]);
// The board reads again and shows where the bus has the task.
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']").value === "in-review"');
});
test('a filter change asks the server for the matching tasks', async t => {
const { b, wait } = await open(t);
await b.evaluate(`(() => { const real = window.fetch; window.__reads = []; window.fetch = (u, o) => { if (String(u).startsWith('/api/workspace?')) window.__reads.push(String(u)); return real(u, o); }; })()`);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('window.__reads.length');
const q = new URLSearchParams(await b.evaluate('window.__reads[0].split("?")[1]'));
assert.equal(q.get('project'), 'forge:billing');
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
assert.deepEqual(await cards(b), ['forge|task:42']);
});
test('a conflict on a task another writer moved out of the board\'s filter reopens on the current task', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "in-progress"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
// Another writer finishes the task, so the board's In progress read no longer holds it.
const t42 = orgs.store.find(s => s.n === 42);
Object.assign(t42, { state: 'done', rev: t42.rev + 1 });
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
await wait('document.querySelector("#tb-dialog form")?.state.value === "done"');
assert.match(await b.evaluate('document.querySelector("#tb-dialog [data-tb-form-status]").textContent'), /changed after the board read it/);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), true);
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
});
test('an empty result names the organizations whose tasks were not read', async t => {
const { b, wait } = await open(t, { tasksFail: ['ddigital'] });
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "blocked"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('/No tasks match/.test(document.querySelector("[data-tb-results]").textContent)');
assert.equal(await b.evaluate('document.querySelector("[data-tb-results] .ws-panel").textContent'),
'No tasks match these filters in the organizations that answered. Tasks from Distinctly Digital, Home could not be read.');
});
@@ -111,7 +111,8 @@ test('sidebar follows sample 01 and Queue opens from the organization dashboard,
await b.evaluate('document.querySelector("[data-org-queue]").click()');
await wait('location.hash === "#/orgs/ddigital/queue" && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Queue")');
// Views not built yet open and say so.
for (const [route, title] of [['inbox', 'Inbox'], ['tasks', 'Tasks'], ['side-by-side', 'Side by side']]) {
// Inbox (U3, workspace-pages-browser.test.mjs) and Tasks (U4b, tasks-board-browser.test.mjs) are built.
for (const [route, title] of [['side-by-side', 'Side by side']]) {
await b.evaluate(`location.hash = "#/orgs/ddigital/${route}"`);
await wait(`document.getElementById("bus-view").innerText.includes("not built yet") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === ${JSON.stringify(title)})`);
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /error|failed/i, route);
@@ -0,0 +1,369 @@
// U3 (#1873, PRD 1.20 REQ-WS-1..6): the cross-organization Dashboard, Projects and Inbox pages
// (samples 02, 04 and 05). Every bus here is a fake: two running organizations and one stopped,
// read through the real GET /api/workspace (workspace-read.mjs) and the real page code.
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<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
// acme and beta answer; gamma's bus is stopped. Decision ids are long so a shortened id would show.
const ACME_DECISION = 'dec-acme-7f3c9a21-5b8e-4d1f-9c2a-0e6b7d8f9a10';
const BETA_DECISION = 'dec-beta-1a2b3c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d';
function fixture({ pollMs, fail = {}, down = [] } = {}) {
const LIST = [
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false },
];
const DATA = {
acme: {
projects: [
{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) },
{ id: 'p-zeta', repo: 'zeta', name: 'Zeta API', archived: false, at: at(300) },
{ id: 'p-old', repo: 'old', name: 'Old brochure', archived: true, at: at(60 * 24 * 20) },
],
tasks: [{ task_ref: 'local:1', project_key: 'site', at: at(20), fields: { title: 'Hero copy', done: false, bucket: 'Doing' } }],
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
claims: [], threads: [],
inbox: [{ id: ACME_DECISION, question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', task_ref: 'local:1', at: at(10), options: [{ key: 'yes', text: 'Ship it' }, { key: 'no', text: 'Hold' }] }],
decisions: [],
},
beta: {
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }],
tasks: [], agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }], claims: [], threads: [],
inbox: [{ id: BETA_DECISION, question: 'Pick a launch price', class: 'within-role', blocking: false, raised_by_role: 'pm', at: at(5), options: [{ key: 'low', text: 'Low' }] }],
decisions: [],
},
};
// fail: { org: [read names] } makes those reads refuse the way a disabled chat channel does.
const value = (id, name) => fail[id]?.includes(name) ? Promise.reject(Object.assign(new Error('off'), { code: 'channel-disabled' })) : Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
const opts = id => ({
bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') },
chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') },
});
const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme } },
// down: the cross-organization read finds these buses stopped; the page's own reads still answer.
status: async () => (org.busHealthy && !down.includes(org.id) ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
options: () => opts(org.id),
});
return {
list: async () => LIST,
members: async () => LIST.map(member),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
return {
busHealthy: true,
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) },
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
};
},
...(pollMs ? { pollMs } : {}),
};
}
async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs, fail, down } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, down }), log: () => {}, ...(workspaceTimeoutMs ? { workspaceTimeoutMs } : {}) });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(...viewport);
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}/#/orgs/acme${hash}`);
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
test('Dashboard renders every section across organizations, and a stopped one shows its state while the others render', async t => {
const { b, wait, text } = await setup(t);
await wait('document.querySelector("[data-ws-home] [data-ws-tiles]") && document.querySelectorAll("[data-ws-orgcard]").length === 3');
// The Dashboard nav item is this page.
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Dashboard');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/');
assert.equal(await text('#bus-view h1'), 'Dashboard');
// Tiles: needs you, sessions, open tasks, projects, and an honest line for spend.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile)'), ['needs', 'sessions', 'tasks', 'projects', 'spend']);
assert.match(await text('[data-ws-tile=needs]'), /Needs you\s*2\s*items/i);
assert.match(await text('[data-ws-tile=projects]'), /Projects\s*4\s*3 active/i);
assert.match(await text('[data-ws-tile=spend]'), /No spend figures yet/);
assert.doesNotMatch(await text('[data-ws-tile=spend]'), /\$|\d/);
// Organization cards: two running with their read, the stopped one with its state and the time it was read.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgcard]")].map(e => [e.dataset.wsOrgcard, e.dataset.state])'), [['acme', 'ok'], ['beta', 'ok'], ['gamma', 'down']]);
assert.match(await text('[data-ws-orgcard=gamma]'), /Gamma Home/);
assert.match(await text('[data-ws-orgcard=gamma]'), /down/);
assert.match(await text('[data-ws-orgcard=gamma]'), /stopped by the owner/);
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").getAttribute("href")'), '#/orgs/beta/organization');
assert.match(await text('[data-ws-home] .sub'), /2 of 3 organizations answered/);
assert.match(await text('[data-ws-home] .sub'), /Gamma Home down, read \d\d:\d\d/);
// Recent project activity across both running organizations, newest first, each linking into its org.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-recent] a")].map(a => a.getAttribute("href"))'), ['#/orgs/acme/projects/p-site', '#/orgs/beta/projects/p-shop', '#/orgs/acme/projects/p-zeta']);
assert.match(await text('[data-ws-states]'), /Doing\s*1/);
assert.match(await text('[data-ws-states]'), /Done this week\s*0/);
// Needs you lists both organizations' decisions, newest raised first as the read orders them.
const needs = await b.evaluate('[...document.querySelectorAll("[data-ws-needs] a.q")].map(a => [a.textContent, a.getAttribute("href")])');
assert.equal(needs.length, 2);
assert.ok(needs.some(([q, h]) => q === 'Ship the hero?' && h === `#/orgs/acme/inbox?d=${encodeURIComponent(`acme:${ACME_DECISION}`)}`), JSON.stringify(needs));
assert.ok(needs.some(([q]) => q === 'Pick a launch price'));
// Sessions now: the working and the waiting role, each with its organization.
assert.match(await text('[data-ws-sessions]'), /coder/);
assert.match(await text('[data-ws-sessions]'), /pm/);
// The sidebar Inbox count follows the read across organizations.
assert.equal(await b.evaluate('document.querySelector("#sections a[href=\'#/inbox\'] .ws-n")?.textContent'), '2');
// The per-organization board moved to #/board; the processing link goes there.
assert.equal(await b.evaluate('document.getElementById("processing-link").getAttribute("href")'), '#/board');
});
test('Projects sorts and filters by each key, remembers the grouping, and rows open the project in its organization', async t => {
const { b, wait, text } = await setup(t, { hash: '/projects' });
const names = 'JSON.stringify([...document.querySelectorAll("[data-ws-project]")].map(r => r.dataset.wsProject))';
const settle = async (hash, expected) => { await wait(`location.hash === ${JSON.stringify(hash)} && ${names} === ${JSON.stringify(JSON.stringify(expected))}`); };
await wait('document.querySelector("[data-ws-projects] [data-ws-table]")');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Projects');
// Default: active only, newest activity first, grouped by organization with the stopped one marked.
assert.equal(await b.evaluate(names), JSON.stringify(['acme:p-site', 'acme:p-zeta', 'beta:p-shop']));
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-group-org]")].map(r => r.dataset.wsGroupOrg)'), ['acme', 'beta', 'gamma']);
assert.match(await text('[data-ws-table] tr.ws-unread'), /Not read.*the bus/);
// Rows open the existing project page in their own organization.
assert.equal(await b.evaluate('document.querySelector("[data-ws-project=\'beta:p-shop\'] a.nm").getAttribute("href")'), '#/orgs/beta/projects/p-shop');
// Sort by name (ascending), then again to reverse it.
await b.evaluate('document.querySelector("[data-ws-sort=name]").click()');
await b.evaluate('document.querySelector("[data-ws-group=flat]").click()');
await settle('#/orgs/acme/projects?sort=name', ['beta:p-shop', 'acme:p-site', 'acme:p-zeta']);
assert.equal(await b.evaluate('document.querySelector("[data-ws-table] th[aria-sort]").textContent.trim()'), 'Project↑');
await b.evaluate('document.querySelector("[data-ws-sort=name]").click()');
await settle('#/orgs/acme/projects?sort=name&order=desc', ['acme:p-zeta', 'acme:p-site', 'beta:p-shop']);
// Sort by organization and by status.
await b.evaluate('document.querySelector("[data-ws-sort=org]").click()');
await wait('location.hash === "#/orgs/acme/projects?sort=org" && document.querySelector("[data-ws-sort=org]").getAttribute("aria-pressed") === "true"');
assert.deepEqual(JSON.parse(await b.evaluate(names)).map(k => k.split(':')[0]), ['acme', 'acme', 'beta']);
await b.evaluate('document.querySelector("[data-ws-sort=status]").click()');
await wait('location.hash === "#/orgs/acme/projects?sort=status" && document.querySelector("[data-ws-sort=status]").getAttribute("aria-pressed") === "true"');
// Filter by status: archived and both.
await b.evaluate('(() => { const s = document.querySelector("[data-ws-status]"); s.value = "archived"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await settle('#/orgs/acme/projects?sort=status&status=archived', ['acme:p-old']);
await b.evaluate('(() => { const s = document.querySelector("[data-ws-status]"); s.value = "active,archived"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-ws-project]").length === 4');
// Filter by session state: only Website has a decision waiting.
await b.evaluate('(() => { const s = document.querySelector("[data-ws-state]"); s.value = "waiting"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait(`${names} === ${JSON.stringify(JSON.stringify(['acme:p-site']))}`);
await b.evaluate('(() => { const s = document.querySelector("[data-ws-state]"); s.value = ""; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-ws-project]").length === 4');
// Filter by organization chip, then back to all.
await b.evaluate('document.querySelector("[data-ws-org=beta]").click()');
await wait(`${names} === ${JSON.stringify(JSON.stringify(['beta:p-shop']))} && document.querySelector("[data-ws-org=beta]").getAttribute("aria-pressed") === "true"`);
assert.match(location(await b.evaluate('location.hash')), /org=beta/);
await b.evaluate('document.querySelector("[data-ws-org=\'\']").click()');
await wait('document.querySelectorAll("[data-ws-project]").length === 4');
// Filter by name: typing keeps focus and the caret in the box while the list narrows.
await b.evaluate('(() => { const i = document.querySelector("[data-ws-name]"); i.focus(); i.value = "zet"; i.dispatchEvent(new Event("input", { bubbles: true })); })()');
await wait(`${names} === ${JSON.stringify(JSON.stringify(['acme:p-zeta']))}`);
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement.value, document.activeElement.selectionStart]'), ['proj-name', 'zet', 3]);
// Flat mode shows an Organization column and names the stopped organization; the choice is the
// sidebar's remembered one and survives a reload.
assert.equal(await b.evaluate('document.querySelector("[data-ws-table]").dataset.grouping'), 'flat');
assert.match(await text('[data-ws-unread]'), /Gamma Home down/);
assert.equal(await b.evaluate('document.querySelector("#workspace-nav [data-group=flat]").getAttribute("aria-pressed")'), 'true');
assert.equal(await b.evaluate('localStorage.getItem("mosaic-workspace-project-grouping-v1")'), 'flat');
await b.evaluate('location.reload()');
await wait('document.querySelector("[data-ws-table]")?.dataset.grouping === "flat" && document.querySelectorAll("[data-ws-project]").length === 1 && document.querySelector("#workspace-nav [data-group=flat]")?.getAttribute("aria-pressed") === "true"');
// The sidebar's toggle switches this page back.
// The nav repaints after async reads, so the click retries until the page switches.
await wait('(document.querySelector("#workspace-nav [data-group=org]")?.click(), document.querySelector("[data-ws-table]")?.dataset.grouping === "org")');
await wait('document.querySelector("[data-ws-table]")?.dataset.grouping === "org" && document.querySelector("[data-ws-group=org]").getAttribute("aria-pressed") === "true"');
});
const location = hash => decodeURIComponent(hash);
test('Inbox lists both organizations, copies a command with --org and the full id, and deciding needs human proof', async t => {
const { b, base, wait, text } = await setup(t, { hash: '/inbox' });
await wait('document.querySelectorAll("[data-ws-item]").length === 2 && document.querySelector("[data-ws-insp]")');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Inbox');
assert.equal(await b.evaluate('document.querySelector("[data-not-built]")'), null);
const items = await b.evaluate('[...document.querySelectorAll("[data-ws-item]")].map(r => [r.dataset.wsItem, r.innerText])');
assert.deepEqual(items.map(([k]) => k).sort(), [`acme:${ACME_DECISION}`, `beta:${BETA_DECISION}`]);
// Each row names its organization, project and session.
const acme = items.find(([k]) => k.startsWith('acme'))[1];
assert.match(acme, /Acme Studio/); assert.match(acme, /Website/); assert.match(acme, /coder/); assert.match(acme, /gated/);
const beta = items.find(([k]) => k.startsWith('beta'))[1];
assert.match(beta, /Beta Works/); assert.match(beta, /No project/); assert.match(beta, /pm/);
assert.match(await text('[data-ws-unread]'), /Gamma Home down/);
// Tabs the data can't back say so.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-inbox] .ws-seg button")].map(x => [x.textContent, x.disabled])'), [['Needs you', false], ['Seen', true], ['Answered', true]]);
assert.match(await text('[data-ws-inbox]'), /not built yet/);
// Select the acme decision; the inspector shows it, and the selection stays in the hash.
// A pointer click focuses the link before it activates; .click() alone does not.
await b.evaluate(`(() => { const a = document.querySelector('[data-ws-select="acme:${ACME_DECISION}"]'); a.focus(); a.click(); })()`);
await wait(`document.querySelector("[data-ws-insp]")?.dataset.wsInsp === ${JSON.stringify(`acme:${ACME_DECISION}`)}`);
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), `item:acme:${ACME_DECISION}`);
const cmds = await b.evaluate('[...document.querySelectorAll("[data-ws-cmds] [data-copy]")].map(x => [x.dataset.copy, x.previousElementSibling.tagName, x.previousElementSibling.textContent])');
assert.deepEqual(cmds, [[`mosaic decide ${ACME_DECISION} yes --org acme`, 'CODE', `mosaic decide ${ACME_DECISION} yes --org acme`], [`mosaic decide ${ACME_DECISION} no --org acme`, 'CODE', `mosaic decide ${ACME_DECISION} no --org acme`]]);
// Copy puts the full command on the clipboard (or selects it when there is none) and says so.
await b.evaluate('document.querySelector("[data-ws-cmds] [data-copy]").click()');
await wait('document.getElementById("copy-status")?.textContent.length > 0');
const status = await text('#copy-status');
assert.ok(status.includes(`mosaic decide ${ACME_DECISION} yes --org acme`) || /clipboard is unavailable/.test(status), status);
// An answer opens the existing per-organization decision page.
assert.equal(await b.evaluate('document.querySelector("[data-ws-insp] .ws-opts a").getAttribute("href")'), `#/orgs/acme/inbox/${ACME_DECISION}`);
// Without a signed-in human session and its proof, the per-organization decide path refuses.
const refused = await b.evaluate(`(async () => { const r = await fetch('/api/orgs/acme/decisions/${ACME_DECISION}/confirmation', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'yes' }) }); return [r.status, (await r.json().catch(() => ({}))).error ?? null]; })()`);
assert.ok(refused[0] >= 400 && refused[0] < 500, JSON.stringify(refused));
// The decision page itself offers no answer controls without that proof.
await b.navigate(`${base}/#/orgs/acme/inbox/${ACME_DECISION}`);
await wait('[...document.querySelectorAll("#bus-view h1")].length > 0 && !document.querySelector("#bus-view .loading")');
assert.equal(await b.evaluate('document.querySelector("#decide-section [data-decide]")'), null);
// Filter to one organization.
await b.navigate(`${base}/#/orgs/acme/inbox`);
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
await b.evaluate('document.querySelector("[data-ws-org=beta]").click()');
await wait(`document.querySelectorAll("[data-ws-item]").length === 1 && document.querySelector("[data-ws-item]").dataset.wsItem === ${JSON.stringify(`beta:${BETA_DECISION}`)}`);
});
test('A poll repaints in place: focus, caret and scroll stay where they were', async t => {
const { b, wait } = await setup(t, { hash: '/projects', viewport: [1000, 420] });
await wait('document.querySelector("[data-ws-table]") && document.querySelectorAll("[data-ws-project]").length === 3');
assert.ok(await b.evaluate('window.MosaicWorkspace.pollMs') >= 1000);
// Focus a row link, scroll the page, and repaint as a poll does.
await b.evaluate('document.querySelector("[data-ws-project=\'beta:p-shop\'] a.nm").focus({ preventScroll: true }); (document.scrollingElement.scrollTop = 120); document.getElementById("main").scrollTop = 120;');
const before = await b.evaluate('[document.scrollingElement.scrollTop, document.getElementById("main").scrollTop]');
assert.ok(before.some(v => v > 0), `nothing scrolled: ${before}`);
await b.evaluate('window.__marker = document.querySelector("[data-ws-table]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait('document.querySelector("[data-ws-table]") !== window.__marker');
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'prow:beta:p-shop');
assert.deepEqual(await b.evaluate('[document.scrollingElement.scrollTop, document.getElementById("main").scrollTop]'), before);
// The search box keeps its caret across a repaint.
await b.evaluate('(() => { const i = document.querySelector("[data-ws-name]"); i.focus(); i.value = "shop"; i.dispatchEvent(new Event("input", { bubbles: true })); })()');
await wait('location.hash.includes("name=shop") && document.querySelectorAll("[data-ws-project]").length === 1 && document.activeElement?.dataset.focusKey === "proj-name"');
await b.evaluate('document.activeElement.setSelectionRange(2, 2)');
await b.evaluate('window.__marker = document.querySelector("[data-ws-table]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait('document.querySelector("[data-ws-table]") !== window.__marker');
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement.selectionStart]'), ['proj-name', 2]);
// The Dashboard and the Inbox repaint the same way.
await b.evaluate('location.hash = "#/orgs/acme/"');
await wait('document.querySelectorAll("[data-ws-orgcard]").length === 3');
await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").focus()');
await b.evaluate('window.__marker = document.querySelector("[data-ws-orgs]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait('document.querySelector("[data-ws-orgs]") !== window.__marker');
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'orgcard:beta');
// No loading view replaced the page during that repaint.
assert.equal(await b.evaluate('document.querySelector("#bus-view .loading")'), null);
});
test('Phone width: no horizontal scroll on any of the three pages, and the drawer opens and closes', async t => {
const { b, wait } = await setup(t, { viewport: [390, 844] });
for (const [hash, ready] of [['/', '[data-ws-orgcard]'], ['/projects', '[data-ws-project]'], ['/inbox', '[data-ws-item]']]) {
await b.evaluate(`location.hash = "#/orgs/acme${hash}"`);
await wait(`document.querySelector(${JSON.stringify(ready)})`);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, hash);
}
await b.evaluate('document.getElementById("nav-open").click()');
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "true"');
assert.equal(await b.evaluate('getComputedStyle(document.getElementById("console-nav")).visibility !== "hidden" && document.getElementById("console-nav").getBoundingClientRect().right > 0'), true);
await b.evaluate('document.getElementById("nav-close").click()');
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "false"');
// A sidebar link closes the drawer and opens its page.
await b.evaluate('document.getElementById("nav-open").click()');
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "true"');
await b.evaluate('document.querySelector("#sections a[href=\'#/projects\']").click()');
await wait('location.hash === "#/orgs/acme/projects" && document.querySelector("[data-ws-project]")');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
// An inbox row at phone width puts its source on its own line above the question.
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
await wait('document.querySelector("[data-ws-item] .ws-msrc")');
assert.equal(await b.evaluate(`(() => { const r = document.querySelector('[data-ws-item]'), s = r.querySelector('.ws-msrc').getBoundingClientRect(), q = r.querySelector('a.q').getBoundingClientRect(); return s.height > 0 && s.bottom <= q.top + 1; })()`), true, 'source above question');
});
test('A read that an answering organization could not make says not read, never zero', async t => {
// beta answers, but its project and inbox reads refuse.
const { b, wait, text } = await setup(t, { fail: { beta: ['projects', 'inbox'] } });
await wait('document.querySelector("[data-ws-tiles]")');
assert.match(await text('[data-ws-tile=projects] [data-ws-miss=projects]'), /Not read in Beta Works \(the chat channel is off\)/);
assert.match(await text('[data-ws-tile=needs] [data-ws-miss=needs]'), /Not read in Beta Works/);
assert.match(await text('[data-ws-miss=recent]'), /Not read in Beta Works/);
assert.match(await text('[data-ws-miss=needs]:not([data-ws-tile] *)'), /Not read in Beta Works/);
assert.doesNotMatch(await text('.ws-home .sub'), /and \d+ projects?/, 'no project total when a project read failed');
// Projects, grouped: beta's group says why and shows no count or "No projects match".
await b.evaluate('location.hash = "#/orgs/acme/projects"');
await wait('document.querySelector("[data-ws-group-org=beta]")');
assert.equal(await b.evaluate(`document.querySelector('[data-ws-group-org=beta] .ws-n')`), null);
const betaRow = await b.evaluate(`document.querySelector('[data-ws-group-org=beta]').nextElementSibling.innerText`);
assert.match(betaRow, /Projects not read: the chat channel is off/);
assert.match(await text('[data-ws-org=beta] .n'), /not read/i);
// Flat: the note names it.
await b.evaluate('document.querySelector("[data-ws-group=flat]").click()');
await wait('document.querySelector("[data-ws-miss=projects]")');
assert.match(await text('[data-ws-miss=projects]'), /Not read in Beta Works/);
// Inbox: the note names it, and the chip carries no count.
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
await wait('document.querySelector("[data-ws-miss=inbox]")');
assert.match(await text('[data-ws-miss=inbox]'), /Not read in Beta Works \(the chat channel is off\)/);
assert.match(await text('[data-ws-org=beta] .n'), /not read/i);
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-item]").length'), 1);
});
test('When every answering organization failed a read, its tile says Not read instead of a number', async t => {
const { wait, text } = await setup(t, { fail: { acme: ['projects'], beta: ['projects'] } });
await wait('document.querySelector("[data-ws-tiles]")');
assert.match(await text('[data-ws-tile=projects]'), /Not read/i);
assert.equal(await text('[data-ws-tile=projects] .ws-big'), null);
});
// Review P2 (org-u3): a project row's figures come from reads beyond the project list.
test('Project rows and per-organization figures say not read when the reads behind them failed', async t => {
const { b, wait, text } = await setup(t, { fail: { beta: ['inbox', 'tasks', 'agents', 'claims', 'threads'] }, hash: '/projects' });
await wait('document.querySelector("[data-ws-project=\'beta:p-shop\']")');
const cells = await b.evaluate(`[...document.querySelector("[data-ws-project='beta:p-shop']").cells].map(c => c.innerText.trim())`);
// Grouped columns: Project, Status, Sessions, Open tasks, Needs you, Last activity, Git.
assert.deepEqual(cells.slice(2, 5), ['not read', 'not read', 'not read'], 'sessions, open tasks and needs you');
assert.match(cells[5], /some reads missing/);
const acme = await b.evaluate(`[...document.querySelector("[data-ws-project='acme:p-site']").cells].map(c => c.innerText.trim())`);
assert.equal(acme[3], '1'); assert.equal(acme[4], '1');
assert.match(await text('[data-ws-miss=figures]'), /Some figures not read in Beta Works/);
// Dashboard: the per-organization figures leave Beta out instead of showing its 0.
await b.evaluate('location.hash = "#/orgs/acme/"');
await wait('document.querySelector("[data-ws-tiles]")');
for (const tile of ['needs', 'sessions', 'tasks']) {
const by = await b.evaluate(`document.querySelector('[data-ws-tile=${tile}] .ws-by')?.textContent ?? ''`);
assert.doesNotMatch(by, /Beta Works/, tile);
assert.match(await text(`[data-ws-tile=${tile}] [data-ws-miss=${tile}]`), /Not read in Beta Works/, tile);
}
assert.doesNotMatch(await b.evaluate(`[...document.querySelectorAll('[data-ws-states] .ws-srow')].find(r => /Done this week/.test(r.textContent))?.querySelector('.ws-by')?.textContent ?? ''`), /Beta Works/);
});
test('With every organization down the dashboard and inbox show no totals and no empty-list claims', async t => {
const { b, wait, text } = await setup(t, { down: ['acme', 'beta'] });
await wait('document.querySelector("[data-ws-tiles]")');
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-home] .ws-big").length'), 0);
assert.match(await text('[data-ws-tiles]'), /No organization answered/);
assert.equal(await text('[data-ws-needs]'), 'Decisions not read');
assert.equal(await text('[data-ws-states]'), 'Tasks not read');
assert.equal(await text('[data-ws-recent]'), 'Projects not read');
assert.equal(await text('[data-ws-sessions]'), 'Sessions not read');
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
await wait('document.querySelector("[data-ws-items]")');
assert.equal(await text('[data-ws-items]'), 'Decisions not read');
await b.evaluate('location.hash = "#/orgs/acme/projects?grouping=flat"');
await wait('document.querySelector("[data-ws-table]")');
await b.evaluate('document.querySelector("[data-ws-group=flat]").click()');
await wait('document.querySelector("[data-ws-table][data-grouping=flat]")');
assert.match(await text('[data-ws-table] tbody'), /Projects not read/);
});
test('When no organization could read its tasks, Tasks by state shows no Done this week figure', async t => {
const { wait, text } = await setup(t, { fail: { acme: ['tasks'], beta: ['tasks'] } });
await wait('document.querySelector("[data-ws-states]")');
assert.equal(await text('[data-ws-states]'), 'Tasks not read');
assert.match(await text('[data-ws-tile=tasks]'), /Not read/);
});
// Review P3: the session's organization is in the accessible text, not only in the hidden avatar.
test('Sessions now names each session\'s organization for a screen reader', async t => {
const { b, wait } = await setup(t);
await wait('document.querySelector("[data-ws-sessions] td.who")');
const who = await b.evaluate('[...document.querySelectorAll("[data-ws-sessions] td.who")].map(c => c.textContent)');
assert.ok(who.some(w => /Acme Studio: coder/.test(w)), who.join('|'));
assert.ok(who.some(w => /Beta Works: pm/.test(w)), who.join('|'));
});
@@ -0,0 +1,383 @@
// U4b (#1873, PRD 1.20): the task board's server side. The write route POST /api/workspace/tasks
// (strict body, the hook fence, owner and --send, one org per write, the bus's refusals), the task
// rows the cross-organization read adds, and a Vikunja organization's tasks read through the real
// tasks adapter from a fake Vikunja listening on port 0. Every bus here is a real Broker over a Store
// in this test's own scratch, or a fake options object; no test reaches a live bus or a real Vikunja.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { storeAdapter } from '../../tasks/src/store.mjs';
import { FakeVikunja } from '../../tasks/src/fake.mjs';
import { world, sleep } from '../../tasks/tests/world.mjs';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { actionTrail } from '../src/action-trail.mjs';
import { parseTaskWrite, TASK_WRITE_ROUTE } from '../src/workspace-tasks.mjs';
import { parseWorkspaceQuery, readWorkspace, WorkspaceQueryError, TASKS_PER_ORG } from '../src/workspace-read.mjs';
import { close } from './fixture.mjs';
const DIGEST = /^[0-9a-f]{64}$/;
const D = n => String(n).repeat(64);
const scratch = (t, prefix) => { const root = mkdtempSync(join(tmpdir(), prefix)); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
function call(target, { method = 'GET', path, headers = {}, body }) {
return new Promise((resolve, reject) => {
const all = { ...headers };
for (const k of Object.keys(all)) if (all[k] === null) delete all[k];
if (body !== undefined) all['content-length'] = Buffer.byteLength(body);
const req = request({ ...target, method, path, headers: all }, res => {
let raw = '';
res.on('data', c => { raw += c; });
res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch { /* not JSON */ } resolve({ status: res.statusCode, body: json }); });
});
req.on('error', reject);
req.end(body);
});
}
// One organization's bus: a real Broker over its own Store. `taskTracker` names the module the bus
// runtime chose (a Vikunja org's is not the store). `human` is the person's cap, `agent` a role's.
const AUTHORITY = { withinRole: ['task.update.assigned', 'message.send'], crossRole: [] };
async function bus(t, id, { taskTracker = null } = {}) {
const root = scratch(t, 'tb-');
const businesses = { [id]: { id, human: 'jason', arbiters: { technical: 'reviewer', delivery: 'coder' }, roles: { coder: { definition: 'coder', authority: AUTHORITY }, reviewer: { definition: 'reviewer', authority: AUTHORITY } } } };
const store = new Store(root);
const broker = new Broker({ store, businesses, ...(taskTracker ? { taskTracker } : {}) });
const adapter = await storeAdapter()({ broker, businesses });
t.after(() => { adapter.close(); store.close(); });
const human = broker.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true });
const agent = broker.bindLaunch({ business: id, role: 'coder', run: 'coder-run', harness: 'pi' });
broker.request(agent, { verb: 'role.claim' });
const read = () => broker.request(broker.bindReader({ business: id }), { verb: 'tasks', args: {} });
return { broker, human, agent, read };
}
// The registry the route resolves through: `resolve(id, session)` admits an org the session belongs
// to and answers its own transport. Every resolve and every bus call is recorded by org.
async function registry(t) {
const forge = await bus(t, 'forge');
const ddigital = await bus(t, 'ddigital', { taskTracker: () => 'vikunja' });
const robot = await bus(t, 'robot');
const resolved = [], sent = [];
const writer = (org, b, cap) => async (verb, args) => { sent.push([org, verb, args]); return b.broker.request(cap, { verb, args }); };
const orgs = {
forge: { busHealthy: true, taskWrite: writer('forge', forge, forge.human) },
ddigital: { busHealthy: true, taskWrite: writer('ddigital', ddigital, ddigital.human) },
// A Console started by an agent: the bus sees no person and answers human-required.
robot: { busHealthy: true, taskWrite: writer('robot', robot, robot.agent) },
home: { busHealthy: false, taskWrite: async (...a) => { sent.push(['home', ...a]); return {}; } },
lost: { busHealthy: true, taskWrite: async (...a) => { sent.push(['lost', ...a]); throw new Error('socket closed'); } },
odd: { busHealthy: true, taskWrite: async (...a) => { sent.push(['odd', ...a]); throw Object.assign(new Error('x'), { code: 'brand-new-code' }); } },
quiet: { busHealthy: true, taskWrite: null },
};
const organizations = {
list: async () => [], members: async () => [],
async resolve(id, session) {
resolved.push([id, session?.sub ?? null]);
if (id === 'old') throw Object.assign(new Error('archived'), { code: 'org-archived' });
if (id === 'broken') throw new Error('/srv/x/org.json: Unexpected token');
return orgs[id] ?? null;
},
};
return { organizations, resolved, sent, forge, ddigital, robot };
}
async function serve(t, { organizations, send = true, sso = createDevOwner() }) {
const dir = join(scratch(t, 'tw-'), 'console');
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, organizations, log: () => {}, trail: actionTrail(dir), ...(send ? { send: async () => ({}) } : {}) });
t.after(() => close(server));
const port = server.address().port, host = `127.0.0.1:${port}`, origin = `http://${host}`;
const post = (body, headers = {}) => call({ host: '127.0.0.1', port }, { method: 'POST', path: TASK_WRITE_ROUTE, body: typeof body === 'string' ? body : JSON.stringify(body),
headers: { host, origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...headers } });
const trail = () => (existsSync(join(dir, 'actions.jsonl')) ? readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []);
return { post, trail, origin };
}
test('parseTaskWrite takes the three ops strictly and refuses before any bus', () => {
assert.deepEqual(parseTaskWrite({ op: 'create', org: 'forge', title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', state: 'todo', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' }),
{ op: 'create', org: 'forge', verb: 'tasks.create', args: { title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', state: 'todo', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' } });
assert.deepEqual(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), state: 'in-review' }),
{ op: 'edit', org: 'forge', verb: 'tasks.edit', args: { task_ref: 'task:4', expect: D(1), state: 'in-review' } });
assert.deepEqual(parseTaskWrite({ op: 'archive', org: 'forge', task_ref: 'task:4', expect: D(1) }), { op: 'archive', org: 'forge', verb: 'tasks.archive', args: { task_ref: 'task:4', expect: D(1) } });
assert.equal(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), due_date: null }).args.due_date, null);
const refused = [
[{ op: 'delete', org: 'forge', task_ref: 'task:1' }, /^op takes create, edit, archive$/],
[{ op: 'unarchive', org: 'forge', task_ref: 'task:1' }, /^op takes/],
[{ op: 'edit', org: 'Forge!', task_ref: 'task:1', title: 'x' }, /^org takes/],
[{ op: 'edit', task_ref: 'task:1', title: 'x' }, /^org takes/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', description: 'x' }, /^edit does not take description$/],
[{ op: 'archive', org: 'forge', task_ref: 'task:1', title: 'x' }, /^archive does not take title$/],
[{ op: 'create', org: 'forge', title: 'x' }, /^create needs requirement$/],
[{ op: 'edit', org: 'forge', title: 'x' }, /^edit needs task_ref$/],
// An edit or archive without the digest the board read is refused before any bus (no silent overwrite).
[{ op: 'edit', org: 'forge', task_ref: 'task:1', title: 'x' }, /^edit needs expect$/],
[{ op: 'archive', org: 'forge', task_ref: 'task:1' }, /^archive needs expect$/],
[{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(2) }, /^edit needs a field to change$/],
// A Vikunja task is never written from the board: its ref is refused here.
[{ op: 'edit', org: 'ddigital', expect: D(1), task_ref: 'vikunja:3/112', title: 'x' }, /^task_ref is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:0', title: 'x' }, /^task_ref is not valid$/],
[{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: 'abc', title: 'x' }, /^expect is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: ' ' }, /^title is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: 'x'.repeat(251) }, /^title is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', state: 'doing' }, /^state is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 6 }, /^priority is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 1.5 }, /^priority is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: ['coder', 'bad name'] }, /^assignees is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: 'coder' }, /^assignees is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-11-20' }, /^due_date is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-02-30T00:00:00.000Z' }, /^due_date is not valid$/],
[{ op: 'create', org: 'forge', title: 'x', requirement: 'req-1' }, /^requirement is not valid$/],
[{ op: 'create', org: 'forge', title: 'x', requirement: 'REQ-TASK-1', group: 'a\nb' }, /^group is not valid$/],
];
for (const [body, message] of refused) assert.throws(() => parseTaskWrite(body), e => e.status === 400 && message.test(e.message), JSON.stringify(body));
});
test('route: the fence, the owner and --send come before any org is resolved', async t => {
const r = await registry(t);
const { post } = await serve(t, { organizations: r.organizations });
const body = { op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(1), title: 'x' };
const fenced = [
[{ origin: null }, 'cross-origin request refused'],
[{ origin: 'http://evil.test' }, 'cross-origin request refused'],
[{ 'sec-fetch-site': null }, 'cross-site request refused'],
[{ 'sec-fetch-site': 'cross-site' }, 'cross-site request refused'],
[{ 'sec-fetch-site': 'same-site' }, 'cross-site request refused'],
];
for (const [headers, error] of fenced) {
const res = await post(body, headers);
assert.equal(res.status, 403, JSON.stringify(headers)); assert.equal(res.body.error, error);
}
// Body checks: content type, size and shape, each with sent:false.
const bad = [
[await post(JSON.stringify(body), { 'content-type': 'text/plain' }), 415],
[await post(JSON.stringify({ ...body, title: 'x'.repeat(17000) })), 413],
[await post('{not json'), 400],
[await post('[1]'), 400],
[await post({ ...body, extra: 1 }), 400],
];
for (const [res, status] of bad) { assert.equal(res.status, status); assert.equal(res.body.error, 'invalid-request'); assert.equal(res.body.sent, false); }
assert.deepEqual(r.resolved, []); assert.deepEqual(r.sent, []);
// Without --send the Console is read only.
const ro = await serve(t, { organizations: r.organizations, send: false });
const refused = await ro.post(body);
assert.equal(refused.status, 403); assert.equal(refused.body.error, 'read-only');
// A granted user's session is fenced as owner-only before the handler.
const granted = { ...createDevOwner(), session: () => ({ provider: 'local-dev', iss: 'local-dev', sub: 'alice-sub', user: 'alice', name: 'Alice' }) };
const user = await serve(t, { organizations: r.organizations, sso: granted });
const other = await user.post(body);
assert.equal(other.status, 403); assert.equal(other.body.error, 'owner-only');
assert.deepEqual(r.resolved, []); assert.deepEqual(r.sent, []);
});
test('route: each write reaches only the org it names, through that org\'s real bus, with the person\'s verbs', async t => {
const r = await registry(t);
const { post, trail } = await serve(t, { organizations: r.organizations });
const created = await post({ op: 'create', org: 'forge', title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', assignees: ['coder'], priority: 2 });
assert.equal(created.status, 200, JSON.stringify(created.body));
assert.equal(created.body.ok, true); assert.equal(created.body.org, 'forge'); assert.equal(created.body.op, 'create');
assert.equal(created.body.task_ref, 'task:1'); assert.match(created.body.digest, DIGEST);
assert.deepEqual(r.resolved, [['forge', 'local-dev-owner']]);
assert.deepEqual(r.sent.map(([org, verb]) => [org, verb]), [['forge', 'tasks.create']]);
const [task] = r.forge.read();
assert.equal(task.task_ref, 'task:1'); assert.equal(task.source, 'store'); assert.equal(task.fields.title, 'Rate limits');
assert.equal(task.digest, created.body.digest);
// No other org's bus holds anything.
assert.deepEqual(r.robot.read(), []);
// An edit with the digest the board read is written; a stale one is refused and changes nothing.
const moved = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: created.body.digest, state: 'in-review' });
assert.equal(moved.status, 200); assert.notEqual(moved.body.digest, created.body.digest);
assert.equal(r.forge.read()[0].fields.state, 'in-review');
const stale = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: created.body.digest, title: 'Lost update' });
assert.equal(stale.status, 409); assert.equal(stale.body.error, 'task-conflict'); assert.equal(stale.body.sent, false);
assert.match(stale.body.message, /Nothing was written/);
assert.equal(r.forge.read()[0].fields.title, 'Rate limits');
const missing = await post({ op: 'edit', org: 'forge', task_ref: 'task:9', expect: D(1), title: 'x' });
assert.equal(missing.status, 404); assert.equal(missing.body.error, 'task-not-found');
const role = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: moved.body.digest, assignees: ['nobody'] });
assert.equal(role.status, 400); assert.equal(role.body.error, 'unknown-role');
const archived = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: moved.body.digest });
assert.equal(archived.status, 200);
assert.deepEqual(r.forge.read(), [], 'an archived task leaves the read');
const again = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: archived.body.digest });
assert.equal(again.status, 409); assert.equal(again.body.error, 'task-already-archived');
// A Vikunja organization's bus refuses the store verbs; the board never writes to Vikunja.
const vik = await post({ op: 'create', org: 'ddigital', title: 'Launch post', requirement: 'REQ-TASK-1' });
assert.equal(vik.status, 409); assert.equal(vik.body.error, 'tracker-not-store'); assert.equal(vik.body.sent, false);
assert.match(vik.body.message, /keeps its tasks in Vikunja/);
assert.deepEqual(r.ddigital.read(), []);
// A Console started by an agent: the bus finds no person, so nothing is written.
const robot = await post({ op: 'create', org: 'robot', title: 'Sneaky', requirement: 'REQ-TASK-1' });
assert.equal(robot.status, 403); assert.equal(robot.body.error, 'human-required');
assert.match(robot.body.message, /only from a person/);
assert.deepEqual(r.robot.read(), []);
// An org outside the session, an archived org, an unreadable registry, a down bus, a Console
// without the write: each is refused before or without a bus call.
const before = r.sent.length;
const outside = await post({ op: 'edit', org: 'acme', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(outside.status, 404); assert.deepEqual(outside.body, { error: 'org-unavailable', sent: false });
const old = await post({ op: 'edit', org: 'old', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(old.status, 409); assert.equal(old.body.error, 'org-archived');
const broken = await post({ op: 'edit', org: 'broken', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(broken.status, 404); assert.deepEqual(broken.body, { error: 'org-unavailable', sent: false });
const down = await post({ op: 'edit', org: 'home', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(down.status, 503); assert.equal(down.body.error, 'no-bus-host'); assert.equal(down.body.sent, false);
const quiet = await post({ op: 'edit', org: 'quiet', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(quiet.status, 403); assert.equal(quiet.body.error, 'read-only');
assert.equal(r.sent.length, before);
// A lost answer is not a refusal: the change may have been written, and the person is told so.
const lost = await post({ op: 'edit', org: 'lost', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(lost.status, 503); assert.equal(lost.body.error, 'outcome-unknown'); assert.equal(lost.body.sent, 'unknown');
const odd = await post({ op: 'edit', org: 'odd', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(odd.status, 502); assert.equal(odd.body.error, 'brand-new-code'); assert.equal(odd.body.sent, false);
// Every resolve named exactly the org in the body, and each bus call went to that org.
for (const [org, verb] of r.sent) assert.ok(['tasks.create', 'tasks.edit', 'tasks.archive'].includes(verb), `${org} ${verb}`);
assert.deepEqual([...new Set(r.resolved.map(([id]) => id))], ['forge', 'ddigital', 'robot', 'acme', 'old', 'broken', 'home', 'quiet', 'lost', 'odd']);
// The action trail notes each outcome with who, which org and which verb.
const notes = trail().map(e => [e.org, e.action, e.task ?? null, e.outcome]);
assert.deepEqual(notes, [
['forge', 'tasks.create', null, 'written'],
['forge', 'tasks.edit', 'task:1', 'written'],
['forge', 'tasks.edit', 'task:1', 'refused task-conflict'],
['forge', 'tasks.edit', 'task:9', 'refused task-not-found'],
['forge', 'tasks.edit', 'task:1', 'refused unknown-role'],
['forge', 'tasks.archive', 'task:1', 'written'],
['forge', 'tasks.archive', 'task:1', 'refused task-already-archived'],
['ddigital', 'tasks.create', null, 'refused tracker-not-store'],
['robot', 'tasks.create', null, 'refused human-required'],
['lost', 'tasks.edit', 'task:1', 'unknown'],
['odd', 'tasks.edit', 'task:1', 'refused brand-new-code'],
]);
assert.ok(trail().every(e => e.by?.sub === 'local-dev-owner' && e.by?.provider === 'local-dev'));
});
// One org's reads as the workspace read sees them (options().bus and options().chat).
function member(id, data, doc = {}) {
const value = name => Promise.resolve(data[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
return {
id, doc, status: async () => ({ healthy: true, pid: 1, boot: null }),
options: () => ({
bus: { inbox: () => value('inbox'), agents: () => value('agents'), 'thread.claims': () => value('claims'), tasks: () => value('tasks'), usage: () => value('usage'), limits: () => value('limits'), decisions: () => value('decisions') },
chat: { threads: () => value('threads'), projects: () => value('projects') },
}),
};
}
const SESSION = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
const readTasks = (members, search = 'sections=tasks') => readWorkspace({ organizations: { members: async () => members }, session: SESSION, query: parseWorkspaceQuery(search), now: () => Date.parse('2026-11-10T12:00:00.000Z') });
test('read: a built-in store org\'s rows, from its real bus, carry state, project, roles and the digest a write sends back', async t => {
const forge = await bus(t, 'forge');
const p = (verb, args) => forge.broker.request(forge.human, { verb, args });
p('tasks.create', { title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' });
p('tasks.create', { title: 'Invoice PDF', requirement: 'REQ-TASK-1', group: 'billing', state: 'blocked' });
p('tasks.create', { title: 'Loose end', requirement: 'REQ-TASK-1' });
const gone = p('tasks.create', { title: 'Old idea', requirement: 'REQ-TASK-1' });
p('tasks.archive', { task_ref: gone.task_ref });
const projects = [{ id: 'p-api', repo: 'api-gateway', name: 'API gateway', archived: false, at: '2026-11-01T00:00:00.000Z' }, { id: 'p-bill', repo: 'billing', name: 'Billing', archived: false, at: '2026-11-01T00:00:00.000Z' }];
const body = await readTasks([member('forge', { tasks: forge.read(), projects }, { roles: { coder: {}, reviewer: {} } })]);
assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker]), [['forge', 'ok', 'built-in']]);
const rows = body.tasks.map(x => [x.ref, x.tracker, x.title, x.state, x.project?.key ?? null, x.group, x.assignees, x.priority, x.dueDate, x.writable, DIGEST.test(x.digest)]);
assert.deepEqual(rows, [
// A group matches a project by id or by key; a group naming neither has no project.
['task:1', 'built-in', 'Rate limits', 'todo', 'api-gateway', 'p-api', ['coder'], 3, '2026-11-20T00:00:00.000Z', true, true],
['task:2', 'built-in', 'Invoice PDF', 'blocked', 'billing', 'billing', [], 0, null, true, true],
['task:3', 'built-in', 'Loose end', 'todo', null, null, [], 0, null, true, true],
]);
// The digest is the bus's own, so an edit's `expect` matches the task the board shows.
assert.equal(body.tasks[0].digest, forge.read().find(x => x.task_ref === 'task:1').digest);
p('tasks.edit', { task_ref: 'task:1', expect: body.tasks[0].digest, state: 'done' });
const after = await readTasks([member('forge', { tasks: forge.read(), projects }, {})], 'sections=tasks&task-state=done');
assert.deepEqual(after.tasks.map(x => [x.ref, x.state, x.done]), [['task:1', 'done', true]]);
});
test('read: filters, the per-org cap, and rows the read refuses', async () => {
const store = (n, f = {}, extra = {}) => ({ task_ref: `task:${n}`, source: 'store', digest: D(n % 10), at: '2026-11-09T00:00:00.000Z', fields: { title: `T${n}`, state: 'todo', group: null, assignees: [], labels: [], ...f }, ...extra });
const tasks = [store(1, { state: 'in-review', assignees: ['coder'], labels: [4] }), store(2, { assignees: ['reviewer'] }), store(3, { state: 'nonsense' }), store(4, {}, { digest: 'not-a-digest' }), store(5, {}, { archived: true })];
const one = async search => (await readTasks([member('forge', { tasks })], `sections=tasks${search}`)).tasks.map(x => x.ref);
assert.deepEqual(await one(''), ['task:1', 'task:2', 'task:3', 'task:4']);
assert.deepEqual(await one('&task-state=in-review'), ['task:1']);
// An unknown state reads as null, which only `open` matches.
assert.deepEqual(await one('&task-state=open'), ['task:3']);
assert.deepEqual(await one('&assignee=reviewer,coder'), ['task:1', 'task:2']);
assert.deepEqual(await one('&label=4'), ['task:1']);
const all = (await readTasks([member('forge', { tasks })])).tasks;
assert.equal(all.find(x => x.ref === 'task:4').digest, null, 'a malformed digest is never sent back');
for (const search of ['task-state=doing', 'assignee=', 'assignee=a b', 'label=0', 'label=x', 'task-state=todo&task-state=done'])
assert.throws(() => parseWorkspaceQuery(search), WorkspaceQueryError, search);
// Past the cap the org keeps its TASKS_PER_ORG most recently changed rows and says how many
// matched; another org is untouched. Task n changed n minutes after midnight.
const minute = n => new Date(Date.parse('2026-11-09T00:00:00.000Z') + n * 60000).toISOString();
const many = Array.from({ length: TASKS_PER_ORG + 7 }, (_, i) => store(i + 1, i + 1 > TASKS_PER_ORG ? { group: 'late', state: 'blocked' } : { group: 'p1' }, { at: minute(i + 1), project_key: 'k' }));
const projects = [{ id: 'p1', repo: 'one', name: 'One', archived: false, at: minute(0) }, { id: 'late', repo: 'late', name: 'Late', archived: false, at: minute(0) }];
const big = [member('big', { tasks: many, projects }), member('small', { tasks: [store(1)] })];
const capped = await readTasks(big, 'sections=tasks,projects,metrics');
const bigRows = capped.tasks.filter(x => x.org === 'big');
assert.equal(bigRows.length, TASKS_PER_ORG);
assert.equal(capped.orgs.find(o => o.org === 'big').tasksHeld, TASKS_PER_ORG + 7);
assert.equal(capped.orgs.find(o => o.org === 'small').tasksHeld, undefined);
assert.equal(bigRows.some(x => x.ref === 'task:7'), false, 'the oldest changes are the ones cut');
assert.equal(bigRows.some(x => x.ref === 'task:8'), true);
// Store refs sort by number, so task:10 follows task:9.
assert.deepEqual(bigRows.slice(1, 4).map(x => x.ref), ['task:9', 'task:10', 'task:11']);
// Projects and metrics count every task the org holds, not only the ones the answer carries.
const proj = Object.fromEntries(capped.projects.filter(x => x.org === 'big').map(x => [x.id, x]));
assert.equal(proj.p1.openTasks, TASKS_PER_ORG); assert.equal(proj.late.openTasks, 7);
assert.equal(capped.metrics.orgs.find(m => m.org === 'big').openTasks, TASKS_PER_ORG + 7);
// A filter runs before the cap, so it reaches tasks past row TASKS_PER_ORG.
for (const search of ['sections=tasks&project=big:late', 'sections=tasks&task-state=blocked']) {
const filtered = await readTasks(big, search);
assert.deepEqual(filtered.tasks.map(x => x.ref), Array.from({ length: 7 }, (_, i) => `task:${TASKS_PER_ORG + i + 1}`), search);
assert.equal(filtered.orgs.find(o => o.org === 'big').tasksHeld, undefined, search);
}
});
test('read: a Vikunja org\'s tasks come through the real tasks adapter from a fake Vikunja on port 0', async t => {
const fake = new FakeVikunja();
const server = await fake.listen();
t.after(server.close);
assert.match(server.url, /^http:\/\/127\.0\.0\.1:\d+$/);
const w = await world(t, { fake, fetch: globalThis.fetch, tracker: { baseUrl: server.url } });
await w.adapter.start('demo');
assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused);
const pm = w.agent('pm');
const a = await w.call(pm, 'task.create', { title: 'Launch post', request: w.instruction(), requirement: 'REQ-TASK-1' });
const b = await w.call(pm, 'task.create', { title: 'Press kit', request: w.instruction(), requirement: 'REQ-TASK-1' });
const id = ref => Number(ref.slice(ref.indexOf('/') + 1));
// The owner works in the Vikunja UI; the adapter's poll picks it up.
assert.equal((await w.ui('POST', `/tasks/${id(a.task_ref)}/assignees`, { user_id: w.bots.coder })).status, 201);
assert.equal((await w.ui('POST', `/tasks/${id(a.task_ref)}/labels`, { label_id: w.label })).status, 201);
assert.equal((await w.ui('PATCH', `/tasks/${id(a.task_ref)}`, { due_date: '2026-11-20T00:00:00Z', percent_done: 0.5, description: 'not on the board' })).status, 200);
assert.equal((await w.ui('PATCH', `/tasks/${id(b.task_ref)}`, { done: true })).status, 200);
await sleep(20);
await w.adapter.tick('demo');
assert.ok(fake.requests.length > 0, 'the adapter read the fake over HTTP');
const rows = w.broker.request(w.human, { verb: 'tasks', args: {} });
const doc = w.businesses.demo;
const body = await readTasks([member('demo', { tasks: rows, projects: [] }, doc)]);
assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker]), [['demo', 'ok', 'vikunja']]);
const [x, y] = body.tasks;
// Mapped: ref, title, done (the only state the bus names), bot ids as roles, labels, priority,
// due date and the numeric bucket. Left out: description, percent_done, relations, comments.
assert.deepEqual({ ref: x.ref, tracker: x.tracker, title: x.title, state: x.state, done: x.done, assignees: x.assignees, labels: x.labels, dueDate: x.dueDate, writable: x.writable, digest: x.digest },
{ ref: a.task_ref, tracker: 'vikunja', title: 'Launch post', state: null, done: false, assignees: ['coder'], labels: [w.label], dueDate: '2026-11-20T00:00:00.000Z', writable: false, digest: null });
assert.ok(Number.isSafeInteger(x.bucket) && x.bucket > 0, 'the bucket is the board\'s numeric id');
assert.equal(x.requirement, 'REQ-TASK-1');
assert.deepEqual([y.ref, y.state, y.done], [b.task_ref, 'done', true]);
for (const key of ['description', 'percent_done', 'percentDone', 'relations', 'comments']) assert.equal(Object.hasOwn(x, key), false, key);
// The board's filters work on a Vikunja org too.
const open = await readTasks([member('demo', { tasks: rows, projects: [] }, doc)], 'sections=tasks&task-state=open&assignee=coder');
assert.deepEqual(open.tasks.map(t => t.ref), [a.task_ref]);
});