- Edit and archive require the digest the board read (expect); the route refuses a write without it. - The edit dialog saves against the task it showed, so a poll that reads a newer task turns a stale save into task-conflict; the dialog reopens on the current task. - An answer the board cannot read, or one marked sent=unknown, is shown as an open outcome, not as a refusal. - The per-org cap applies after the filters and keeps the most recently changed tasks; projects, metrics and the inbox count every task. The board sends project, state, assignee and label filters to the server. - A poll keeps the focus, caret and scroll the person has at the moment it answers, not when it started. - An org whose tasks read fails shows "tasks not read" and is not writable. - browser.test.mjs pins tasks-board.js in the script order.
390 lines
25 KiB
JavaScript
390 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() };
|
|
const finish = (state, extra = {}) => ({ ...head, state, at: iso(now()), readMs: now() - started, ...extra });
|
|
if (member.invalid) return { org: { ...head, roles: [], state: 'invalid', at: iso(now()), readMs: 0, reason: member.reason } };
|
|
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,
|
|
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);
|
|
}
|