// 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 `:`. 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 :'); 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); }