feat(webui,cli): U1 cross-organization read model and console verbs (#1873)

GET /api/workspace on the dev-local-owner Console: one snapshot of
projects, sessions, inbox, tasks and per-org metrics across every
organization the owner belongs to. Membership reuses the org list's
admission (organizations.members); each org is read over the same
per-org options the org pages use, with one deadline per org (4000 ms)
and a state (ok, down, starting, timeout, invalid) with a time. Every
row carries its org id; decide commands carry --org. Granted users
and the tester get 403 owner-only. Polling at 10 s, no push.

mosaic console restart and mosaic org reload <id> call the Console's
F3 routes (POST /api/console/restart, POST /api/orgs/<id>/restart/bus)
so gating, proof and trail are the Console's own; both refuse inside
an agent run first.

The cross-org assistant read is designed and stopped: it needs a new
broker read verb or role authority.
This commit is contained in:
jason.woltje committed 2026-11-09 17:54:06 -06:00
1 parent 1616d1dd6f
commit 2109458b3f
10 files changed
+906 -7

No files matched your search

+4 -1
View File
@@ -311,7 +311,8 @@ scripts/mosaic decide <decision> <option> [--note <text>] [--yes]
scripts/mosaic trail <task|decision> [--json] scripts/mosaic trail <task|decision> [--json]
scripts/mosaic bus start <business> [--pm | --no-launch] [--dev-local-owner] | stop | status [--json] scripts/mosaic bus start <business> [--pm | --no-launch] [--dev-local-owner] | stop | status [--json]
scripts/mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N] scripts/mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
scripts/mosaic console stop | status scripts/mosaic console stop | status | restart
scripts/mosaic org reload <org>
scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>] scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>]
scripts/mosaic project create <name> [--repo <repo>] | list [--json] scripts/mosaic project create <name> [--repo <repo>] | list [--json]
scripts/mosaic threads [--project <project>] [--json] [--tester] scripts/mosaic threads [--project <project>] [--json] [--tester]
@@ -332,6 +333,8 @@ For local development on a named instance only, `mosaic bus start BUSINESS --dev
`mosaic console start --dev-local-owner --dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens that dev owner Console to other machines, for UI testing from another device on a trusted LAN. It has no sign-in: anyone who can reach ADDR:PORT acts as the owner. Start, and `mosaic console status` while that Console runs, print `WARNING: dev owner Console listens on ADDR:PORT with no sign-in; anyone who can reach it acts as the owner.` on stderr. ADDR is `0.0.0.0` or an IPv4 address, and a specific address must also be one of the names. NAME is a host name or IPv4 address without a port; give several as one comma-separated value. The Console answers only `NAME:<bound port>` and the loopback names, and takes a write only when Origin is `http://` plus that same Host. A browser on such an http origin sends no Fetch Metadata, so for an allowed NAME a write with that exact Origin and no `Sec-Fetch-Site` counts as same-origin; a `Sec-Fetch-Site` the browser did send is still checked. Both flags are refused without `--dev-local-owner`, and `--dev-insecure-bind` is refused without a `--dev-allow-host`. SSO, preview and tester Consoles stay on loopback. The Console state file records `bind` and `allowHosts`. `mosaic console status` prints the state as JSON (`bind` is `127.0.0.1` for a loopback Console) and never moves a stale state file. `mosaic console start --dev-local-owner --dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens that dev owner Console to other machines, for UI testing from another device on a trusted LAN. It has no sign-in: anyone who can reach ADDR:PORT acts as the owner. Start, and `mosaic console status` while that Console runs, print `WARNING: dev owner Console listens on ADDR:PORT with no sign-in; anyone who can reach it acts as the owner.` on stderr. ADDR is `0.0.0.0` or an IPv4 address, and a specific address must also be one of the names. NAME is a host name or IPv4 address without a port; give several as one comma-separated value. The Console answers only `NAME:<bound port>` and the loopback names, and takes a write only when Origin is `http://` plus that same Host. A browser on such an http origin sends no Fetch Metadata, so for an allowed NAME a write with that exact Origin and no `Sec-Fetch-Site` counts as same-origin; a `Sec-Fetch-Site` the browser did send is still checked. Both flags are refused without `--dev-local-owner`, and `--dev-insecure-bind` is refused without a `--dev-allow-host`. SSO, preview and tester Consoles stay on loopback. The Console state file records `bind` and `allowHosts`. `mosaic console status` prints the state as JSON (`bind` is `127.0.0.1` for a loopback Console) and never moves a stale state file.
`mosaic console restart` and `mosaic org reload ORG` are the Console's Settings controls (F3) from a terminal (U1, #1873). Each sends one request to the running Console named in the instance's console state, as the page sends it (the Console's own Host and Origin, `Sec-Fetch-Site: same-origin`, a JSON `{}` body), so the Console applies the same owner, `--send` and origin checks and writes the same action trail, `<dataRoot>/console/actions.jsonl`. Both refuse inside an agent run (exit 3) before reading anything, and refuse with exit 3 when no Console runs. `console restart` works only for a Console started in the foreground by `mosaic console start` (otherwise the Console answers `not-supervised`); it waits up to 30 seconds for the relaunched Console to answer and prints `{"outcome":"restarted","from":OLD,"boot":NEW}`. `org reload ORG` restarts a running bus or starts a stopped one and prints the Console's done receipt. A request that left but got no answer is reported as outcome unknown (exit 1) and is never retried; check `mosaic console status`, `mosaic bus status ORG` and the trail. `mosaic bus restart ORG` remains the trail-less host verb.
The instance processing switch is separate from per-business launches off/on. The instance processing switch is separate from per-business launches off/on.
`mosaic halt` durably stops managed processing across every business in the `mosaic halt` durably stops managed processing across every business in the
selected configuration's data root. The bus stays up for reads and human selected configuration's data root. The bus stays up for reads and human
+10 -1
View File
@@ -43,7 +43,7 @@ import { SeatError } from "../../seat/src/seat.mjs";
import { OrgError, resolveOrg, withOrgLifecycle } from "../../organization/src/org-registry.mjs"; import { OrgError, resolveOrg, withOrgLifecycle } from "../../organization/src/org-registry.mjs";
import { orgSelector } from "./org-selector.mjs"; import { orgSelector } from "./org-selector.mjs";
import { busStart, busStatus, busStop, busRestart, busRecover } from "./org-bus.mjs"; import { busStart, busStatus, busStop, busRestart, busRecover } from "./org-bus.mjs";
import { consoleStart, consoleStatus, consoleStop } from "./org-console.mjs"; import { consoleRestart, consoleStart, consoleStatus, consoleStop } from "./org-console.mjs";
import { usageCommand, USAGE_USAGE } from "./usage.mjs"; import { usageCommand, USAGE_USAGE } from "./usage.mjs";
import { orgCommand, ORG_USAGE } from "./org.mjs"; import { orgCommand, ORG_USAGE } from "./org.mjs";
@@ -91,6 +91,7 @@ export const USAGE = `usage:
mosaic bus status <id> [--json] mosaic bus status <id> [--json]
mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N] mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
mosaic console stop|status mosaic console stop|status
mosaic console restart
${DEV_USAGE} ${DEV_USAGE}
${RUN_USAGE}`; ${RUN_USAGE}`;
@@ -571,6 +572,14 @@ async function consoleCommand(rest, io, deps) {
io.stdout.write(`${JSON.stringify(consoleStatus(organizationContext(io, deps), { warn: s => io.stderr.write(s) }))}\n`); io.stdout.write(`${JSON.stringify(consoleStatus(organizationContext(io, deps), { warn: s => io.stderr.write(s) }))}\n`);
return; return;
} }
// U1 (#1873): the F3 Settings control from a terminal. The running Console applies its own owner
// and trail checks (org-console.mjs consoleRestart); an agent run is refused before any read.
if (sub === "restart") {
if (args.length) throw usage("console restart takes no arguments");
refuseInsideAgent(io.env);
io.stdout.write(`${JSON.stringify(await consoleRestart(organizationContext(io, deps), deps.consoleRequest ?? {}))}\n`);
return;
}
if (sub !== "start") throw usage(); if (sub !== "start") throw usage();
const parsed = parse(args, { flags: ["--dev-local-owner", "--detach"], values: ["--port", "--dev-insecure-bind", "--dev-allow-host"] }); const parsed = parse(args, { flags: ["--dev-local-owner", "--detach"], values: ["--port", "--dev-insecure-bind", "--dev-allow-host"] });
if (parsed.positional.length) throw usage("console start takes no positional arguments"); if (parsed.positional.length) throw usage("console start takes no positional arguments");
+77
View File
@@ -2,6 +2,7 @@
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { processState } from '../../seat/src/process-state.mjs'; import { processState } from '../../seat/src/process-state.mjs';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { request as httpRequest } from 'node:http';
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs'; import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -219,3 +220,79 @@ export function consoleStatus(ctx, { warn = s => process.stderr.write(s) } = {})
if (running && s.bind) warn(lanWarning(bind, port)); if (running && s.bind) warn(lanWarning(bind, port));
return { running, ...(running ? {} : { stale: true }), pid: s.pid, url: s.url, bind, ...(s.allowHosts ? { allowHosts: s.allowHosts } : {}) }; return { running, ...(running ? {} : { stale: true }), pid: s.pid, url: s.url, bind, ...(s.allowHosts ? { allowHosts: s.allowHosts } : {}) };
} }
// U1 (#1873): `mosaic console restart` and `mosaic org reload ID` are the F3 page controls from a
// terminal. Each one is a request to the running Console named by console.json, sent as the page
// sends it: the Console's own Host and Origin, Sec-Fetch-Site: same-origin and a JSON `{}` body.
// The Console's route applies its owner, --send and origin checks and writes the action trail;
// nothing here writes the trail, signals a process or touches a bus. The CLI verbs refuse inside
// an agent run before they get here (transport.mjs refuseInsideAgent).
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
function consoleTarget(ctx) {
const s = state(ctx);
if (!s) fail('no Console runs for this instance; start one with mosaic console start');
if (!liveConsole(s)) fail('the Console in console state is not running; see mosaic console status');
const url = /^http:\/\/([0-9.]+):(\d+)\/$/.exec(s.url);
if (!url) fail('console state is invalid');
const port = Number(url[2]);
if (forbidden.has(port)) fail('console state names a forbidden port');
// A LAN Console on 0.0.0.0 answers on loopback too; one bound to an address answers only there.
const address = s.bind && s.bind !== '0.0.0.0' ? s.bind : '127.0.0.1';
return { address, port, host: `${address}:${port}` };
}
function consoleCall(target, { method = 'GET', path, timeoutMs }) {
return new Promise((resolve, reject) => {
const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': 2 } : {}) };
let sent = false;
const req = httpRequest({ host: target.address, port: target.port, method, path, headers, timeout: timeoutMs }, res => {
let text = '';
res.setEncoding('utf8');
res.on('data', chunk => { text += chunk; if (text.length > 65536) req.destroy(new Error('the Console answer is too large')); });
res.on('end', () => { let body = null; try { body = JSON.parse(text); } catch { /* not JSON */ } resolve({ status: res.statusCode, body }); });
res.on('error', e => reject(Object.assign(e, { sent: true })));
});
req.on('timeout', () => req.destroy(Object.assign(new Error('timed out'), { code: 'timeout' })));
req.on('finish', () => { sent = true; });
req.on('error', e => reject(Object.assign(e, { sent })));
req.end(method === 'POST' ? '{}' : undefined);
});
}
// A refused connection never reached the Console. Anything else after the request left may have
// changed something, so it is outcome-unknown and nothing is retried.
async function consolePost(target, path, { timeoutMs, unknown }) {
let answer;
try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs }); }
catch (e) {
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
throw new CliError(`outcome unknown: ${unknown}`, 1);
}
return answer;
}
function refusal(answer, what) {
const code = typeof answer.body?.error === 'string' && /^[a-z-]{1,64}$/.test(answer.body.error) ? answer.body.error : `http ${answer.status}`;
const message = typeof answer.body?.message === 'string' ? `: ${answer.body.message.slice(0, 500)}` : '';
return new CliError(`${what} refused (${code})${message}`, answer.status >= 500 ? 1 : 3);
}
// POST /api/console/restart, then wait for the relaunched Console (a new boot id on /api/config).
export async function consoleRestart(ctx, { timeoutMs = 10000, waitMs = 30000 } = {}) {
const target = consoleTarget(ctx);
const answer = await consolePost(target, '/api/console/restart', { timeoutMs, unknown: 'the Console may be restarting; check mosaic console status and the action trail' });
if (answer.status !== 202 || answer.body?.outcome !== 'restarting' || typeof answer.body.boot !== 'string') throw refusal(answer, 'console restart');
const from = answer.body.boot, deadline = Date.now() + waitMs;
while (Date.now() < deadline) {
await sleep(250);
try {
const config = await consoleCall(target, { path: '/api/config', timeoutMs: 2000 });
if (config.status === 200 && typeof config.body?.boot === 'string' && config.body.boot !== from) return { outcome: 'restarted', from, boot: config.body.boot };
} catch { /* closed while it restarts */ }
}
throw new CliError('the Console accepted the restart but has not answered since; check mosaic console status', 1);
}
// POST /api/orgs/ID/restart/bus: a running bus restarts, a stopped one starts (org-admin.mjs restartBus).
export async function consoleBusRestart(ctx, id, { timeoutMs = 120000 } = {}) {
if (typeof id !== 'string' || !ORG_ID.test(id)) throw new CliError('invalid organization id', 4);
const target = consoleTarget(ctx);
const answer = await consolePost(target, `/api/orgs/${id}/restart/bus`, { timeoutMs, unknown: `the bus may be restarting; check mosaic bus status ${id} and the action trail` });
if (answer.status !== 200 || answer.body?.outcome !== 'done' || answer.body.step !== 'bus-restart') throw refusal(answer, 'org reload');
return answer.body;
}
+8 -1
View File
@@ -6,7 +6,9 @@ import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
import { loadSystem } from './config.mjs'; import { loadSystem } from './config.mjs';
import { CliError } from './errors.mjs'; import { CliError } from './errors.mjs';
import { busStopForDelete } from './org-bus.mjs'; import { busStopForDelete } from './org-bus.mjs';
export const ORG_USAGE = 'mosaic org lock show|clear ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]'; import { consoleBusRestart } from './org-console.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ORG_USAGE = 'mosaic org lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; } function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e.message}`, 2); } } async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e.message}`, 2); } }
export async function orgCommand(args, io, deps = {}) { export async function orgCommand(args, io, deps = {}) {
@@ -16,12 +18,17 @@ export async function orgCommand(args, io, deps = {}) {
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; }; const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
if (words[0] === 'template' && words[1] === 'render' && words.length === 3) { allowed([], ['--fixture', '--output']); result = await templateRender(words[2], { fixture: p.values['--fixture'] }); await writeFile(need('--output'), JSON.stringify(result, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); io.stdout.write(`rendered ${words[2]} to ${p.values['--output']}\n`); return; } if (words[0] === 'template' && words[1] === 'render' && words.length === 3) { allowed([], ['--fixture', '--output']); result = await templateRender(words[2], { fixture: p.values['--fixture'] }); await writeFile(need('--output'), JSON.stringify(result, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); io.stdout.write(`rendered ${words[2]} to ${p.values['--output']}\n`); return; }
if (words[0] === 'validate' && words.length === 2) { allowed([], []); result = await validateOrg(await document(words[1])); if (!result.ok) throw new CliError(result.errors.map(e => `${e.path}: ${e.message}`).join('\n'), 2); io.stdout.write('valid\n'); return; } if (words[0] === 'validate' && words.length === 2) { allowed([], []); result = await validateOrg(await document(words[1])); if (!result.ok) throw new CliError(result.errors.map(e => `${e.path}: ${e.message}`).join('\n'), 2); io.stdout.write('valid\n'); return; }
// U1 (#1873): `org reload` is a human action; an agent run is refused before any config is read.
if (words[0] === 'reload') refuseInsideAgent(io.env);
const instance = devOwnerInstance(io.env); const system = deps.system ?? loadSystem({ env: io.env, instance }); const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER }; const instance = devOwnerInstance(io.env); const system = deps.system ?? loadSystem({ env: io.env, instance }); const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); } if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); }
else if (words[0] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); } else if (words[0] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); }
else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); } else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); }
else if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json'], ['--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); const opts = { ifRevision: Number(r) }; result = words[0] === 'archive' ? await archiveOrg(ctx, words[1], { ...opts, busStop: busStopForDelete }) : await restoreOrg(ctx, words[1], opts); } else if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json'], ['--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); const opts = { ifRevision: Number(r) }; result = words[0] === 'archive' ? await archiveOrg(ctx, words[1], { ...opts, busStop: busStopForDelete }) : await restoreOrg(ctx, words[1], opts); }
else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const r = p.values['--if-revision']; if (r !== undefined && !/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, confirm: need('--confirm'), ifRevision: r === undefined ? undefined : Number(r) }); } else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const r = p.values['--if-revision']; if (r !== undefined && !/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, confirm: need('--confirm'), ifRevision: r === undefined ? undefined : Number(r) }); }
// U1 (#1873): the F3 bus control from a terminal, through the running Console's own route, which
// checks the owner and writes the action trail (org-console.mjs consoleBusRestart).
else if (words[0] === 'reload' && words.length === 2) { allowed([], []); result = await consoleBusRestart(ctx, words[1], deps.consoleRequest ?? {}); }
else if (words[0] === 'show' && words.length === 2) { allowed(['--json'], []); result = await showOrg(ctx, words[1]); } else if (words[0] === 'show' && words.length === 2) { allowed(['--json'], []); result = await showOrg(ctx, words[1]); }
else if (words[0] === 'list' && words.length === 1) { allowed(['--json'], []); for (const entry of await listOrgs(ctx)) if (entry.state === 'active' || entry.state === 'archived') accepted.push(await showOrg(ctx, entry.id, { includeArchived: true })); result = accepted; } else if (words[0] === 'list' && words.length === 1) { allowed(['--json'], []); for (const entry of await listOrgs(ctx)) if (entry.state === 'active' || entry.state === 'archived') accepted.push(await showOrg(ctx, entry.id, { includeArchived: true })); result = accepted; }
else if ((words[0] === 'queue' && words.length === 3) || (words[0] === 'project' && words[1] === 'repo' && words.length === 5)) { else if ((words[0] === 'queue' && words.length === 3) || (words[0] === 'project' && words[1] === 'repo' && words.length === 5)) {
+187
View File
@@ -0,0 +1,187 @@
// U1 (#1873): `mosaic console restart` and `mosaic org reload ID` are the F3 page controls from a
// terminal. Each test runs the real scripts/mosaic on a scratch instance made by bootstrap.sh, and
// checks the refusal before any read, the request to the Console's own route and the action trail
// that route writes. The supervised Console is the real one; the org reload success path runs
// against an in-process Console whose bus service is a fake, so no bus host starts.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { request } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
import { processState } from '../../seat/src/process-state.mjs';
import { startServer } from '../../webui/src/serve.mjs';
import { createDevOwner } from '../../webui/src/dev-owner.mjs';
import { createOrgAdmin } from '../../webui/src/org-admin.mjs';
import { actionTrail } from '../../webui/src/action-trail.mjs';
import { close } from '../../webui/tests/fixture.mjs';
import { humanEnv } from './helpers.mjs';
import { consoleStart, consoleStop } from '../src/org-console.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
const MOSAIC = join(REPO, 'scripts/mosaic');
const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350];
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
// A scratch instance, as bootstrap.sh makes one, with org alpha active.
async function instance(t) {
const home = mkdtempSync(join(tmpdir(), 'u1-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-u1test/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'u1test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-u1test'), dataRoot: join(home, '.mosaic-u1test'), instance: 'u1test', env };
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); });
return { ctx, env, trailFile: join(ctx.dataRoot, 'console/actions.jsonl') };
}
// The CLI as a child, without blocking this process: the in-process Console must keep answering.
function mosaic(env, args, timeout = 60000) {
return new Promise((resolve, reject) => {
const child = spawn(MOSAIC, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
child.stdout.setEncoding('utf8').on('data', c => { stdout += c; });
child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
child.on('error', reject);
child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); });
});
}
const trailLines = file => existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.ok(Number.isFinite(Date.parse(at))); return e; }) : [];
function get(port, path) {
return new Promise((resolve, reject) => {
request({ host: '127.0.0.1', port, path, headers: { host: `127.0.0.1:${port}` } }, res => {
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
}).on('error', reject).end();
});
}
// console.json naming a Console that runs in this test process (same checks as a real one).
function recordConsole(ctx, url, { pid = process.pid, startTime = processState(process.pid).startTime } = {}) {
const dir = join(ctx.dataRoot, 'console');
mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700);
const file = join(dir, 'console.json');
writeFileSync(file, JSON.stringify({ pid, startTime, url }), { mode: 0o600 }); chmodSync(file, 0o600);
}
test('both verbs refuse inside an agent run before any config is read', async () => {
// The config path is not an instance config, so a read would refuse with another message.
// humanEnv strips agent markers, so the marker goes in after it.
const env = { ...humanEnv({ MOSAIC_CONFIG: '/nonexistent/u1/config.json' }), CLAUDECODE: '1' };
for (const args of [['console', 'restart'], ['org', 'reload', 'alpha']]) {
const r = await mosaic(env, args);
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
assert.match(r.stderr, /outside any agent run \(found CLAUDECODE/);
assert.equal(r.stdout, '');
}
});
test('with no live Console both verbs refuse and nothing is written', { timeout: 90000 }, async t => {
const { ctx, env, trailFile } = await instance(t);
for (const args of [['console', 'restart'], ['org', 'reload', 'alpha']]) {
const r = await mosaic(env, args);
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
assert.match(r.stderr, /no Console runs for this instance/);
}
recordConsole(ctx, 'http://127.0.0.1:1/', { pid: 2147483647, startTime: '1' });
const stale = await mosaic(env, ['console', 'restart']);
assert.equal(stale.status, 3, stale.stderr);
assert.match(stale.stderr, /is not running/);
const bad = await mosaic(env, ['org', 'reload', 'Bad/Id']);
assert.equal(bad.status, 4, bad.stderr);
assert.match(bad.stderr, /invalid organization id/);
assert.equal((await mosaic(env, ['console', 'restart', 'extra'])).status, 4);
assert.equal(existsSync(trailFile), false);
});
test('mosaic console restart relaunches a supervised Console and the trail records it', { timeout: 120000 }, async t => {
const { ctx, env, trailFile } = await instance(t);
const warnings = [];
const result = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) });
t.after(async () => { result.stop(); await result.done; });
const port = Number(new URL(result.url).port);
assert.ok(!FORBIDDEN.includes(port));
const before = (await get(port, '/api/config')).body;
assert.equal(before.consoleRestart, true);
const r = await mosaic(env, ['console', 'restart']);
assert.equal(r.status, 0, r.stderr);
const answer = JSON.parse(r.stdout);
assert.equal(answer.outcome, 'restarted'); assert.equal(answer.from, before.boot);
assert.equal(typeof answer.boot, 'string'); assert.notEqual(answer.boot, before.boot);
assert.equal((await get(port, '/api/config')).body.boot, answer.boot);
assert.deepEqual(trailLines(trailFile), [{ by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot }]);
assert.equal(statSync(trailFile).mode & 0o777, 0o600);
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
});
test('a detached Console refuses mosaic console restart, and org reload of an unknown org, with no trail', { timeout: 90000 }, async t => {
const { ctx, env, trailFile } = await instance(t);
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
const port = Number(new URL(result.url).port);
assert.ok(!FORBIDDEN.includes(port));
const r = await mosaic(env, ['console', 'restart']);
assert.equal(r.status, 3, r.stderr);
assert.match(r.stderr, /console restart refused \(not-supervised\)/);
// The real organization service refuses an org that does not exist before any trail line.
const unknown = await mosaic(env, ['org', 'reload', 'nosuch']);
assert.equal(unknown.status, 3, unknown.stderr);
assert.match(unknown.stderr, /org reload refused \(org-[a-z-]+\)/);
assert.equal((await get(port, '/api/config')).status, 200);
assert.equal(existsSync(trailFile), false);
assert.equal((await consoleStop(ctx)).stopped, true);
});
// The F3 bus control through the Console's own route: a stopped bus starts and a running one restarts.
function fakeAdmin(ctx) {
const calls = [];
let running = false;
const known = id => { if (id !== 'acme') throw Object.assign(new Error('unknown organization'), { code: 'org-unknown' }); };
const service = { showOrg: async (c, id) => { known(id); return { id, root: join(ctx.dataRoot, 'org-runtime', id), revision: 1, doc: {}, principal: c.principal }; } };
const bus = {
busStatus: async (c, id) => { known(id); return { org: id, instance: ctx.instance, root: join(ctx.dataRoot, 'org-runtime', id), socket: '/run/u1.sock', healthy: running, pid: running ? 4242 : null }; },
busStart: async (c, id, opts) => { calls.push(['busStart', id, opts]); running = true; return { org: id, pid: 4242, socket: '/run/u1.sock', root: join(ctx.dataRoot, 'org-runtime', id) }; },
busRestart: async (c, id) => { calls.push(['busRestart', id]); return { org: id, pid: 4343, socket: '/run/u1.sock', root: join(ctx.dataRoot, 'org-runtime', id) }; },
};
const registry = { listOrgs: async () => [{ id: 'acme', state: 'active' }] };
return { calls, admin: createOrgAdmin({ service, registry, bus, context: async () => ({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: ctx.instance }) }) };
}
test('mosaic org reload starts or restarts the bus through the Console route and the trail records each', { timeout: 90000 }, async t => {
const { ctx, env, trailFile } = await instance(t);
const f = fakeAdmin(ctx);
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), orgAdmin: f.admin, log: () => {}, trail: actionTrail(join(ctx.dataRoot, 'console')) });
t.after(() => close(server));
const port = server.address().port;
assert.ok(!FORBIDDEN.includes(port));
recordConsole(ctx, `http://127.0.0.1:${port}/`);
let r = await mosaic(env, ['org', 'reload', 'acme']);
assert.equal(r.status, 0, r.stderr);
assert.deepEqual(JSON.parse(r.stdout), { outcome: 'done', step: 'bus-restart', restarted: false, healthy: true, receipt: { org: 'acme', pid: 4242, socket: '<host path>', root: '<data root>/org-runtime/acme' }, verb: 'mosaic bus start acme --dev-local-owner --detach' });
r = await mosaic(env, ['org', 'reload', 'acme']);
assert.equal(r.status, 0, r.stderr);
const second = JSON.parse(r.stdout);
assert.equal(second.restarted, true); assert.equal(second.verb, 'mosaic bus restart acme'); assert.equal(second.receipt.pid, 4343);
assert.deepEqual(f.calls, [['busStart', 'acme', { detach: true, devLocalOwner: true, pm: false }], ['busRestart', 'acme']]);
// An unknown org is refused by the route before the trail (as org-unavailable, which does not say
// whether it exists); extra arguments never reach the Console.
const unknown = await mosaic(env, ['org', 'reload', 'other']);
assert.equal(unknown.status, 3, unknown.stderr);
assert.match(unknown.stderr, /org reload refused \(org-unavailable\)/);
assert.equal((await mosaic(env, ['org', 'reload', 'acme', 'extra'])).status, 4);
assert.deepEqual(trailLines(trailFile), [
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: false },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: true },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
]);
// A read-only Console (no --send) refuses, and the CLI reports the Console's own reason.
await close(server);
const readonly = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), orgAdmin: f.admin, log: () => {}, trail: actionTrail(join(ctx.dataRoot, 'console')) });
t.after(() => close(readonly));
recordConsole(ctx, `http://127.0.0.1:${readonly.address().port}/`);
const denied = await mosaic(env, ['org', 'reload', 'acme']);
assert.equal(denied.status, 3, denied.stderr);
assert.match(denied.stderr, /org reload refused \(read-only\): start the Console with --send/);
assert.equal(trailLines(trailFile).length, 4);
});
+6 -3
View File
@@ -156,9 +156,12 @@ try {
const organizations = devOwner ? organizationConsole({ const organizations = devOwner ? organizationConsole({
ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance }, ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance },
listOrgs, showOrg, resolveOrg, orgPaths, busStatus: orgBusStatus, listOrgs, showOrg, resolveOrg, orgPaths, busStatus: orgBusStatus,
options: ({ id, root, paths, session }) => { // U1 (#1873): the cross-organization read passes `timeoutMs` so a slow bus's children are
const owner = busCaller({ dataRoot: root, socket: paths.socket, business: id }); // killed at its deadline. The org pages pass none and keep the transport default.
const call = session.user === undefined ? owner : userCaller({ dataRoot: root, socket: paths.socket, business: id, user: session.user }); options: ({ id, root, paths, session, timeoutMs }) => {
const bound = timeoutMs === undefined ? {} : { timeoutMs };
const owner = busCaller({ dataRoot: root, socket: paths.socket, business: id, ...bound });
const call = session.user === undefined ? owner : userCaller({ dataRoot: root, socket: paths.socket, business: id, user: session.user, ...bound });
return { return {
...consoleBus(call, { send: sendOn, ...(session.user === undefined ? {} : { owner }) }), ...consoleBus(call, { send: sendOn, ...(session.user === undefined ? {} : { owner }) }),
decisionWrite: session.user === undefined && sendOn ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null, decisionWrite: session.user === undefined && sendOn ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
+32
View File
@@ -35,8 +35,40 @@ export function organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPat
} }
return result; return result;
} }
// U1 (#1873): the orgs a session belongs to, for the cross-organization read (workspace-read.mjs).
// The same admission as `list` and `resolve`: an org the session does not own is skipped, and a
// broken org shows only to its verified owner, as invalid. Bus status and options are lazy, so a
// stopped bus never gets a transport; `timeoutMs` bounds each bus read the options make.
async function members(session, { timeoutMs } = {}) {
const entries = await listOrgs(ctx), result = [];
for (const entry of entries) {
if (entry.state !== 'active') continue;
let verified;
try { verified = await admitted(entry.id, session); }
catch (error) {
if (error?.ownerVerified !== true) continue;
result.push({ id: entry.id, invalid: true, reason: publicReason(ctx, error) });
continue;
}
const { org, shown, context } = verified, id = entry.id;
result.push({
id, doc: shown.doc,
// Host paths in a refusal or boot reason become relative to the instance before they leave.
async status() {
let s;
try { s = await busStatus(ctx, id); }
catch (error) { throw new Error(plainMessage(ctx, typeof error?.message === 'string' ? error.message : 'bus state could not be read')); }
const boot = s?.boot && typeof s.boot === 'object' ? { ok: s.boot.ok, at: s.boot.at, lastGood: s.boot.lastGood ?? null, reason: s.boot.reason == null ? null : plainMessage(ctx, s.boot.reason) } : null;
return { healthy: s?.healthy === true, pid: Number.isSafeInteger(s?.pid) ? s.pid : null, boot };
},
options: () => options({ id, root: org.root, paths: orgPaths(org.root), session, context, doc: shown.doc, ...(timeoutMs === undefined ? {} : { timeoutMs }) }),
});
}
return result;
}
return Object.freeze({ return Object.freeze({
list, list,
members,
async resolve(id, session) { async resolve(id, session) {
const { org, shown, context } = await admitted(id, session); const { org, shown, context } = await admitted(id, session);
let busHealthy = false; let busHealthy = false;
+4 -1
View File
@@ -20,6 +20,7 @@ import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs'; import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs'; import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
import { consoleRestartHTTP } from './console-restart-http.mjs'; import { consoleRestartHTTP } from './console-restart-http.mjs';
import { workspaceHTTP } from './workspace-read.mjs';
import { actor } from './action-trail.mjs'; import { actor } from './action-trail.mjs';
import { devHostName } from '../../cli/src/dev-local-owner.mjs'; import { devHostName } from '../../cli/src/dev-local-owner.mjs';
@@ -770,7 +771,7 @@ async function libraryStatusRead(res, read, search) {
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for // `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json. // tests; the field table is otherwise the committed packages/tasks/fields.json.
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request. // `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID() } = {}) { export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined } = {}) {
// org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host // org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host
// names (without a port) a remote browser may use; only the dev owner takes them, and only with // names (without a port) a remote browser may use; only the dev owner takes them, and only with
// them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback. // them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback.
@@ -805,6 +806,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// F3: the owner restarts the Console (console-restart-http.mjs). `restart` is set only under the // F3: the owner restarts the Console (console-restart-http.mjs). `restart` is set only under the
// supervisor; `boot` names this process so an open page can tell when the new one answers. // supervisor; `boot` names this process so an open page can tell when the new one answers.
registered.add('POST', '/api/console/restart', (req, res, context) => consoleRestartHTTP(req, res, { ...context, restart, trail, boot, log })); registered.add('POST', '/api/console/restart', (req, res, context) => consoleRestartHTTP(req, res, { ...context, restart, trail, boot, log }));
// U1 (#1873): one read across every organization the owner belongs to (workspace-read.mjs).
registered.add('GET', '/api/workspace', (req, res, context) => workspaceHTTP(req, res, { ...context, organizations, log, ...(workspaceTimeoutMs === undefined ? {} : { timeoutMs: workspaceTimeoutMs }) }));
const upstream = boardURL(board); const upstream = boardURL(board);
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session // `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions. // user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
+314
View File
@@ -0,0 +1,314 @@
// 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;
// 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 };
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] };
} else bad(`unknown query key ${key}`);
}
q.order ??= q.sort === 'activity' ? 'desc' : 'asc';
return q;
}
const TIMED_OUT = Symbol('timed-out');
function deadline(ms) {
let timer;
const promise = new Promise(resolve => { timer = setTimeout(() => resolve(TIMED_OUT), ms); timer.unref?.(); });
return { promise, 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 } };
let expired = false;
const limit = deadline(timeoutMs);
try {
let status;
try {
status = await Promise.race([member.status(), limit.promise]);
} catch (e) {
// 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) 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();
const reads = pooled(needs, PER_ORG_CONCURRENCY, name => READS[name](options), () => expired);
const results = await Promise.race([reads, limit.promise]);
if (results === TIMED_OUT) { expired = true; 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]));
return { org: finish('ok', { errors }), data, doc: member.doc };
} finally { limit.clear(); }
}
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);
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 ?? {};
return {
org: id, ref: t.task_ref, tracker: org.tracker, title: f.title ?? null, done: Boolean(f.done), bucket: f.bucket ?? null,
assignees: arr(f.assignees).filter(u => bots.has(u)).map(u => bots.get(u)).sort(), labels: arr(f.labels), priority: t.priority ?? null,
requirement: t.requirement ?? null, project: ref(typeof t.project_key === 'string' ? byKey.get(t.project_key) : null), changedAt: t.at ?? null,
};
});
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 = orgRows(r.org, r.data, r.doc, nowMs);
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;
body.tasks = merged.tasks.filter(t => !p || (t.org === p.org && (t.project?.id === p.key || t.project?.key === p.key)))
.sort((a, b) => a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref));
}
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);
}
@@ -0,0 +1,264 @@
// U1 (#1873, PRD 1.20 REQ-WS-1..8): the cross-organization read behind GET /api/workspace.
// Merge, membership, partial failure, timeout, sort and filter, the copied decide command, the
// route through startServer, and the assistant-read stop. Every bus here is a fake options
// object or a real org registry in this test's own scratch; no test reaches a live bus.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createOrg, showOrg, templateRender } from '../../organization/src/org-service.mjs';
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
import { organizationConsole } from '../src/organizations.mjs';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { parseWorkspaceQuery, readWorkspace, workspaceHTTP, WorkspaceQueryError, WORKSPACE_POLL_MS } from '../src/workspace-read.mjs';
import * as broker from '../../bus/src/broker.mjs';
const principal = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
const NOW = Date.parse('2026-11-09T13:00:00.000Z');
const now = () => NOW;
const never = () => new Promise(() => {});
// One org's bus as the org pages see it: options().bus and options().chat. `over` replaces reads.
function options(data, over = {}) {
const value = name => (over[name] ? over[name]() : Promise.resolve(data[name] ?? []));
return {
bus: { inbox: () => value('inbox'), agents: () => value('agents'), 'thread.claims': () => value('claims'), tasks: () => value('tasks'),
usage: () => value('usage'), limits: () => value('limits'), decisions: () => value('decisions') },
chat: { threads: () => value('threads'), projects: () => value('projects') },
};
}
const healthy = async () => ({ healthy: true, pid: 4242, boot: null });
function member(id, data, { status = healthy, over = {}, doc = {} } = {}) {
return { id, doc: { appearance: { theme: `${id}-theme` }, ...doc }, status, options: () => options(data, over) };
}
const organizationsOf = (...members) => ({ members: async () => members, list: async () => [], resolve: async () => null });
const ACME = {
projects: [
{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: '2026-11-09T10:00:00.000Z' },
{ id: 'p-old', repo: 'old', name: 'Old brochure', archived: true, at: '2026-10-01T00:00:00.000Z' },
],
tasks: [
{ task_ref: 'vk:1', project_key: 'site', priority: 2, at: '2026-11-09T11:00:00.000Z', fields: { title: 'Hero copy', done: false, assignees: [7, 99], bucket: 'Doing', labels: ['web'] } },
{ task_ref: 'vk:2', project_key: 'site', at: '2026-11-08T00:00:00.000Z', fields: { title: 'Logo', done: true } },
{ task_ref: 'vk:3', project_key: 'site', at: '2026-11-08T00:00:00.000Z', fields: { title: 'Removed', gone: true } },
],
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'waiting', at: '2026-11-09T09:00:00.000Z', inputNeeded: [{ task_ref: 'vk:1', at: '2026-11-09T12:00:00.000Z' }], stranded: [] }],
claims: [{ thread: 't-1', instance: 'assistant', holder_run: 'run-2', harness: 'claude', at: '2026-11-09T08:00:00.000Z' }],
threads: [{ id: 't-1', project: 'p-site' }],
inbox: [{ id: 'd1234567', question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', task_ref: 'vk:1', at: '2026-11-09T12:00:00.000Z', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }] }],
usage: { total: { tokens: 1200 } },
limits: { tokens: [{ scope: 'org', used: 1200, cap: 5000 }] },
decisions: [{ at: '2026-11-09T00:00:00.000Z', closed: { op: 'resolved', at: '2026-11-09T00:10:00.000Z' } }],
};
const ACME_DOC = { tracker: { kind: 'vikunja' }, roles: { coder: { tracker: { botId: 7 } }, assistant: {} } };
const BETA = {
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: '2026-11-07T00:00:00.000Z' }],
tasks: [{ task_ref: 'local:1', project_key: 'shop', at: '2026-11-07T00:00:00.000Z', fields: { title: 'Price list', done: false } }],
};
test('merge: every item from two buses carries its org, and the snapshot names both orgs', async () => {
const body = await readWorkspace({ organizations: organizationsOf(member('acme', ACME, { doc: ACME_DOC }), member('beta', BETA)), session: principal, now });
assert.equal(body.pollMs, WORKSPACE_POLL_MS);
assert.deepEqual(body.summary, { read: 2, of: 2 });
assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker, o.theme]), [['acme', 'ok', 'vikunja', 'acme-theme'], ['beta', 'ok', 'built-in', 'beta-theme']]);
assert.deepEqual(body.orgs[0].roles, ['assistant', 'coder']);
for (const key of ['projects', 'sessions', 'inbox', 'tasks']) for (const row of body[key]) assert.ok(['acme', 'beta'].includes(row.org), `${key} row without org`);
assert.deepEqual(body.metrics.orgs.map(m => m.org), ['acme', 'beta']);
// Archived projects are hidden by default; the default sort is newest activity first.
assert.deepEqual(body.projects.map(p => `${p.org}/${p.key}`), ['acme/site', 'beta/shop']);
const site = body.projects[0];
assert.deepEqual({ ...site }, { org: 'acme', id: 'p-site', key: 'site', name: 'Website', status: 'active', state: 'working', git: null,
sessions: { working: 1, waiting: 1, idle: 0 }, openTasks: 1, needsYou: 1, lastActivity: '2026-11-09T12:00:00.000Z' });
const role = body.sessions.find(s => s.kind === 'role'), thread = body.sessions.find(s => s.kind === 'thread');
assert.equal(role.org, 'acme'); assert.equal(role.role, 'coder'); assert.equal(role.state, 'waiting'); assert.equal(role.inputNeeded, 1);
assert.deepEqual(role.task, { ref: 'vk:1', title: 'Hero copy' }); assert.equal(role.project.key, 'site'); assert.equal(role.projectFrom, 'task');
assert.equal(thread.thread, 't-1'); assert.equal(thread.project.key, 'site'); assert.equal(thread.projectFrom, 'thread');
const ask = body.inbox[0];
assert.equal(ask.org, 'acme'); assert.equal(ask.raisedBy, 'coder'); assert.deepEqual(ask.session, { role: 'coder', run: 'run-1' }); assert.equal(ask.project.key, 'site');
// The gone task is dropped; only bot ids from the org file become role names.
assert.deepEqual(body.tasks.map(t => `${t.org}/${t.ref}`), ['acme/vk:1', 'acme/vk:2', 'beta/local:1']);
assert.deepEqual(body.tasks[0].assignees, ['coder']); assert.equal(body.tasks[0].bucket, 'Doing'); assert.equal(body.tasks[2].tracker, 'built-in');
assert.deepEqual(body.metrics.orgs[0], { org: 'acme', needsYou: 1, oldestNeedsYouAt: '2026-11-09T12:00:00.000Z', sessions: { working: 0, waiting: 1, idle: 0, live: 1 },
openTasks: 1, doneThisWeek: 1, projects: { active: 1, archived: 1 }, usage: { tokens: 1200 }, limits: [{ scope: 'org', used: 1200, cap: 5000 }], decisionWaitMedianMs: 600000 });
assert.deepEqual(body.metrics.totals, { needsYou: 1, oldestNeedsYouAt: '2026-11-09T12:00:00.000Z', sessions: { working: 0, waiting: 1, idle: 0, live: 1 }, openTasks: 2, doneThisWeek: 1, projects: { active: 2, archived: 1 } });
});
test('sections: only the asked sections are read and returned', async () => {
const asked = [];
const tracked = member('acme', ACME, { over: Object.fromEntries(['inbox', 'agents', 'claims', 'threads', 'tasks', 'projects', 'usage', 'limits', 'decisions'].map(n => [n, () => { asked.push(n); return Promise.resolve(ACME[n] ?? []); }])) });
const body = await readWorkspace({ organizations: organizationsOf(tracked), session: principal, query: parseWorkspaceQuery('sections=tasks'), now });
assert.deepEqual(Object.keys(body).filter(k => ['projects', 'sessions', 'inbox', 'tasks', 'metrics'].includes(k)), ['tasks']);
assert.deepEqual(asked.sort(), ['inbox', 'projects', 'tasks']);
});
test('membership: a foreign org is never read or named; a broken own org shows as invalid; options get the timeout', async t => {
const root = await fs.mkdtemp(join(tmpdir(), 'wsr-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test', principal };
await fs.mkdir(ctx.configDir, { mode: 0o700 }); await fs.mkdir(ctx.dataRoot, { mode: 0o700 });
for (const id of ['alpha', 'beta', 'private-tenant']) await createOrg(ctx, id, await templateRender(id));
await fs.writeFile(join(ctx.dataRoot, 'org-runtime', 'private-tenant', 'owner.json'), JSON.stringify({ ...principal, sub: 'different-owner' }));
const statusAsked = [], optionsAsked = [];
const orgs = organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPaths: () => ({}),
busStatus: async (_ctx, id) => { statusAsked.push(id); return id === 'beta' ? { healthy: false, pid: null, boot: { ok: false, at: '2026-11-09T12:59:00.000Z', reason: `config missing at ${ctx.dataRoot}/org-runtime/beta/x.json` } } : { healthy: true, pid: 7 }; },
options: o => { optionsAsked.push({ id: o.id, timeoutMs: o.timeoutMs }); return options(o.id === 'alpha' ? BETA : {}); } });
const body = await readWorkspace({ organizations: orgs, session: principal, timeoutMs: 1500, now, ctx });
assert.deepEqual(body.orgs.map(o => [o.org, o.state]), [['alpha', 'ok'], ['beta', 'down']]);
assert.equal(JSON.stringify(body).includes('private-tenant'), false);
assert.deepEqual(statusAsked.sort(), ['alpha', 'beta']);
assert.deepEqual(optionsAsked, [{ id: 'alpha', timeoutMs: 1500 }]);
// The boot refusal is shown with the data root made relative.
assert.equal(body.orgs[1].reason, 'the bus refused to start: config missing at <data root>/org-runtime/beta/x.json');
assert.equal(JSON.stringify(body).includes(root), false);
// A different principal sees none of these orgs.
const outsider = await readWorkspace({ organizations: orgs, session: { ...principal, sub: 'outsider' }, now, ctx });
assert.deepEqual(outsider.orgs, []);
// The owner of a broken org sees it as invalid, with no host path.
await fs.unlink(join(ctx.configDir, 'businesses', 'alpha.json'));
const broken = await readWorkspace({ organizations: orgs, session: principal, now, ctx });
assert.equal(broken.orgs[0].org, 'alpha'); assert.equal(broken.orgs[0].state, 'invalid');
assert.equal(broken.orgs[0].reason.includes(ctx.configDir), false);
assert.deepEqual(broken.summary, { read: 0, of: 2 });
});
test('partial failure: down, starting and a status that throws each get a state and a time; the others return', async () => {
const ctx = { dataRoot: '/srv/fake-data', configDir: '/srv/fake-config' };
const body = await readWorkspace({ now, ctx, session: principal, organizations: organizationsOf(
member('acme', ACME, { doc: ACME_DOC }),
member('down', BETA, { status: async () => ({ healthy: false, pid: null, boot: { ok: true, at: '2026-11-09T12:00:00.000Z' } }) }),
member('starting', BETA, { status: async () => ({ healthy: false, pid: 991, boot: null }) }),
member('stale', BETA, { status: async () => { throw new Error('host state names /srv/fake-data/org-runtime/stale/bus-host and /etc/secret-place'); } }),
member('reads-fail', BETA, { over: { tasks: () => Promise.reject(Object.assign(new Error('nope'), { code: 'bus-unavailable' })), projects: () => Promise.reject(new Error('raw text /etc/x')) } }),
) });
const byOrg = Object.fromEntries(body.orgs.map(o => [o.org, o]));
assert.equal(byOrg.acme.state, 'ok');
assert.equal(byOrg.down.state, 'down'); assert.equal(byOrg.down.reason, 'the bus is not running');
assert.equal(byOrg.starting.state, 'starting'); assert.equal(byOrg.starting.reason, 'the bus host is running but does not answer yet');
assert.equal(byOrg.stale.state, 'down');
assert.equal(byOrg.stale.reason, 'host state names <data root>/org-runtime/stale/bus-host and <host path>');
// A read that fails inside an answering bus is that read's error code, never its message.
assert.equal(byOrg['reads-fail'].state, 'ok');
assert.deepEqual(byOrg['reads-fail'].errors, { tasks: 'bus-unavailable', projects: 'read-failed' });
for (const o of body.orgs) { assert.equal(o.at, new Date(NOW).toISOString()); assert.equal(typeof o.readMs, 'number'); }
assert.deepEqual(body.summary, { read: 2, of: 5 });
assert.ok(body.projects.every(p => p.org === 'acme'));
});
test('timeout: a bus that never answers, or whose state is never read, is marked timeout within the bound', async () => {
const started = Date.now();
const body = await readWorkspace({ timeoutMs: 150, session: principal, organizations: organizationsOf(
member('acme', ACME, { doc: ACME_DOC }),
member('slow', BETA, { over: { tasks: never } }),
member('stuck', BETA, { status: never }),
) });
const elapsed = Date.now() - started;
assert.ok(elapsed < 1500, `took ${elapsed} ms`);
const byOrg = Object.fromEntries(body.orgs.map(o => [o.org, o]));
assert.equal(byOrg.acme.state, 'ok');
assert.equal(byOrg.slow.state, 'timeout'); assert.equal(byOrg.slow.reason, 'the bus did not answer within 150 ms');
assert.equal(byOrg.stuck.state, 'timeout'); assert.equal(byOrg.stuck.reason, 'the bus state was not read within 150 ms');
assert.equal(body.timeoutMs, 150);
assert.ok(body.tasks.every(t => t.org === 'acme'));
});
test('query: strict keys and values', () => {
const q = parseWorkspaceQuery('?sections=projects,tasks&org=acme,beta&status=active,archived&state=working&name=Web&sort=name&project=acme:site');
assert.deepEqual([...q.sections], ['projects', 'tasks']); assert.deepEqual([...q.orgs], ['acme', 'beta']);
assert.deepEqual([...q.status], ['active', 'archived']); assert.deepEqual([...q.state], ['working']);
assert.equal(q.name, 'web'); assert.equal(q.sort, 'name'); assert.equal(q.order, 'asc'); assert.deepEqual(q.project, { org: 'acme', key: 'site' });
assert.equal(parseWorkspaceQuery('').order, 'desc');
for (const bad of ['secton=projects', 'org=acme&org=beta', 'sections=', 'sections=chat', 'org=Bad Org', 'status=paused', 'state=busy', `name=${'x'.repeat(201)}`, 'name=%20', 'sort=size', 'order=up', 'project=acme', 'project=acme:a/b'])
assert.throws(() => parseWorkspaceQuery(bad), WorkspaceQueryError, bad);
});
test('sort and filter: projects by org, status, state, name and activity; tasks by org and project', async () => {
const organizations = organizationsOf(member('acme', ACME, { doc: ACME_DOC }), member('beta', BETA));
const read = search => readWorkspace({ organizations, session: principal, now, query: parseWorkspaceQuery(search) });
const keys = rows => rows.map(p => `${p.org}/${p.key}`);
assert.deepEqual(keys((await read('status=active,archived')).projects), ['acme/site', 'beta/shop', 'acme/old']);
assert.deepEqual(keys((await read('status=active,archived&sort=activity&order=asc')).projects), ['acme/old', 'beta/shop', 'acme/site']);
assert.deepEqual(keys((await read('status=active,archived&sort=name')).projects), ['acme/old', 'beta/shop', 'acme/site']);
assert.deepEqual(keys((await read('status=active,archived&sort=org&order=desc')).projects), ['beta/shop', 'acme/site', 'acme/old']);
assert.deepEqual(keys((await read('status=active,archived&sort=status')).projects), ['acme/site', 'beta/shop', 'acme/old']);
assert.deepEqual(keys((await read('status=archived')).projects), ['acme/old']);
assert.deepEqual(keys((await read('state=none')).projects), ['beta/shop']);
assert.deepEqual(keys((await read('name=SHO')).projects), ['beta/shop']);
const onlyBeta = await read('org=beta');
assert.deepEqual(onlyBeta.orgs.map(o => o.org), ['beta']); assert.deepEqual(keys(onlyBeta.projects), ['beta/shop']);
// An org filter naming an org the viewer is not in returns nothing, not an error.
assert.deepEqual((await read('org=nobody')).orgs, []);
assert.deepEqual((await read('project=acme:site')).tasks.map(t => t.ref), ['vk:1', 'vk:2']);
assert.deepEqual((await read('project=acme:p-site')).tasks.map(t => t.ref), ['vk:1', 'vk:2']);
assert.deepEqual((await read('project=beta:site')).tasks, []);
});
test('decide: a copied command names the org; an unsafe id or key gets none', async () => {
const data = { ...ACME, inbox: [...ACME.inbox, { id: 'd99 ; rm -rf ~', question: 'x', options: [{ key: 'ok', text: 'ok' }] }, { id: 'e7654321', question: 'y', options: [{ key: 'a b', text: 'a b' }] }] };
const body = await readWorkspace({ organizations: organizationsOf(member('acme', data, { doc: ACME_DOC })), session: principal, now, query: parseWorkspaceQuery('sections=inbox') });
const byId = Object.fromEntries(body.inbox.map(d => [d.id, d]));
assert.deepEqual(byId.d1234567.decide, [{ key: 'yes', command: 'mosaic decide d1234567 yes --org acme' }, { key: 'no', command: 'mosaic decide d1234567 no --org acme' }]);
assert.deepEqual(byId['d99 ; rm -rf ~'].decide, [{ key: 'ok', command: null }]);
assert.deepEqual(byId.e7654321.decide, [{ key: 'a b', command: null }]);
// Gated first, then oldest first.
assert.equal(body.inbox[0].id, 'd1234567');
});
function fakeRes() {
return { status: null, body: null, writeHead(status) { this.status = status; }, end(text) { this.body = JSON.parse(text); } };
}
test('handler: non-GET, no registry, non-owner, bad query and an unreadable registry', async () => {
const organizations = organizationsOf(member('acme', ACME));
const run = async (opts, method = 'GET') => { const res = fakeRes(); await workspaceHTTP({ method }, res, { session: principal, owner: true, organizations, ...opts }); return res; };
assert.equal((await run({}, 'POST')).status, 405);
assert.deepEqual((await run({ organizations: null })).body, { error: 'org-unavailable' });
// A granted user or the tester reaches the hook with owner false and no owner session.
for (const who of [{ owner: false }, { session: null }]) {
const res = await run(who);
assert.equal(res.status, 403); assert.deepEqual(res.body, { error: 'owner-only', message: 'the cross-organization view is the owner\'s' });
}
const bad = await run({ search: '?org=acme&org=acme' });
assert.equal(bad.status, 400); assert.equal(bad.body.error, 'invalid-request');
const logged = [];
const down = await run({ organizations: { members: async () => { throw new Error('/srv/x/org-runtime.json: Unexpected token'); } }, log: line => logged.push(line) });
assert.equal(down.status, 503); assert.deepEqual(down.body, { error: 'org-unavailable', message: 'Organization registry could not be read' });
assert.deepEqual(logged, ['workspace: organization registry could not be read']);
});
async function serve(t, organizations, sso = createDevOwner()) {
const s = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, organizations, log: () => {}, workspaceTimeoutMs: 200 });
t.after(() => new Promise(r => { s.close(r); s.closeIdleConnections(); }));
return `http://127.0.0.1:${s.address().port}`;
}
test('route: the dev owner reads GET /api/workspace; a granted user gets owner-only; the timeout option applies', async t => {
const seen = [];
const organizations = { list: async () => [], resolve: async () => null, members: async (session, opts) => { seen.push({ sub: session.sub, timeoutMs: opts.timeoutMs }); return [member('acme', ACME, { doc: ACME_DOC }), member('slow', BETA, { status: never })]; } };
const base = await serve(t, organizations);
const response = await fetch(`${base}/api/workspace?sections=projects,metrics`), body = await response.json();
assert.equal(response.status, 200); assert.equal(response.headers.get('cache-control'), 'no-store');
assert.deepEqual(body.orgs.map(o => [o.org, o.state]), [['acme', 'ok'], ['slow', 'timeout']]);
assert.equal(body.timeoutMs, 200); assert.deepEqual(seen, [{ sub: 'local-dev-owner', timeoutMs: 200 }]);
assert.equal((await fetch(`${base}/api/workspace?bogus=1`)).status, 400);
// POST has no route here: with no Origin the dev fence refuses it, and with the page's Origin the
// organizations fence answers as for any unknown /api path.
assert.equal((await fetch(`${base}/api/workspace`, { method: 'POST' })).status, 403);
const post = await fetch(`${base}/api/workspace`, { method: 'POST', headers: { origin: base, 'content-type': 'application/json' }, body: '{}' });
assert.equal(post.status, 404); assert.deepEqual(await post.json(), { error: 'org-unavailable' });
// A granted user's session (session.user set) is fenced before any org is read.
const granted = { ...createDevOwner(), session: () => ({ ...principal, sub: 'alice-sub', user: 'alice', name: 'Alice' }) };
const userBase = await serve(t, organizations, granted);
const refused = await fetch(`${userBase}/api/workspace`);
assert.equal(refused.status, 403); assert.equal((await refused.json()).error, 'owner-only');
assert.equal(seen.length, 1);
});
test('assistant read is designed, not built: no workspace broker verb, assistant authority unchanged', () => {
const role = JSON.parse(readFileSync(new URL('../../../roles/assistant.json', import.meta.url), 'utf8'));
assert.deepEqual(role.authority, { withinRole: ['message.send', 'record.write'], crossRole: [] });
const verbs = new Set(Object.values(broker).filter(Array.isArray).flat().filter(v => typeof v === 'string'));
assert.ok(verbs.has('inbox'), 'the verb lists were read');
assert.deepEqual([...verbs].filter(v => /^(workspace|orgs?)\b/.test(v)), []);
});