Files
jason.woltje 154c2216f3 feat(webui): board labels, pack picker and workflow editor on the organization page (#1873 SP6)
The Tasks board takes the selected organization's board: its labels and column order over the five
fixed states, and its default layout unless the viewer saved one. The all-organizations view keeps
the fixed labels. The organization page gains the owner's starter pack picker (list, preview with
marks, flagged lines, credential lines and noHire marks, apply by digest) and a workflow list and
editor that marks a step role naming no current role. Every organization- or pack-supplied string
goes through esc or textContent.

The workspace read carries each organization's and project's board. GET /api/orgs/<id>/workflows
is the owner's read of the workflows, the role names a step may resolve to and the file revision.
packsServes pins HEAD and GET /api/board to the Board read, never the pack route.
2026-11-12 18:17:26 -06:00

394 lines
25 KiB
JavaScript

// U1 (#1873, PRD 1.20 REQ-WS-1..8): the cross-organization read model behind GET /api/workspace.
//
// One snapshot across the bus of every organization the viewer belongs to. Membership is the
// per-org check the org list and the org pages already use (organizations.mjs `admitted`), so an
// org the viewer does not belong to is never read and never named. Each org keeps its own bus;
// this module only fans out over the same per-org options the org pages get (cli.mjs) and merges.
// There is no second path to a bus and no write.
//
// Every org has one deadline. A bus that is down, still starting or slow is marked with a state
// and a time, and the other orgs still answer. Every merged item carries its org id.
import { plainMessage } from './org-admin.mjs';
export const WORKSPACE_SECTIONS = Object.freeze(['projects', 'sessions', 'inbox', 'tasks', 'metrics']);
export const WORKSPACE_POLL_MS = 10000;
export const WORKSPACE_TIMEOUT_MS = 4000;
const PROJECT_STATUSES = ['active', 'archived'];
const PROJECT_STATES = ['working', 'waiting', 'idle', 'none'];
const SORTS = ['activity', 'name', 'org', 'status'];
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
// A project selector is `<org>:<project uuid or org-file project key>`.
const PROJECT_SELECTOR = /^([a-z0-9][a-z0-9._-]{0,63}):([A-Za-z0-9._-]{1,128})$/;
// Only these reach a copied shell command; anything else gets no command.
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 = {
projects: ['projects', 'agents', 'claims', 'threads', 'tasks', 'inbox'],
sessions: ['agents', 'claims', 'threads', 'projects', 'tasks'],
inbox: ['inbox', 'agents', 'tasks', 'projects'],
tasks: ['tasks', 'projects', 'inbox'],
metrics: ['inbox', 'agents', 'claims', 'tasks', 'projects', 'usage', 'limits', 'decisions'],
};
const READS = {
inbox: o => o.bus.inbox(),
agents: o => o.bus.agents(),
claims: o => o.bus['thread.claims'](),
threads: o => o.chat.threads({}),
tasks: o => o.bus.tasks(),
projects: o => o.chat.projects({ includeArchived: true }),
usage: o => o.bus.usage(),
limits: o => o.bus.limits(),
decisions: o => o.bus.decisions('recent'),
};
export class WorkspaceQueryError extends Error {}
const bad = message => { throw new WorkspaceQueryError(message); };
const list = value => value.split(',').map(v => v.trim()).filter(Boolean);
function oneOf(name, value, allowed) {
const values = list(value);
if (!values.length) bad(`${name} is empty`);
for (const v of values) if (!allowed.includes(v)) bad(`${name} takes ${allowed.join(', ')}`);
return new Set(values);
}
// 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, taskState: null, assignee: null, label: null };
for (const [key, value] of params) {
if (seen.has(key)) bad(`${key} is given twice`);
seen.add(key);
if (key === 'sections') q.sections = oneOf('sections', value, WORKSPACE_SECTIONS);
else if (key === 'org') {
const ids = list(value);
if (!ids.length || ids.some(id => !ORG_ID.test(id))) bad('org takes organization ids, comma separated');
q.orgs = new Set(ids);
} else if (key === 'status') q.status = oneOf('status', value, PROJECT_STATUSES);
else if (key === 'state') q.state = oneOf('state', value, PROJECT_STATES);
else if (key === 'name') {
if (!value.trim() || value.length > 200) bad('name takes 1 to 200 characters');
q.name = value.trim().toLowerCase();
} else if (key === 'sort') { if (!SORTS.includes(value)) bad(`sort takes ${SORTS.join(', ')}`); q.sort = value; }
else if (key === 'order') { if (!['asc', 'desc'].includes(value)) bad('order takes asc or desc'); q.order = value; }
else if (key === 'project') {
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';
return q;
}
const TIMED_OUT = Symbol('timed-out');
function deadline(ms) {
const controller = new AbortController(), until = Date.now() + ms;
let timer;
const promise = new Promise(resolve => { timer = setTimeout(() => { controller.abort(); resolve(TIMED_OUT); }, ms); timer.unref?.(); });
return { promise, signal: controller.signal, deadline: until, abort: () => controller.abort(), clear: () => clearTimeout(timer) };
}
const code = e => (typeof e?.code === 'string' && /^[a-z-]{1,64}$/.test(e.code) ? e.code : 'read-failed');
// Runs `names` reads `limit` at a time and stops starting new ones once `stopped()` says so.
async function pooled(names, limit, run, stopped) {
const out = {}, queue = [...names];
const worker = async () => {
while (queue.length && !stopped()) {
const name = queue.shift();
try { out[name] = { ok: true, value: await run(name) }; } catch (e) { out[name] = { ok: false, error: code(e) }; }
}
};
await Promise.all(Array.from({ length: Math.min(limit, names.length) }, worker));
return out;
}
const iso = ms => new Date(ms).toISOString();
const latest = values => values.filter(v => typeof v === 'string' && !Number.isNaN(Date.parse(v))).sort().at(-1) ?? null;
function median(values) {
if (!values.length) return null;
const s = [...values].sort((a, b) => a - b), mid = s.length >> 1;
return s.length % 2 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
}
// One org's bus state and reads. Never throws: every failure becomes the org's state.
async function readOrg(member, needs, { timeoutMs, now, ctx }) {
const started = now();
const head = { org: member.id, label: member.id, theme: member.doc?.appearance?.theme ?? null, tracker: member.doc?.tracker ? 'vikunja' : 'built-in', roles: Object.keys(member.doc?.roles ?? {}).sort(),
// Starter packs SP6 (#1873): the org's board style (labels, order and a default layout over the five
// fixed states), as the organization file holds it; the tasks page applies it only to a one-org view.
board: member.doc?.board ?? null };
const finish = (state, extra = {}) => ({ ...head, state, at: iso(now()), readMs: now() - started, ...extra });
if (member.invalid) return { org: { ...head, roles: [], board: null, state: 'invalid', at: iso(now()), readMs: 0, reason: member.reason } };
const limit = deadline(timeoutMs);
const bound = { signal: limit.signal, deadline: limit.deadline };
const invalid = () => ({ org: finish('invalid', { error: 'invalid-response' }) });
try {
let status;
try {
status = await Promise.race([member.status(bound), limit.promise]);
} catch (e) {
if (limit.signal.aborted || Date.now() >= limit.deadline) return { org: finish('timeout', { reason: `the bus state was not read within ${timeoutMs} ms` }) };
// A stale or mismatched host state (org-bus.mjs checkedState) is a stopped bus to the reader.
// members() has already made host paths relative; any absolute path left is withheld.
return { org: finish('down', { reason: plainMessage(ctx, typeof e?.message === 'string' ? e.message : 'bus state could not be read').slice(0, 300) }) };
}
if (status === TIMED_OUT || limit.signal.aborted || Date.now() >= limit.deadline) return { org: finish('timeout', { reason: `the bus state was not read within ${timeoutMs} ms` }) };
if (status?.healthy !== true) {
const boot = status?.boot && typeof status.boot === 'object' ? { ok: status.boot.ok === true, at: status.boot.at ?? null, lastGood: status.boot.lastGood ?? null } : null;
if (Number.isSafeInteger(status?.pid)) return { org: finish('starting', { reason: 'the bus host is running but does not answer yet', boot }) };
const reason = boot && !boot.ok && typeof status.boot.reason === 'string'
? `the bus refused to start: ${plainMessage(ctx, status.boot.reason).slice(0, 300)}`
: 'the bus is not running';
return { org: finish('down', { reason, boot }) };
}
const options = member.options(bound);
const reads = pooled(needs, PER_ORG_CONCURRENCY, name => READS[name](options), () => limit.signal.aborted || Date.now() >= limit.deadline);
const results = await Promise.race([reads, limit.promise]);
if (results === TIMED_OUT || limit.signal.aborted || Date.now() >= limit.deadline) return { org: finish('timeout', { reason: `the bus did not answer within ${timeoutMs} ms` }) };
const errors = Object.fromEntries(Object.entries(results).filter(([, r]) => !r.ok).map(([name, r]) => [name, r.error]));
const data = Object.fromEntries(Object.entries(results).filter(([, r]) => r.ok).map(([name, r]) => [name, r.value]));
if (Object.values(errors).includes('invalid-response') || !Object.entries(data).every(([name, value]) => validRead(name, value))) return invalid();
const org = finish('ok', { errors });
return { org, rows: orgRows(org, data, member.doc, now()) };
} catch { return invalid(); } finally { if (Date.now() >= limit.deadline) limit.abort(); limit.clear(); }
}
// Validate the fields consumed by projection and by the cross-org filters and sorts. Unknown
// fields are retained by the bus contract; optional fields can be absent or null.
const record = v => v !== null && typeof v === 'object' && !Array.isArray(v);
const optional = (v, check) => v == null || check(v);
const string = v => typeof v === 'string';
const strings = (v, keys) => keys.every(k => optional(v[k], string));
const records = v => Array.isArray(v) && v.every(record);
function validRead(name, value) {
if (name === 'usage' || name === 'limits') {
if (!record(value)) return false;
return name === 'limits' ? optional(value.tokens, records) : optional(value.total, record);
}
if (!records(value)) return false;
return value.every(v => {
if (name === 'projects') return string(v.id) && strings(v, ['repo', 'name', 'at']);
if (name === 'threads') return strings(v, ['id', 'project']);
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']));
// 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;
});
}
const arr = value => (Array.isArray(value) ? value : []);
// Merges one org's reads into rows. Each row names its org; ids stay the bus's own.
function orgRows(org, data, doc, nowMs) {
const id = org.org;
const projects = arr(data.projects).filter(p => p && typeof p.id === 'string');
const byKey = new Map(projects.filter(p => typeof p.repo === 'string').map(p => [p.repo, p]));
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 && 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: 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]));
// A role session's project and task: the task its open decision names, else its first open
// assigned task. A role claim itself names no project, so this is inferred, and says so.
const roleWork = role => {
const open = tasks.filter(t => !t.done && t.assignees.includes(role));
return open[0] ?? null;
};
const sessions = [];
for (const c of arr(data.agents)) {
if (!c || typeof c.role !== 'string') continue;
const asked = arr(c.inputNeeded).map(d => taskByRef.get(d.task_ref)).find(Boolean);
const task = asked ?? roleWork(c.role);
sessions.push({
org: id, kind: 'role', role: c.role, run: c.holder_run ?? null, harness: c.harness ?? null, state: c.state ?? null,
since: c.working?.since ?? arr(c.inputNeeded)[0]?.at ?? c.at ?? null, claimedAt: c.at ?? null,
messages: c.working?.messages ?? 0, inputNeeded: arr(c.inputNeeded).length, stranded: arr(c.stranded).length,
task: task ? { ref: task.ref, title: task.title } : null, project: task?.project ?? null, projectFrom: task ? 'task' : null,
});
}
for (const c of arr(data.claims)) {
if (!c || typeof c.thread !== 'string') continue;
const p = threadProject.get(c.thread) ?? null;
sessions.push({ org: id, kind: 'thread', thread: c.thread, instance: c.instance ?? null, run: c.holder_run ?? null, harness: c.harness ?? null, state: 'live', since: c.at ?? null, claimedAt: c.at ?? null, project: ref(p), projectFrom: p ? 'thread' : null });
}
const sessionByRole = new Map(sessions.filter(s => s.kind === 'role').map(s => [s.role, s]));
const inbox = arr(data.inbox).filter(d => d && typeof d.id === 'string').map(d => {
const task = taskByRef.get(d.task_ref) ?? null, s = sessionByRole.get(d.raised_by_role) ?? null;
const options = arr(d.options).map(o => ({ key: o.key, text: o.text }));
const safe = SHELL_SAFE.test(d.id) && ORG_ID.test(id);
return {
org: id, id: d.id, question: d.question ?? null, class: d.class ?? null, blocking: Boolean(d.blocking), raisedBy: d.raised_by_role ?? null,
taskRef: d.task_ref ?? null, task: task ? { ref: task.ref, title: task.title } : null, project: task?.project ?? null,
session: s ? { role: s.role, run: s.run } : null, at: d.at ?? null, options, console: Boolean(d.console),
// A shared view mixes organizations, so a copied command always names the org's bus.
decide: options.map(o => ({ key: o.key, command: safe && SHELL_SAFE.test(String(o.key)) ? `mosaic decide ${d.id} ${o.key} --org ${id}` : null })),
};
});
const projectRows = projects.map(p => {
const mine = s => s.project?.id === p.id;
const ps = sessions.filter(mine), needs = inbox.filter(mine), open = tasks.filter(t => !t.done && t.project?.id === p.id);
const count = state => ps.filter(s => s.state === state).length;
const counts = { working: count('working') + count('live'), waiting: count('waiting'), idle: count('idle') };
const entry = typeof p.repo === 'string' ? doc?.projects?.[p.repo] : undefined;
return {
org: id, id: p.id, key: p.repo ?? null, name: p.name ?? null, status: p.archived ? 'archived' : 'active',
state: counts.working ? 'working' : counts.waiting || needs.length ? 'waiting' : counts.idle ? 'idle' : 'none',
git: entry ? (entry.git?.mode ?? (entry.remote ? 'remote' : null)) : null,
board: entry?.board ?? null,
sessions: counts, openTasks: open.length, needsYou: needs.length,
lastActivity: latest([p.at, ...tasks.filter(t => t.project?.id === p.id).map(t => t.changedAt), ...needs.map(d => d.at), ...ps.map(s => s.claimedAt)]),
};
});
const decided = arr(data.decisions).filter(d => d?.closed?.op === 'resolved' && typeof d.at === 'string' && typeof d.closed.at === 'string')
.map(d => Date.parse(d.closed.at) - Date.parse(d.at)).filter(ms => Number.isFinite(ms) && ms >= 0);
const roleCount = state => sessions.filter(s => s.kind === 'role' && s.state === state).length;
const metrics = {
org: id, needsYou: inbox.length, oldestNeedsYouAt: inbox.map(d => d.at).filter(Boolean).sort()[0] ?? null,
sessions: { working: roleCount('working'), waiting: roleCount('waiting'), idle: roleCount('idle'), live: sessions.filter(s => s.kind === 'thread').length },
openTasks: tasks.filter(t => !t.done).length,
doneThisWeek: tasks.filter(t => t.done && t.changedAt && nowMs - Date.parse(t.changedAt) <= WEEK_MS).length,
projects: { active: projectRows.filter(p => p.status === 'active').length, archived: projectRows.filter(p => p.status === 'archived').length },
usage: data.usage?.total ?? null, limits: arr(data.limits?.tokens), decisionWaitMedianMs: median(decided),
};
return { projects: projectRows, sessions, inbox, tasks, metrics };
}
function sortProjects(rows, { sort, order }) {
const activity = (a, b) => (a.lastActivity ?? '').localeCompare(b.lastActivity ?? '');
const name = (a, b) => (a.name ?? '').localeCompare(b.name ?? '') || a.id.localeCompare(b.id);
const by = {
activity: (a, b) => activity(a, b) || name(a, b),
name,
org: (a, b) => a.org.localeCompare(b.org) || name(a, b),
status: (a, b) => PROJECT_STATUSES.indexOf(a.status) - PROJECT_STATUSES.indexOf(b.status) || PROJECT_STATES.indexOf(a.state) - PROJECT_STATES.indexOf(b.state) || name(a, b),
}[sort];
const sign = order === 'desc' ? -1 : 1;
return [...rows].sort((a, b) => sign * by(a, b));
}
// `organizations.members(session, { timeoutMs })` gives the orgs this session belongs to, each
// `{ id, doc, status(), options() }` or `{ id, invalid, reason }`. A registry that cannot be read
// throws, and the HTTP handler answers a fixed message.
export async function readWorkspace({ organizations, session, query = parseWorkspaceQuery(''), timeoutMs = WORKSPACE_TIMEOUT_MS, now = Date.now, ctx = {} }) {
const members = (await organizations.members(session, { timeoutMs })).filter(m => !query.orgs || query.orgs.has(m.id));
const needs = [...new Set([...query.sections].flatMap(s => NEEDS[s]))];
const read = await Promise.all(members.map(m => readOrg(m, needs, { timeoutMs, now, ctx })));
const nowMs = now();
const merged = { projects: [], sessions: [], inbox: [], tasks: [], metrics: [] };
for (const r of read) {
if (r.org.state !== 'ok') continue;
const rows = r.rows;
for (const key of Object.keys(merged)) merged[key].push(...(key === 'metrics' ? [rows.metrics] : rows[key]));
}
const body = {
readAt: iso(nowMs), pollMs: WORKSPACE_POLL_MS, timeoutMs,
summary: { read: read.filter(r => r.org.state === 'ok').length, of: read.length },
orgs: read.map(r => r.org),
};
if (query.sections.has('projects')) {
const rows = merged.projects.filter(p => query.status.has(p.status) && (!query.state || query.state.has(p.state)) && (!query.name || (p.name ?? '').toLowerCase().includes(query.name)));
body.projects = sortProjects(rows, query);
}
if (query.sections.has('sessions')) body.sessions = merged.sessions.sort((a, b) => a.org.localeCompare(b.org) || (b.since ?? '').localeCompare(a.since ?? ''));
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;
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;
const sum = f => m.reduce((n, x) => n + f(x), 0);
body.metrics = {
orgs: m,
totals: {
needsYou: sum(x => x.needsYou), oldestNeedsYouAt: m.map(x => x.oldestNeedsYouAt).filter(Boolean).sort()[0] ?? null,
sessions: { working: sum(x => x.sessions.working), waiting: sum(x => x.sessions.waiting), idle: sum(x => x.sessions.idle), live: sum(x => x.sessions.live) },
openTasks: sum(x => x.openTasks), doneThisWeek: sum(x => x.doneThisWeek),
projects: { active: sum(x => x.projects.active), archived: sum(x => x.projects.archived) },
},
};
}
return body;
}
const json = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
};
// GET /api/workspace. The hook fence has required a signed-in session on the page's own name.
// Only the owner reads it: a granted user or the tester sees no organizations today, so they get
// owner-only here too, and nothing about any org.
export async function workspaceHTTP(req, res, { session = null, owner = false, organizations = null, search = '', timeoutMs = WORKSPACE_TIMEOUT_MS, now = Date.now, ctx = {}, log = () => {} }) {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
if (!organizations || typeof organizations.members !== 'function') return json(res, 404, { error: 'org-unavailable' });
if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'the cross-organization view is the owner\'s' });
let query;
try { query = parseWorkspaceQuery(search); }
catch (e) { if (e instanceof WorkspaceQueryError) return json(res, 400, { error: 'invalid-request', message: e.message }); throw e; }
let body;
try { body = await readWorkspace({ organizations, session, query, timeoutMs, now, ctx }); }
catch { log('workspace: organization registry could not be read'); return json(res, 503, { error: 'org-unavailable', message: 'Organization registry could not be read' }); }
return json(res, 200, body);
}