Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36b88ddacc | ||
|
|
e46913a9ce | ||
|
|
8308357549 |
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1442,12 +1442,6 @@
|
||||
// ---------------------------------------------------------------- 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();
|
||||
@@ -1457,7 +1451,7 @@
|
||||
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 window.MosaicWorkspace.inboxPage(q);
|
||||
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 === '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);
|
||||
|
||||
@@ -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(?:\?|$)|workspace(?:\?|$)|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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 });
|
||||
})();
|
||||
@@ -735,6 +735,8 @@
|
||||
clearTimeout(typing); typing = setTimeout(() => document.dispatchEvent(new CustomEvent('mosaic-workspace-render')), 250);
|
||||
});
|
||||
|
||||
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, refresh: () => refresh(), home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
|
||||
// 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();
|
||||
})();
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,8 +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.
|
||||
// Inbox is built (U3, workspace-pages-browser.test.mjs).
|
||||
for (const [route, title] of [['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,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]);
|
||||
});
|
||||
Reference in new issue
Block a user