diff --git a/packages/webui/src/public/tasks-board.js b/packages/webui/src/public/tasks-board.js
index dd0fec473..c6dd6f302 100644
--- a/packages/webui/src/public/tasks-board.js
+++ b/packages/webui/src/public/tasks-board.js
@@ -1,7 +1,9 @@
// 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. An org whose bus is down,
+// 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
@@ -28,7 +30,9 @@
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: '' };
- let snap = null, canWrite = false, notice = null, dragging = null, dragEnded = null;
+ // `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 };
@@ -40,7 +44,9 @@
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' ? 'Vikunja' : 'built-in');
const stateOf = t => (STATE_LABEL[t.state] ? t.state : UNREAD);
- const orgWritable = o => canWrite && o?.state === 'ok' && o.tracker === 'built-in';
+ // 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 => {
@@ -52,9 +58,18 @@
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?sections=tasks,projects', { cache: 'no-store', redirect: 'manual' }),
+ 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,
@@ -70,7 +85,10 @@
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 };
}
- const reload = async () => { snap = await read(); prune(); };
+ // 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 = '';
@@ -94,8 +112,8 @@
for (const t of snap.tasks) counts.set(t.org, (counts.get(t.org) ?? 0) + 1);
const chip = (id, inner, label) => ``;
const chips = [chip('', `All ${snap.tasks.length}`)].concat(snap.orgs.map(o => chip(o.id,
- `${av(o.id)}${esc(o.label)}${o.state === 'ok' ? counts.get(o.id) : 'unavailable'}`,
- `${o.label}, ${o.state === 'ok' ? `${counts.get(o.id)} tasks` : 'tasks unavailable'}`))).join('');
+ `${av(o.id)}${esc(o.label)}${tasksRead(o) ? counts.get(o.id) : 'unavailable'}`,
+ `${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 ? `` : ''; }).join('');
@@ -155,9 +173,13 @@
}
function results() {
const rows = filtered(), all = snap.tasks.length;
- const line = rows.length !== all ? `
Showing ${rows.length} of ${all} tasks.
` : '';
+ // 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 ? `
Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.
`
+ : n !== all ? `
Showing ${n} of ${all} tasks.
` : '';
const body = !snap.orgs.length ? '
No organizations yet.
'
- : !all ? `
${snap.summary?.read ? 'No tasks yet.' : 'No organization answered, so no tasks could be read.'}
`
+ : !all ? `
${snap.orgs.some(tasksRead) ? 'No tasks yet.' : 'No organization answered with its tasks, so no tasks could be read.'}
`;
@@ -166,9 +188,10 @@
// ---------- page ----------
function trackers() {
const items = snap.orgs.map(o => {
- const s = ORG_STATE[o.state];
- const st = s ? ` ${s[1]}${esc(s[2])}${o.reason ? `${esc(o.reason)}` : ''}` : '';
- const cut = o.tasksHeld ? `shows the first 500 of ${esc(o.tasksHeld)} tasks` : '';
+ 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 ? ` ${s[1]}${esc(s[2])}${why ? `${esc(why)}` : ''}` : '';
+ const cut = o.tasksHeld ? `shows the 500 most recently changed of ${esc(o.tasksHeld)} matching tasks` : '';
return `${av(o.id)}${esc(o.label)} ${o.tracker ? trk(o.tracker) : ''}${st}${cut}`;
}).join('');
return `
Trackers${items}Ids keep their tracker's form: V-12 is Vikunja, #41 is built in.
`;
@@ -181,7 +204,7 @@
const add = ``;
let sub = '';
if (snap) {
- const n = snap.tasks.length, m = snap.summary?.read ?? 0, of = snap.summary?.of ?? snap.orgs.length;
+ 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.';
@@ -204,12 +227,14 @@
scroll: root ? [...root.querySelectorAll('.tb-board,.tb-list')].map(e => [e.matches('.tb-board') ? 'board' : 'list', e.scrollLeft]) : [],
};
}
- function putBack(root, kept) {
+ // 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 (el && document.activeElement !== el) el.focus({ preventScroll: true });
- if (el && kept.caret && el.matches('[data-tb-q]')) el.setSelectionRange(kept.caret[0], kept.caret[1]);
+ 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') {
@@ -224,21 +249,28 @@
// 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))]);
- const kept = snapshotView(document.querySelector('[data-tasks-board]'));
try { await reload(); } catch (err) { snap = null; return failed(err); }
- setTimeout(() => putBack(document.querySelector('[data-tasks-board]'), kept), 0);
+ // 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 { ok: false, error: 'outcome-unknown', message: 'The Console did not answer. The change may or may not have been written; the board shows it on its next read.' }; }
+ } 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;
- return { ok: false, error: answer?.error || `http-${response.status}`, message: answer?.message || `The Console refused the change (${response.status}). Nothing was written.` };
+ // 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) {
@@ -246,7 +278,7 @@
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: `${shortId(t.ref)} was not moved. ${r.message}` };
+ 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();
}
@@ -258,7 +290,7 @@
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 = ''; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
+ 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;
}
@@ -306,9 +338,9 @@
`;
}
- function openDialog(html, back) {
+ function openDialog(html, back, task = null) {
const d = dialogEl();
- d.innerHTML = html; d.dataset.return = back || '';
+ 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();
}
@@ -335,7 +367,7 @@
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 = find(form.dataset.tbKey);
+ 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; }
@@ -349,9 +381,9 @@
if (r.error === 'task-conflict' && t) {
try { await reload(); } catch {}
const now = find(key(t));
- if (now) { const msg = r.message; openDialog(editForm(now), dialogEl().dataset.return); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, msg); }
+ 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;
@@ -361,7 +393,7 @@
paint();
}
async function archive(form) {
- const t = find(form.dataset.tbKey);
+ 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', '');
@@ -369,7 +401,7 @@
return;
}
const r = await write({ op: 'archive', org: t.org, task_ref: t.ref, expect: t.digest });
- if (!r.ok) { formStatus(form, r.message); return; }
+ 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 {}
@@ -389,9 +421,13 @@
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 = ''; paint(); return; }
+ 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); return; }
+ 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');
});
@@ -404,7 +440,7 @@
}
if (!inBoard(e) || !snap) return;
const f = e.target.dataset?.tbFilter;
- if (f) { filter[f] = e.target.value; paint(); return; }
+ 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 => {
diff --git a/packages/webui/src/workspace-read.mjs b/packages/webui/src/workspace-read.mjs
index fa2191ff7..ca0b1f4db 100644
--- a/packages/webui/src/workspace-read.mjs
+++ b/packages/webui/src/workspace-read.mjs
@@ -25,9 +25,11 @@ 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 snapshot. The bus's tasks read has no
-// cursor for the merged form, so the cap bounds the answer and the board's DOM; an org past it is
-// marked with how many it holds, and the board says the list is cut.
+// 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}$/;
@@ -208,10 +210,7 @@ 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 allTasks = arr(data.tasks).filter(t => t && typeof t.task_ref === 'string' && !t.fields?.gone && t.archived !== true);
- // U4b: past the cap, the org says how many it holds and the snapshot keeps the first TASKS_PER_ORG.
- if (allTasks.length > TASKS_PER_ORG) org.tasksHeld = allTasks.length;
- const taskRows = allTasks.slice(0, TASKS_PER_ORG);
+ 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 ?? {};
@@ -337,11 +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)))
+ 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))))
- .sort((a, b) => a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref, undefined, { numeric: true }));
+ && (!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;
diff --git a/packages/webui/src/workspace-tasks.mjs b/packages/webui/src/workspace-tasks.mjs
index 504493692..298784e4e 100644
--- a/packages/webui/src/workspace-tasks.mjs
+++ b/packages/webui/src/workspace-tasks.mjs
@@ -24,8 +24,10 @@ const DUE = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/;
// 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'] },
- edit: { verb: 'tasks.edit', keys: ['task_ref', 'expect', 'title', 'state', 'assignees', 'priority', 'due_date'], need: ['task_ref'] },
- archive: { verb: 'tasks.archive', keys: ['task_ref', 'expect'], need: ['task_ref'] },
+ // 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),
diff --git a/packages/webui/tests/browser.test.mjs b/packages/webui/tests/browser.test.mjs
index c270961d2..3c8137792 100644
--- a/packages/webui/tests/browser.test.mjs
+++ b/packages/webui/tests/browser.test.mjs
@@ -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);
diff --git a/packages/webui/tests/tasks-board-browser.test.mjs b/packages/webui/tests/tasks-board-browser.test.mjs
index 4074b4b54..1f55aab56 100644
--- a/packages/webui/tests/tasks-board-browser.test.mjs
+++ b/packages/webui/tests/tasks-board-browser.test.mjs
@@ -11,7 +11,7 @@ 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 } = {}) {
+function boardOrgs({ refuse = null, tasksFail = [] } = {}) {
const ORGS = [
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
@@ -30,9 +30,11 @@ function boardOrgs({ refuse = null } = {}) {
{ 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: D(t.n % 10), requirement: 'REQ-API-1',
+ 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'] } },
@@ -44,17 +46,18 @@ function boardOrgs({ refuse = null } = {}) {
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 (verb === 'tasks.edit' && t && args.state) t.state = args.state;
- return { task_ref: args.task_ref, digest: D(9) };
+ 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 () => TASKS[id](), messages: async () => ({ messages: [], unread: 0 }) },
+ 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,
+ 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 => {
@@ -65,7 +68,7 @@ function boardOrgs({ refuse = null } = {}) {
};
}
-async function open(t, { refuse = null, send = true, width = 1440, height = 900, orgs = boardOrgs({ refuse }) } = {}) {
+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());
@@ -105,12 +108,13 @@ test('org and project filters narrow the board', async 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 of 5 tasks.');
+ 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"');
@@ -190,3 +194,94 @@ test('a Console without --send shows a read-only board and the write route refus
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('Bad gateway', { 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']);
+});
diff --git a/packages/webui/tests/workspace-tasks.test.mjs b/packages/webui/tests/workspace-tasks.test.mjs
index 5b21416a6..c26dd63a9 100644
--- a/packages/webui/tests/workspace-tasks.test.mjs
+++ b/packages/webui/tests/workspace-tasks.test.mjs
@@ -103,32 +103,34 @@ test('parseTaskWrite takes the three ops strictly and refuses before any bus', (
{ 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' }), { op: 'archive', org: 'forge', verb: 'tasks.archive', args: { task_ref: 'task:4' } });
- assert.equal(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', due_date: null }).args.due_date, null);
+ 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', task_ref: 'task:1', description: 'x' }, /^edit does not take description$/],
+ [{ 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$/],
- [{ op: 'edit', org: 'forge', task_ref: 'task:1' }, /^edit needs a field to change$/],
+ // 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', task_ref: 'vikunja:3/112', title: 'x' }, /^task_ref is not valid$/],
- [{ op: 'edit', org: 'forge', task_ref: 'task:0', title: 'x' }, /^task_ref is not valid$/],
+ [{ 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', task_ref: 'task:1', title: ' ' }, /^title is not valid$/],
- [{ op: 'edit', org: 'forge', task_ref: 'task:1', title: 'x'.repeat(251) }, /^title is not valid$/],
- [{ op: 'edit', org: 'forge', task_ref: 'task:1', state: 'doing' }, /^state is not valid$/],
- [{ op: 'edit', org: 'forge', task_ref: 'task:1', priority: 6 }, /^priority is not valid$/],
- [{ op: 'edit', org: 'forge', task_ref: 'task:1', priority: 1.5 }, /^priority is not valid$/],
- [{ op: 'edit', org: 'forge', task_ref: 'task:1', assignees: ['coder', 'bad name'] }, /^assignees is not valid$/],
- [{ op: 'edit', org: 'forge', task_ref: 'task:1', assignees: 'coder' }, /^assignees is not valid$/],
- [{ op: 'edit', org: 'forge', task_ref: 'task:1', due_date: '2026-11-20' }, /^due_date is not valid$/],
- [{ op: 'edit', org: 'forge', task_ref: 'task:1', due_date: '2026-02-30T00:00:00.000Z' }, /^due_date 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$/],
];
@@ -138,7 +140,7 @@ test('parseTaskWrite takes the three ops strictly and refuses before any bus', (
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', title: 'x' };
+ 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'],
@@ -197,14 +199,14 @@ test('route: each write reaches only the org it names, through that org\'s real
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', title: 'x' });
+ 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', assignees: ['nobody'] });
+ 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' });
+ 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.
@@ -221,22 +223,22 @@ test('route: each write reaches only the org it names, through that org\'s real
// 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', title: 'x' });
+ 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', title: 'x' });
+ 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', title: 'x' });
+ 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', title: 'x' });
+ 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', title: 'x' });
+ 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', title: 'x' });
+ 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', title: 'x' });
+ 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.
@@ -314,14 +316,31 @@ test('read: filters, the per-org cap, and rows the read refuses', async () => {
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 TASKS_PER_ORG rows and says how many it holds; another org is untouched.
- const many = Array.from({ length: TASKS_PER_ORG + 7 }, (_, i) => store(i + 1));
- const capped = await readTasks([member('big', { tasks: many }), member('small', { tasks: [store(1)] })]);
- assert.equal(capped.tasks.filter(x => x.org === 'big').length, TASKS_PER_ORG);
+ // 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(capped.tasks.filter(x => x.org === 'big').slice(8, 11).map(x => x.ref), ['task:9', 'task:10', 'task:11']);
+ 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 => {