Compare commits

..
24 Commits
Author SHA1 Message Date
jason.woltje feb98d5529 test(cli): compare file bytes in the org tester-init refusal check
Sol review of 221c1e9d3 (P2): the snapshot held only path names, so a
rewrite of .mosaic-org.json before the refusal passed. It now holds names,
types and file bytes; the reviewer's rewrite mutant fails it.
2026-11-09 19:17:28 -06:00
jason.woltje 221c1e9d35 Fix tester project tests for organization refusal 2026-11-09 19:05:09 -06:00
jason.woltje 544918a715 docs: record U1 and SYS1 landings (#1873) 2026-11-09 19:00:25 -06:00
jason.woltje 2c6556ab1e fix(sys1): count project system types in the restore guard and admit the CLI verbs
Sol review sys1-review-r1 (changes 3):
- P1: project_system_types joins stack-release.sh COUNTED and the T45 LISTED pin; the
  post-backup refusal and in-run recount tests now cover it.
- P2: the CLI channel admits project.system-types and project.system-types.set; a new test
  drives both through run() and the real channel module (1/1 red without the fix).
- P2: the #811 migration-reader pin moves to 19 steps ending 3t-3u.
2026-11-09 18:46:36 -06:00
jason.woltje f4bfd3a744 feat(plugins,organization): system types as a plug-in kind (REQ-SYS-1, #1873)
Adds the system-type kind with five modules (coding, creative-writing,
marketing, montage, business-management), an org-level enabled list
validated fail-closed, project selections limited to the org's list,
read verbs, and bus migration 3t-3u. REQ-SYS-2 toolsets are designed and
deferred: they need a role authority contract.
2026-11-09 18:46:35 -06:00
jason.woltje d35447005a Isolate invalid workspace reads and cancel timed-out org work 2026-11-09 18:22:27 -06:00
jason.woltje 2109458b3f 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.
2026-11-09 17:54:06 -06:00
jason.woltje 1616d1dd6f docs: BUILD-LOG and session line for PRD 1.20 and defect l (#1873, #1886) 2026-11-09 17:38:26 -06:00
jason.woltje 7a97cfc4f3 fix(bus): make the private store root at start when a reference names the store (#1886)
Adding an agent with a store credential on an instance with no
<configDir>/secrets/agents left the bus unable to start
(credential-file): Credentials refuses a missing store root, and only
issue() made it, which needs a running bus. Start now makes the root
(0700) the same way issue() does, only when some reference has
store: true. Credentials' own refusal (credential-store T1) is unchanged,
and a root with loose modes or a symlink still refuses start.

Tests: two new credential-verbs tests; the creation test fails on
483eccb3d with credential-file. All 171 bus test files run one at a
time: 1713 pass, 0 fail.
2026-11-09 17:10:09 -06:00
jason.woltje 03fe321b12 docs(prd): land version 1.20, the unified workspace (#1873)
Jason approved draft 2 on 2026-11-09 at 22:57Z: "The file looks fine
other than that." The exception is the Montage module name, deferred
since 19:26Z. Amends REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3
and adds 17 requirements (101 to 118): REQ-WS-1..8, REQ-AGENT-1..3,
REQ-SYS-1..2, REQ-ACCT-1..2, REQ-SETUP-1..2. requirements.json and the
tasks and bus count pins follow.

Tests: 68 pass, 1 fail. The failure is stack-copy.test.mjs (20/1), which
fails the same way on origin/refactor 483eccb3d: npm's pinned install
can't find node-gyp/bin/node-gyp.js in the test Node installation.
2026-11-09 17:04:31 -06:00
jason.woltje 483eccb3dd docs: BUILD-LOG and session line for organizations F3 (#1873) 2026-11-09 16:45:56 -06:00
jason.woltje a8f9f09573 Merge remote-tracking branch 'origin/refactor' into org-f3-merge 2026-11-09 14:07:52 -06:00
jason.woltje 0a73279d62 fix(webui): F3 review round: instances trail line, honest dev-mode README
The instances route now passes the owner trail note, so an organization
instance change appends the actions.jsonl line the README promises (P3).

The README claimed no agent reaches the F3 controls. On a dev local owner
Console that is false: every local request acts as the owner, which the
pinned startup warning already says. The README now states it and names
sign-in as the boundary; a local-agent peer refusal is a follow-up (P1).
2026-11-09 14:07:52 -06:00
jason.woltje 73df1a687f docs: BUILD-LOG for organizations landings #1876-#1881 (#1873) 2026-11-09 14:00:54 -06:00
jason.woltje 999d173fd4 Merge refactor fdd6658a6 into F3 (E2E5 fixes)
app.js busDown conflict: refactor's order (org list read before
bus-status, E2E5 review P2) with F3's Restart the bus attachment on
the Bus unavailable banner.
2026-11-09 13:54:46 -06:00
jason.woltje fdd6658a65 fix(webui): refresh the Settings bus control on every org list read
E2E5 review P2. An open Settings page kept Done after the bus died
because only opening the organization reread bus health. display.js
now announces each fresh list read, org-create.js redraws the setup
steps when the selected org's bus state flips (choices kept, no draft
guard), and app.js reads the list before bus-status so a failed
bus-status read no longer skips it. Regression: healthy, Refresh down
shows Start, Refresh up hides it; red on 8a8edc44b.
2026-11-09 13:53:19 -06:00
jason.woltje e98909d907 Merge refactor 2bed0aebe into F3 (console restart, owner settings writes)
Conflicts kept both sides: rename stage B organization paths and the
relative-launch re-exec with F3 imports, F2 onboarding script and the
north-star fence exemption with F3 console-restart.js and ORG_OWNER_WRITE,
and the /api/config fields from both.
2026-11-09 13:41:20 -06:00
jason.woltje 1b6cac2bdf feat(webui): restart the Console, the org bus and agents from the WebUI (F3)
Self-service operation from the WebUI, for a dev instance first.

- Settings > Console > Restart Console. A foreground `mosaic console
  start` now supervises the webui: the child exits 75 after answering a
  restart and the supervisor relaunches it with the same argv. Org buses
  keep running. /api/config carries a per-process boot id, so the open
  page polls until a new process answers and then reloads. A detached
  Console has no supervisor and answers 409.
- Restart bus on every "Restart needed" receipt and on the bus-down
  banner: POST /api/orgs/<id>/restart/bus restarts a running bus or
  starts a stopped one, then shows a done receipt with the time.
- Settings > Routing: after a save, the page lists how the change
  reaches each agent (next start, session end, thread idle) and offers
  "Restart <agent> now" for a running one. A session is terminated with
  the reason "route change" and launched again; a thread is terminated.
- Organization settings: the signed-in owner's settings, routing,
  agent, plug-in and Model proxy writes now reach the selected org's
  bus instead of answering 403. Org mode exists only under
  --dev-local-owner. Proxy setup stays a terminal step; a dev-only note
  names the two commands.

Every control needs the signed-in owner session, the page's exact
Origin and Sec-Fetch-Site: same-origin. The tester listener, granted
users, API tokens, agents and bus roles are refused before any work.
Each use is written to <dataRoot>/console/actions.jsonl (0600, no
symlinks) with who, when, what and the outcome. The Console and bus
restart trails fail closed. No change to credential handling, the
broker, the bus tenant boundary or v2 schemas.
2026-11-09 13:37:28 -06:00
jason.woltje 8a8edc44b6 Merge refactor 2bed0aebe into E2E5 fixes 2026-11-09 13:30:24 -06:00
jason.woltje d6eff452e3 fix(webui): recover archived organizations and console controls 2026-11-09 13:29:23 -06:00
jason.woltje 2bed0aebeb Merge refactor bad760ce0 into U0 samples 2026-11-09 13:26:39 -06:00
jason.woltje af674e14c1 docs(design): keep the WebUI four-tile logo in the U0 samples; record U0 approval (#1873)
Jason approved the U0 screens on 11-09 with one change: keep the temporary
logo the site already uses. The samples drew the design system's Relay
placeholder; they now draw the WebUI's four-tile mark with the same fills
as processing.css. The direction doc records the approval verbatim, the
deferred Montage name, and the open-question defaults phase U takes.
2026-11-09 13:26:39 -06:00
jason.woltje 1cc09a1cad docs(design): match U0 org dashboard sessions to the main dashboard count (#1873)
The org dashboard listed an idle chief as a session, so Distinctly
Digital showed four sessions there and three on the main dashboard.
The chief has no running session; it stays on the agents screen.
2026-11-09 12:38:03 -06:00
jason.woltje 36c8260d7d docs(design): add U0 unified workspace samples (#1873)
Eight static screens for PRD 1.20 phase U, for Jason's approval
before U1-U5 are built: sidebar with collapsible Organizations and
org marks, cross-org dashboard, org dashboard with docs, KPIs,
metrics, sessions and a settings index, projects across orgs, one
inbox, Kanban and list tasks with tracker badges, side-by-side panes,
and agents at root, org and project level.

Mock data only: three orgs (Home, Distinctly Digital, Forge), ten
projects, no real names or credentials. workspace.css imports
tokens.css and marks each extension EXT 1-10. render.mjs serves the
samples on 127.0.0.1 port 0 and renders 32 PNGs at 1440x900 and
390x844, light and dark, failing on script errors, failed requests,
sideways scroll or unloaded fonts. No WebUI code changes.
2026-11-09 12:36:28 -06:00
113 changed files with 4678 additions and 297 deletions

No files matched your search

+8
View File
@@ -6339,3 +6339,11 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
2026-11-07 UTC | Organizations S1 — implementation evidence | Organization-root bus and launcher paths, guarded lifecycle and Console launch/stop were implemented locally. Focused Node 26 tests and host regressions passed; the fresh-organization notifier choice, shared-proxy S2 integration, and remaining instance-root consumers are not complete. Exact commands, inventory, failures and boundaries: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s1.md. No push or deployment. Gitea issue creation was excluded by the no-network and no-external-communication limits.
2026-11-09 UTC | Unified workspace (#1873) — direction recorded | Before: the 11-07 rulings had one Console switch between organizations from Settings, tasks only in each org queue, and an agent bound to one organization (REQ-USER-1). After: Jason's 11-09 direction and answers recorded in docs/plans/2026-11-09_unified-workspace-owner-direction.md (all orgs visible at once, per-org task stores with a Vikunja option and an aggregated Kanban, root/org/project agents with one cross-org assistant, system type plug-ins, self-service restart and model switch). PRD 1.20 draft 1 written for his exact-text approval; the PRD itself is unchanged. The unpushed 11-07 rulings record (ad8140065) lands with it.
2026-11-09 UTC | Organizations friction round, landings #1876-#1881 (#1873) | Before: refactor 00ef0bd30. A Console launched by a relative path showed Request status unavailable, the overview printed the north star as raw markdown, packages/business and the business command spellings were the only names, Quick Setup ended with no route to a north star, an archived org could not be restored from a fresh page, and a dead bus after setup had no browser control. After, each through one Codex Sol xhigh review round plus a lead or builder fix round: #1876 re-execs a relative Console launch with its absolute script path (build 744753d7e, reproduced 121/0; review APPROVE 0/0/1). #1877 F1 renders the north star as sanitized markdown with a short preview (review APPROVE 0/0/1; live retest 4/0 twice). #1878 rename stage B moves packages/business to packages/organization and adds the org command spellings, keeping the business ones and their messages (builder 6828/6, see report; review CHANGES 0/1/0, fixed in e19db4009). #1879 F2 adds the onboarding interview after Quick Setup and on the project page, with human-only north-star approval (`mosaic project north-star --approve`) and no new agent grant (builder 411/0; review CHANGES 0/1/1: a refused start left its button disabled, fixed in 5937a977f along with the P3 stale approval capability). #1880 lands the U0 unified workspace samples, approved by Jason with the four-tile logo kept; the module names stay undecided at his request. #1881 E2E round 5 fixes restore from a fresh page, show an owner bus Start whenever the org list reads the bus down, say room changes are unavailable on org Consoles and recover the composer (builder 258/0; review CHANGES 0/1/0, 231/0: Settings kept Done after the bus died because only opening the org reread health). Lead fix fdd6658a6: every org list read is announced and the open setup redraws on a flip, and app.js reads the list before bus-status so a failed bus-status read cannot skip it; regression red on 8a8edc44b, 18 touched webui files 199/0 (Node 26.12.1, one file at a time). uiorg redeployed at fdd6658a6, 19:53Z. Open follow-ups: a down bus offers both the F3 banner Restart and the Settings Start, focus can move when setup redraws, and a Console stop also stops the org buses it started. Reports: /mnt/storage/scratch/mosaic-lead/reports/org-{reqget-2,f1,rename-b,f2,u0-design,e2e5}-*.md.
2026-11-09 UTC, organizations F3 (#1873, PR #1884, a8f9f0957). The WebUI restarts the Console (supervised Consoles exit and `mosaic console start` relaunches them), restarts an organization's bus from the bus-down banner with a done receipt, and sends the owner's routing, agent, instance, name, assistant, limits and model proxy writes to the selected organization's bus. Routing apply lists how a change reaches each saved agent and offers Restart NAME now. Console restarts, bus restarts and routing, agent and instance changes append to `<dataRoot>/console/actions.jsonl`. Builder plus two refactor merges: combined list 354/0 on 999d173fd. Sol review CHANGES 1/0/1. P1: on a `--dev-local-owner` Console a local agent can send the right Origin and Fetch Metadata headers and reach these controls. The pinned startup warning and the accepted no-auth dev flags already cover this, but the README claimed no agent reaches them. Lead decision: correct the README and file the cooperative local-agent peer refusal as #1883, without building it in this round. P3: the instances route wrote no trail line. Fixed in 0a73279d6; the regression test fails on 999d173fd and passes on the fix. After the refactor merge, 25 touched or README-reading files 195/0 (Node 26.12.1, one file at a time, env -i). uiorg redeployed at a8f9f0957, 22:45Z; live config offers consoleRestart and org settingsSend. Correction to this session's process: after the test run completed I replied "No response requested" and stopped until Jason asked. Open follow-ups: defect l (a bus restart after adding a credentialed agent can refuse to start), the bus restart refusal code, a stale-lock Recover control, one control for banner Restart and Settings Start, #1883. Report: /mnt/storage/scratch/mosaic-lead/reports/org-f3-review.md.
2026-11-09 UTC, PRD 1.20 and defect l (#1873, #1886; PRs #1887 03fe321b1 and #1888 7a97cfc4f). PRD 1.20: Jason approved draft 2 at 22:57Z ("The file looks fine other than that"; the Montage module name stays deferred). The PRD now has 118 requirements, with REQ-WS-1..8, REQ-AGENT-1..3, REQ-SYS-1..2, REQ-ACCT-1..2 and REQ-SETUP-1..2 added and REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3 amended. Builder Codex Terra xhigh declined to commit because stack-copy failed 20/1 (node-gyp missing from the test Node's npm); that failure is pre-existing on 483eccb3d, so the lead committed. Sol review APPROVE 0 (word-for-word diff against draft 2, catalog order, both requirements tests). Defect l: before, an agent that declared a store credential made the next bus start refuse, because Credentials refuses a missing store root and only issue() created it. After: start creates the root 0700 when any reference names the store; a root that exists with loose modes or as a symlink still refuses. Two new credential-verbs tests fail on the parent. Bus suite 171 files one at a time, 1713/0 (Node 26.12.1, env -i). Sol review APPROVE 0. uiorg redeployed at 7a97cfc4f, 23:15Z. Follow-up: the stack-copy node-gyp test environment. Reports: /mnt/storage/scratch/mosaic-lead/reports/{prd120-land,prd120-review,defectl-review}.md.
2026-11-10 UTC, unified workspace U1 and SYS1 (#1873; PRs #1889 d35447005 and #1890 2c6556ab1). U1, before: each org page read only its own bus and nothing read every org at once. After: /api/workspace returns projects, sessions, inbox, tasks and metrics from every org the owner holds, each row tagged with its org and each org with a state (ok, starting, down, timeout, invalid); `mosaic console restart` and `mosaic org reload <org>` send the F3 routes. Sol review CHANGES 0/2 on 2109458b3: malformed data from one org turned the whole read into a 503, and the per-org deadline left sockets and transport children running. Terra fix 0672d5fe8 validates each org's result inside its boundary and carries an absolute deadline and AbortSignal through the status check and transport; the new workspace-failures test is 0/10 on the parent and 10/0 on the fix. The lead re-pinned the webui bus.mjs hash in bus channel-proof (the deliberate signal/deadline change; nonces, argv and limits unchanged) and amended as d35447005. SYS1, before: no system-type kind. After: a system-type plug-in kind with five modules (business-management, coding, creative-writing, marketing, montage), an org enable list under plugins.system-type, owner-only project selection with an append-only project_system_types table (migration 3t-3u) and CLI verbs; the modules grant no tools yet (REQ-SYS-2 deferred). Sol review CHANGES 1/2 on a9629f332: the restore guard did not count project_system_types, the CLI channel did not admit the two project verbs, and the #811 migration-reader pin still expected 18 steps. Lead fix 2c6556ab1 (rebased on d35447005) adds the table to COUNTED and LISTED with refusal and in-run recount tests, admits both verbs with a run()-level channel test (red on the unfixed channel), and moves the pin to 19 steps ending 3t-3u; fix files 323/0 and rebase overlap files 63/0 (Node 26.12.1, one file at a time, env -i). Full gate on a9629f332 4040/5: the two stack-release pins above, and tester-reads 3/3, which also fails on 1616d1dd6 (project.mjs:95 refuses an org --tester init; follow-up). Correction to the uiorg deploy at 00:50Z: the deploy script switched the tree without the offline schema migration, so all five buses refused to start (normal startup refuses an older schema by design). The lead ran migrate-schema.mjs for the five org stores (3t to 3u, all ready), restarted the buses (201 each) and added the migration to the deploy script with stop-on-refusal. test3 refused writer-locked and stays down, as before. Reports: /mnt/storage/scratch/mosaic-lead/reports/{u1-review,u1-fix,org-sys1-build,sys1-review}.md.
+3
View File
@@ -2121,3 +2121,6 @@ are never rewritten or removed; corrections are new entries.
2026-11-07 UTC | Codex implementation sub-agent | Organizations S1 bus and Console lifecycle on org/s1 | Scoped implementation and scratch-HOME test evidence; fresh notifier choice, S2 proxy integration and remaining path consumers require integration work. Inventory, commands and limits: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s1.md. Queue refused linked worktree; no push or deployment.
2026-11-07 UTC | Claude implementation sub-agent | Organizations S6 WebUI create and guided setup on org/s6 | Settings > Organizations: template form with JSON preview, C2 create/update with if-revision, setup cards for checkout, repositories and bus; webui suite 974/975 (inherited dev-owner test failure). Report and screenshots: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s6.md. No push, merge or deployment.
2026-11-07 UTC | Codex implementation sub-agent | Organizations release fix round 1 on ui-org-r1 | Local candidate for independent review; verification and findings recorded in /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-fix-r1.md. Queue refused linked worktree; no push, merge or deployment.
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | Organizations F3 fix round and landing (#1873) | PR #1884 merged a8f9f0957; review P3 fixed, P1 README corrected with follow-up #1883; 195/0 focused suite; uiorg redeployed 22:45Z.
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | PRD 1.20 landing and defect l (#1873, #1886) | PR #1887 merged 03fe321b1, PR #1888 merged 7a97cfc4f; both Sol reviews APPROVE 0; bus suite 1713/0; uiorg redeployed 23:15Z; U1, U2 and U4a builds dispatched.
2026-11-10 UTC | Mos (lead, Claude Opus 5.5) | U1 and SYS1 landings (#1873) | PR #1889 merged d35447005, PR #1890 merged 2c6556ab1; both Sol reviews CHANGES, fixed (U1 by Terra, SYS1 by the lead); uiorg at 2c6556ab1 after the missed 3t-3u store migration was run; deploy script now migrates.
+4 -1
View File
@@ -311,7 +311,8 @@ scripts/mosaic decide <decision> <option> [--note <text>] [--yes]
scripts/mosaic trail <task|decision> [--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 stop | status
scripts/mosaic console stop | status | restart
scripts/mosaic org reload <org>
scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>]
scripts/mosaic project create <name> [--repo <repo>] | list [--json]
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 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.
`mosaic halt` durably stops managed processing across every business in the
selected configuration's data root. The bus stays up for reads and human
@@ -0,0 +1,98 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sidebar · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app drawer">
<header class="cmdbar">
<button class="menu" aria-label="Close navigation" aria-expanded="true"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-close"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html" aria-current="page"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="crumb">Shell</div>
<div class="vh"><h1>Sidebar</h1><p class="sub">Every organization stays in view. There is no organization switcher: selecting an organization opens its dashboard, and its projects sit in the group below.</p></div>
<div class="specimen">
<figure>
<div class="mini">
<div class="grp-h" aria-expanded="false"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span></div>
<div class="collapsed-marks"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="av xs" data-org="home" aria-hidden="true">HM</span></div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
</div>
</div>
<figcaption>Organizations collapsed. The row keeps the count and the three marks, so nothing disappears from view.</figcaption>
</figure>
<figure>
<div class="mini"><div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="false">By org</span><span aria-pressed="true">Flat</span></span></div>
<a class="nav proj flat" href="#"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a>
<a class="nav proj flat" href="#" aria-current="page"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="t">Brand refresh</span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="t">Garden plan</span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="t">Monthly newsletter</span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="t">CLI tools</span><span class="n">paused</span></a>
<a class="nav proj flat" href="#"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="t">Trip planning 2027</span><span class="n">paused</span></a>
</div></div>
<figcaption>Projects flat, sorted by activity. Each row leads with its organization's mark.</figcaption>
</figure>
<figure>
<div class="mini">
<div class="grp-h"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organization states</span></div>
<a class="nav org-row" href="03-org-dashboard.html" aria-current="page"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="dot s-offline"><b aria-hidden="true">○</b>bus not read</span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="dot s-waiting"><b aria-hidden="true">◐</b>needs you</span></a>
</div>
<figcaption>Selected, bus unreachable, and an item waiting. Status is a symbol plus a word, never colour alone.</figcaption>
</figure>
</div>
<ol class="callouts">
<li><b>The org mark</b> is a tile in the organization's theme colour with its initials (or an uploaded image), always beside the name. The type icon on the right (business, coding, personal) is the third cue the owner asked for.</li>
<li><b>Plus</b> on the Organizations row starts the organization editor (11-07 ruling 6); it is tied to <code>mosaic org create ID --from FILE</code>.</li>
<li><b>Settings</b> no longer holds an "Existing organization" select. Organization settings open from each org dashboard.</li>
<li><b>Open state</b> of each group and the By org / Flat choice are remembered per viewer.</li>
<li><b>On a phone</b> the sidebar becomes a drawer behind the menu button, with the same groups.</li>
</ol>
</main>
</div>
</body>
</html>
@@ -0,0 +1,87 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dashboard · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="vh"><h1>Dashboard</h1><div class="right"><span class="seg" role="group" aria-label="Period"><span aria-pressed="false">Today</span><span aria-pressed="true">7 days</span><span aria-pressed="false">30 days</span></span></div>
<p class="sub">All 3 organizations and 10 projects. Totals add up every organization you belong to; each tile breaks them down by organization.</p></div>
<div class="grid g5 keep2">
<div class="tile"><span class="label">Needs you</span><span class="big">3<small>items</small></span>
<div class="bar" aria-hidden="true"><i data-org="ddigital" style="flex:0.333"></i><i data-org="forge" style="flex:0.333"></i><i data-org="home" style="flex:0.333"></i></div>
<div class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>1</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>1</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>1</span></div><span class="delta">oldest 52 min</span></div>
<div class="tile"><span class="label">Sessions</span><span class="big">7<small>4 working</small></span>
<div class="bar" aria-hidden="true"><i data-org="ddigital" style="flex:0.429"></i><i data-org="forge" style="flex:0.286"></i><i data-org="home" style="flex:0.143"></i><i style="background:var(--muted);flex:0.143"></i></div>
<div class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>3</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>2</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>1</span><span><span class="av all " aria-hidden="true" title="Assistant, all organizations"><svg class="mosaic-logo" viewBox="0 0 36 36" style="width:70%;height:auto" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg></span>1</span></div><span class="delta">1 waiting · 1 error · 1 idle</span></div>
<div class="tile"><span class="label">Open tasks</span><span class="big">10</span>
<div class="bar" aria-hidden="true"><i data-org="ddigital" style="flex:0.500"></i><i data-org="forge" style="flex:0.300"></i><i data-org="home" style="flex:0.200"></i></div>
<div class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>5</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>3</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>2</span></div><span class="delta"><b>2</b> done this week</span></div>
<div class="tile"><span class="label">Projects</span><span class="big">10<small>8 active</small></span>
<div class="bar" aria-hidden="true"><i data-org="ddigital" style="flex:0.400"></i><i data-org="forge" style="flex:0.300"></i><i data-org="home" style="flex:0.300"></i></div>
<div class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>4</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>3</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>3</span></div><span class="delta">2 paused</span></div>
<div class="tile"><span class="label">Model spend</span><span class="big">$38.40<small>7 days</small></span>
<div class="bar" aria-hidden="true"><i data-org="ddigital" style="flex:0.549"></i><i data-org="forge" style="flex:0.406"></i><i data-org="home" style="flex:0.044"></i></div>
<div class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>$21.10</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>$15.60</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>$1.70</span></div><span class="delta">of $110 in weekly limits</span></div>
</div>
<div class="split" style="margin-top:4px">
<section>
<div class="sec-h"><h2>Activity across organizations</h2><span class="note">newest first, every bus</span><a href="#">All activity</a></div>
<ol class="trail"><li><time>14:32</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>coder</b> pushed 3 commits on #41 Rate limiter <span class="src">· Forge · API gateway</span></span></li><li><time>14:28</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>copywriter</b> saved V-112 Launch email, draft 2 <span class="src">· Distinctly Digital · Spring launch</span></span></li><li><time>14:21</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>reviewer</b> stopped: model provider timed out <span class="src">· Forge · Web app</span></span></li><li><time>14:15</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>editor</b> asked you: keep the revised ending? <span class="src">· Distinctly Digital · The Lantern Coast</span></span></li><li><time>14:02</time><span class="av " data-org="home" aria-hidden="true">HM</span><span class="what"><b>assistant</b> flagged the insurance renewal <span class="src">· Home · Household admin</span></span></li><li><time>13:55</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>montage-producer</b> rendered teaser cut 3 of 4 <span class="src">· Distinctly Digital · Spring launch</span></span></li><li><time>13:40</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>chief</b> raised a gated decision: merge the rate limiter <span class="src">· Forge · API gateway</span></span></li><li><time>13:12</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>chief</b> created V-120 Logo shortlist in Vikunja <span class="src">· Distinctly Digital · Brand refresh</span></span></li><li><time>12:58</time><span class="av " data-org="home" aria-hidden="true">HM</span><span class="what"><b>you</b> closed #9 Order seed potatoes <span class="src">· Home · Garden plan</span></span></li></ol>
<div class="sec-h"><h2>Tasks by state</h2><span class="note">all trackers</span><a href="06-tasks.html">Open tasks</a></div>
<div class="panel tight states">
<div class="srow"><span class="sl">To do</span><span class="sn tnum">5</span><div class="bar" style="width:90%" aria-hidden="true"><i data-org="ddigital" style="flex:2"></i><i data-org="forge" style="flex:1"></i><i data-org="home" style="flex:2"></i></div><span class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>2</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>1</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>2</span></span></div><div class="srow"><span class="sl">In progress</span><span class="sn tnum">3</span><div class="bar" style="width:54%" aria-hidden="true"><i data-org="ddigital" style="flex:2"></i><i data-org="forge" style="flex:1"></i></div><span class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>2</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>1</span></span></div><div class="srow"><span class="sl">Review</span><span class="sn tnum">2</span><div class="bar" style="width:36%" aria-hidden="true"><i data-org="ddigital" style="flex:1"></i><i data-org="forge" style="flex:1"></i></div><span class="by"><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>1</span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>1</span></span></div><div class="srow"><span class="sl">Done this week</span><span class="sn tnum">2</span><div class="bar" style="width:36%" aria-hidden="true"><i data-org="forge" style="flex:1"></i><i data-org="home" style="flex:1"></i></div><span class="by"><span><span class="av " data-org="forge" aria-hidden="true">FG</span>1</span><span><span class="av " data-org="home" aria-hidden="true">HM</span>1</span></span></div>
</div>
</section>
<section>
<div class="sec-h"><h2>Needs you</h2><a href="05-inbox.html">Inbox</a></div>
<ul class="needs"><li class="edge" data-org="forge"><span class="av md" data-org="forge" aria-hidden="true">FG</span><span class="q">Merge the rate limiter branch into main?</span><span class="src">Forge · API gateway · chief</span><span class="chip gated">gated</span></li><li class="edge" data-org="ddigital"><span class="av md" data-org="ddigital" aria-hidden="true">DD</span><span class="q">Chapter 7: keep the revised ending?</span><span class="src">Distinctly Digital · The Lantern Coast · editor</span><span class="chip within">within-role</span></li><li class="edge" data-org="home"><span class="av md" data-org="home" aria-hidden="true">HM</span><span class="q">Home insurance renews in 12 days. Compare the two quotes now?</span><span class="src">Home · Household admin · assistant</span><span class="chip routine">routine</span></li></ul>
<div class="sec-h"><h2>Sessions now</h2><a href="08-agents.html">Agents</a></div>
<div class="tbl"><table class="sess"><thead><tr><th>Agent and project</th><th>Status</th></tr></thead><tbody>
<tr><td class="who"><span class="orgref"><span class="av all xs" aria-hidden="true" title="Assistant, all organizations"><svg class="mosaic-logo" viewBox="0 0 36 36" style="width:70%;height:auto" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg></span>assistant</span><span class="muted"> · All organizations</span></td><td class="st"><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">with you</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>coder</span><span class="muted"> · API gateway</span></td><td class="st"><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">65 min</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>copywriter</span><span class="muted"> · Spring launch</span></td><td class="st"><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">38 min</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>montage-producer</span><span class="muted"> · Spring launch</span></td><td class="st"><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">72 min</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>editor</span><span class="muted"> · The Lantern Coast</span></td><td class="st"><span class="dot s-waiting"><b aria-hidden="true">◐</b>waiting</span> <span class="muted">on you</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>reviewer</span><span class="muted"> · Web app</span></td><td class="st"><span class="dot s-error"><b aria-hidden="true">▲</b>error</span> <span class="muted">timeout</span></td></tr><tr><td class="who"><span class="orgref"><span class="av xs" data-org="home" aria-hidden="true">HM</span>planner</span><span class="muted"> · Household admin</span></td><td class="st"><span class="dot s-idle"><b aria-hidden="true">–</b>idle</span> <span class="muted">since 09:10</span></td></tr>
</tbody></table></div>
</section>
</div>
</main>
</div>
</body>
</html>
@@ -0,0 +1,91 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Distinctly Digital · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html" aria-current="page"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="crumb"><a href="#">Organizations</a> › Distinctly Digital</div>
<div class="orghead" data-org="ddigital">
<span class="av lg" data-org="ddigital" aria-hidden="true">DD</span>
<h1>Distinctly Digital <svg class="i" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></h1>
<div class="right">
<span class="btn" title="mosaic bus restart ddigital"><svg class="i" aria-hidden="true"><use href="#i-restart"/></svg>Restart bus</span>
<span class="btn"><svg class="i" aria-hidden="true"><use href="#i-doc"/></svg>Reload organization file</span>
<a class="btn pri" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-split"/></svg>Open in a pane</a>
</div>
<div class="meta"><span>Business</span><span>id <code>ddigital</code></span><span>Tracker: Vikunja</span><span><span class="dot s-working"><b aria-hidden="true">●</b>bus read 4 s ago</span></span><span>Restart and reload are trailed human actions; no agent can take them.</span></div>
</div>
<div class="typechips chips"><span class="label" style="margin-right:4px">System types</span><span class="chip outline">Coding</span><span class="chip outline">Creative writing</span><span class="chip outline">Marketing</span><span class="chip outline">Mosaic Montage</span><span class="chip outline">Business management</span><span class="note">Each project picks from these; none is required.</span></div>
<div class="docrow chips"><span class="label" style="margin-right:4px">Docs</span><a class="doc" href="#" title="org"><svg class="i sm" aria-hidden="true"><use href="#i-doc"/></svg>North star</a><a class="doc" href="#" title="org"><svg class="i sm" aria-hidden="true"><use href="#i-doc"/></svg>Brand voice guide</a><a class="doc" href="#" title="Spring launch"><svg class="i sm" aria-hidden="true"><use href="#i-doc"/></svg>Launch plan<small>Spring launch</small></a><a class="doc" href="#" title="The Lantern Coast"><svg class="i sm" aria-hidden="true"><use href="#i-doc"/></svg>Series bible<small>The Lantern Coast</small></a><a class="doc" href="#" title="org"><svg class="i sm" aria-hidden="true"><use href="#i-doc"/></svg>Operating notes</a><a href="#" class="more">All docs</a></div>
<div class="split" style="margin-top:6px">
<section>
<div class="sec-h"><h2>KPIs</h2><span class="note">defined in the organization file</span><a href="#">Edit KPIs</a></div>
<div class="grid g4 keep2">
<div class="tile"><span class="label">Subscribers</span><span class="big">2,480</span><span class="delta"><b>+6.2%</b> in 30 days · goal 3,000</span></div>
<div class="tile"><span class="label">Launch pieces</span><span class="big">7<small>of 12</small></span><div class="bar"><i data-org="ddigital" style="flex:7"></i><i style="flex:5;background:transparent"></i></div></div>
<div class="tile"><span class="label">Novel draft</span><span class="big">61,200<small>words</small></span><span class="delta">target 90,000 · 68%</span></div>
<div class="tile"><span class="label">Tasks closed</span><span class="big">18</span><span class="delta">30 days · <b>+4</b> on last month</span></div>
</div>
<div class="sec-h"><h2>Metrics</h2><span class="note">measured by the bus</span></div>
<div class="grid g4 keep2 metrics">
<div class="tile"><span class="label">Spend this week</span><span class="big">$21.10<small>of $60</small></span><div class="bar"><i data-org="ddigital" style="flex:21.1"></i><i style="flex:38.9;background:transparent"></i></div></div>
<div class="tile"><span class="label">Agent hours</span><span class="big">31.5<small>7 days</small></span></div>
<div class="tile"><span class="label">Decision wait</span><span class="big">22<small>min median</small></span></div>
<div class="tile"><span class="label">Open tasks</span><span class="big">5</span><span class="delta">2 in progress · 1 review</span></div>
</div>
<div class="sec-h"><h2>Active agent sessions</h2><span class="note">2 working, 1 waiting on you</span><a href="08-agents.html">All agents</a></div>
<div class="tbl"><table><thead><tr><th>Agent</th><th>Project and task</th><th class="hide-m">Model</th><th>Status</th></tr></thead><tbody>
<tr><td class="t1">copywriter</td><td>Spring launch<span class="t2">V-112 Launch email, draft 2</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td><td><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">38 min</span></td></tr>
<tr><td class="t1">montage-producer</td><td>Spring launch<span class="t2">V-118 Teaser reel, 30 s cut</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td><td><span class="dot s-working"><b aria-hidden="true">●</b>working</span> <span class="muted">1 h 12 min</span></td></tr>
<tr><td class="t1">editor</td><td>The Lantern Coast<span class="t2">V-97 Chapter 7 revision</span></td><td class="hide-m mono">opus-5.5 <a href="#">Change</a></td><td><span class="dot s-waiting"><b aria-hidden="true">◐</b>waiting on you</span></td></tr>
</tbody></table></div>
</section>
<section>
<div class="sec-h"><h2>Settings</h2><span class="note">every setting, with where its value comes from</span></div>
<ul class="cfg one"><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg><span class="k">Identity</span><span class="v">Distinctly Digital · ddigital · Business</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-flag"/></svg><span class="k">Appearance</span><span class="v">Rosewood theme · DD avatar</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-runs"/></svg><span class="k">System types</span><span class="v">5 of 5 installed enabled</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="k">Projects</span><span class="v">4 projects · 3 with a remote, 1 local only</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="k">Agents and roles</span><span class="v">5 org · 2 project · 3 from your root</span><span class="src-tag">org + root</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg><span class="k">Models and providers</span><span class="v">Your shared proxy · editor runs opus-5.5</span><span class="src-tag">override</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="k">Task tracker</span><span class="v">Vikunja module · project DD</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="k">Channels</span><span class="v">Discord off · daily email digest</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-ledger"/></svg><span class="k">Launch limits</span><span class="v">4 sessions at once · $60 a week</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-queue"/></svg><span class="k">Routing and arbiters</span><span class="v">chief routes · editor arbitrates copy</span><span class="src-tag">org file</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="k">Members and invites</span><span class="v">You (owner) · invites arrive in Phase M</span><span class="src-tag">not built</span><a class="go" href="#">Open</a></li><li><svg class="i" aria-hidden="true"><use href="#i-restart"/></svg><span class="k">Bus</span><span class="v">Running · local-only checkout</span><span class="src-tag">system</span><a class="go" href="#">Open</a></li><li class="danger"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-close"/></svg><span class="k">Archive or delete</span><span class="v">Removes this organization's tasks and data only</span><span class="src-tag">danger</span><a class="go" href="#">Open</a></li></ul>
</section>
</div>
</main>
</div>
</body>
</html>
@@ -0,0 +1,164 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Projects · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="vh"><h1>Projects</h1><div class="right"><span class="btn"><svg class="i" aria-hidden="true"><use href="#i-plus"/></svg>New project</span></div>
<p class="sub">10 projects in 3 organizations, sorted by latest activity. Every row names its organization by mark, colour edge and type icon.</p></div>
<div class="toolbar">
<span class="search"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg>Filter by name</span>
<div class="chips" role="group" aria-label="Organization"><span class="f plain" aria-pressed="true">All <span class="n">10</span></span><span class="f" aria-pressed="false"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="n">4</span></span><span class="f" aria-pressed="false"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="n">3</span></span><span class="f" aria-pressed="false"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="n">3</span></span></div>
<span class="sel"><span class="label">Status</span>Active, paused<svg class="i sm" aria-hidden="true"><use href="#i-chevron"/></svg></span>
<div class="right"><span class="label hide-m">Sort</span><span class="seg" role="group" aria-label="Sort"><span aria-pressed="true">Activity</span><span aria-pressed="false">Name</span><span aria-pressed="false">Org</span><span aria-pressed="false">Status</span></span></div>
</div>
<div class="tbl"><table class="projects">
<thead><tr><th class="hide-m">Organization</th><th>Project</th><th>Status</th><th class="hide-m">System types</th><th class="hide-m">Sessions</th><th class="num hide-m">Open tasks</th><th class="num">Needs you</th><th class="sorted hide-m">Last activity<span class="ar">↓</span></th><th class="hide-m">Git</th></tr></thead>
<tbody>
<tr class="edge" data-org="forge">
<td class="hide-m"><span class="orgref"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="forge" aria-hidden="true">FG</span>API gateway</span><span class="t2 mono hide-m">api-gateway</span><span class="t2 show-m">2 min ago · coder pushed 3 commits on #41</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Coding</span></td>
<td class="hide-m"><span class="dot s-working"><b aria-hidden="true">●</b>1 working</span></td>
<td class="num hide-m">2</td>
<td class="num"><span class="n hot">1</span></td>
<td class="hide-m">2 min ago<span class="t2">coder pushed 3 commits on #41</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge" data-org="ddigital">
<td class="hide-m"><span class="orgref"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="ddigital" aria-hidden="true">DD</span>Spring launch</span><span class="t2 mono hide-m">spring-launch</span><span class="t2 show-m">4 min ago · copywriter saved draft 2</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Marketing</span> <span class="chip outline">Creative writing</span> <span class="chip outline">Mosaic Montage</span></td>
<td class="hide-m"><span class="dot s-working"><b aria-hidden="true">●</b>2 working</span></td>
<td class="num hide-m">2</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">4 min ago<span class="t2">copywriter saved draft 2</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge" data-org="forge">
<td class="hide-m"><span class="orgref"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="forge" aria-hidden="true">FG</span>Web app</span><span class="t2 mono hide-m">webapp</span><span class="t2 show-m">11 min ago · reviewer stopped: provider timeout</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Coding</span></td>
<td class="hide-m"><span class="dot s-error"><b aria-hidden="true">▲</b>1 error</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">11 min ago<span class="t2">reviewer stopped: provider timeout</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge" data-org="ddigital">
<td class="hide-m"><span class="orgref"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="ddigital" aria-hidden="true">DD</span>The Lantern Coast</span><span class="t2 mono hide-m">novel-draft</span><span class="t2 show-m">17 min ago · editor asked about chapter 7</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Creative writing</span></td>
<td class="hide-m"><span class="dot s-waiting"><b aria-hidden="true">◐</b>1 waiting</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="n hot">1</span></td>
<td class="hide-m">17 min ago<span class="t2">editor asked about chapter 7</span></td>
<td class="hide-m"><span class="chip outline">local only</span></td>
</tr><tr class="edge" data-org="home">
<td class="hide-m"><span class="orgref"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="home" aria-hidden="true">HM</span>Household admin</span><span class="t2 mono hide-m">household</span><span class="t2 show-m">30 min ago · assistant flagged a renewal</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Business management</span></td>
<td class="hide-m"><span class="dot s-idle"><b aria-hidden="true">–</b>1 idle</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="n hot">1</span></td>
<td class="hide-m">30 min ago<span class="t2">assistant flagged a renewal</span></td>
<td class="hide-m"><span class="chip outline">local only</span></td>
</tr><tr class="edge" data-org="ddigital">
<td class="hide-m"><span class="orgref"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="ddigital" aria-hidden="true">DD</span>Brand refresh</span><span class="t2 mono hide-m">brand-refresh</span><span class="t2 show-m">1 h ago · chief created V-120</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Marketing</span> <span class="chip outline">Mosaic Montage</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">1 h ago<span class="t2">chief created V-120</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge" data-org="home">
<td class="hide-m"><span class="orgref"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="home" aria-hidden="true">HM</span>Garden plan</span><span class="t2 mono hide-m">garden</span><span class="t2 show-m">1 h ago · #9 marked done</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="num hide-m">0</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">1 h ago<span class="t2">#9 marked done</span></td>
<td class="hide-m"><span class="chip outline">local only</span></td>
</tr><tr class="edge" data-org="ddigital">
<td class="hide-m"><span class="orgref"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="ddigital" aria-hidden="true">DD</span>Monthly newsletter</span><span class="t2 mono hide-m">newsletter</span><span class="t2 show-m">yesterday · V-121 created</span></td>
<td><span class="dot s-ok"><b aria-hidden="true">●</b>active</span></td>
<td class="hide-m"><span class="chip outline">Marketing</span> <span class="chip outline">Creative writing</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">yesterday<span class="t2">V-121 created</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge dim" data-org="forge">
<td class="hide-m"><span class="orgref"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="forge" aria-hidden="true">FG</span>CLI tools</span><span class="t2 mono hide-m">cli-tools</span><span class="t2 show-m">12 days ago · #29 done</span></td>
<td><span class="dot s-idle"><b aria-hidden="true">–</b>paused</span></td>
<td class="hide-m"><span class="chip outline">Coding</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="num hide-m">0</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">12 days ago<span class="t2">#29 done</span></td>
<td class="hide-m"><span class="chip outline">remote</span></td>
</tr><tr class="edge dim" data-org="home">
<td class="hide-m"><span class="orgref"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="ic muted"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></span></td>
<td><span class="t1 nm"><span class="av xs mob" data-org="home" aria-hidden="true">HM</span>Trip planning 2027</span><span class="t2 mono hide-m">travel</span><span class="t2 show-m">3 weeks ago · #14 created</span></td>
<td><span class="dot s-idle"><b aria-hidden="true">–</b>paused</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="hide-m"><span class="muted">none</span></td>
<td class="num hide-m">1</td>
<td class="num"><span class="muted">0</span></td>
<td class="hide-m">3 weeks ago<span class="t2">#14 created</span></td>
<td class="hide-m"><span class="chip outline">local only</span></td>
</tr>
</tbody></table></div>
<p class="note" style="margin-top:10px">Sorting by Org groups the rows under each organization's header. Paused projects stay listed, dimmed, and the Status filter hides them.</p>
</main>
</div>
</body>
</html>
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Inbox · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app with-insp">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="vh"><h1>Inbox</h1>
<p class="sub">3 items need you, from 3 organizations. Each row names where it came from. Seen is not resolution: an item leaves only when it is answered.</p></div>
<div class="toolbar">
<div class="chips" role="group" aria-label="Organization"><span class="f plain" aria-pressed="true">All <span class="n">3</span></span><span class="f" aria-pressed="false"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="n">1</span></span><span class="f" aria-pressed="false"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="n">1</span></span><span class="f" aria-pressed="false"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="n">1</span></span></div>
<div class="right"><span class="seg" role="group" aria-label="State"><span aria-pressed="true">Needs you</span><span aria-pressed="false">Seen</span><span aria-pressed="false">Answered</span></span></div>
</div>
<div class="tbl"><table class="ibx">
<thead><tr><th class="hide-m">Source</th><th>Needs</th><th class="hide-m">Class</th><th class="num">Waiting</th></tr></thead>
<tbody>
<tr class="row edge" data-org="forge" aria-selected="true"><td class="src hide-m"><span class="orgref"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge</span><span class="via">API gateway · chief</span></td><td><span class="msrc show-m"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>Forge · API gateway · chief</span><span class="q">Merge the rate limiter branch into main?</span><span class="via">decision 4f2a91 · seen 14:05</span><span class="show-m"><span class="chip gated">gated</span></span></td><td class="hide-m"><span class="chip gated">gated</span></td><td class="num">52 min</td></tr><tr class="row edge" data-org="ddigital"><td class="src hide-m"><span class="orgref"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</span><span class="via">The Lantern Coast · editor session</span></td><td><span class="msrc show-m"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital · The Lantern Coast · editor</span><span class="q">Chapter 7: keep the revised ending?</span><span class="via">decision 7c03e2 · not seen</span><span class="show-m"><span class="chip within">within-role</span></span></td><td class="hide-m"><span class="chip within">within-role</span></td><td class="num">17 min</td></tr><tr class="row edge" data-org="home"><td class="src hide-m"><span class="orgref"><span class="av " data-org="home" aria-hidden="true">HM</span>Home</span><span class="via">Household admin · assistant</span></td><td><span class="msrc show-m"><span class="av xs" data-org="home" aria-hidden="true">HM</span>Home · Household admin · assistant</span><span class="q">Home insurance renews in 12 days. Compare the two quotes now?</span><span class="via">decision b19d40 · not seen</span><span class="show-m"><span class="chip routine">routine</span></span></td><td class="hide-m"><span class="chip routine">routine</span></td><td class="num">30 min</td></tr>
</tbody></table></div>
<div class="sec-h"><h2>Answered today</h2><span class="note">kept for 24 hours, then in the trail</span></div>
<div class="tbl"><table class="ibx"><tbody>
<tr class="dim"><td class="src hide-m"><span class="orgref quiet"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</span><span class="via">Spring launch · copywriter</span></td><td><span class="msrc show-m"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital · Spring launch</span>Use the shorter subject line?<span class="via">answered 11:40 by you · option 2</span></td><td class="num">done</td></tr>
<tr class="dim"><td class="src hide-m"><span class="orgref quiet"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge</span><span class="via">Web app · reviewer session</span></td><td><span class="msrc show-m"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>Forge · Web app</span>Retry with the fallback model?<span class="via">expired 10:02 · no answer needed</span></td><td class="num">expired</td></tr>
</tbody></table></div>
</main>
<aside class="insp" aria-label="Decision" data-org="forge">
<header><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="label">Decision 4f2a91</span><span class="chip gated">gated</span><span class="x" aria-label="Close"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-close"/></svg></span></header>
<div class="body">
<h3>Merge the rate limiter branch into main?</h3>
<div class="auth">Forge · API gateway · raised by <b>chief</b> at 13:40. Only you can answer a gated decision.</div>
<p class="note">Branch <code>rate-limiter-41</code> passes the suite (312 tests). The review is missing: the reviewer session stopped on a provider timeout at 14:21.</p>
<ul class="opts">
<li class="rec"><div class="row"><span class="k">1</span><b>Merge after a fresh review</b><span class="chip ok">chief recommends</span></div><span class="note">Restart the reviewer on the fallback model, then merge if it passes.</span><div class="row"><span class="btn pri">Answer 1…</span></div></li>
<li><div class="row"><span class="k">2</span><b>Merge now</b></div><span class="note">Skips review. The trail records the waiver.</span><div class="row"><span class="btn">Answer 2…</span></div></li>
<li><div class="row"><span class="k">3</span><b>Hold until Monday</b></div><div class="row"><span class="btn">Answer 3…</span></div></li>
</ul>
<div><span class="label">Answer from a terminal</span>
<div class="cmd" style="margin-top:6px"><code>mosaic decide 4f2a91 1 --org forge</code><span class="btn" title="Copy"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-copy"/></svg></span></div>
<p class="note" style="margin-top:6px">The inbox mixes organizations, so the copied command always names the organization's bus.</p></div>
<div><span class="label">History</span>
<ol class="hist" style="margin-top:6px"><li><time>13:40</time>chief raised it on the Forge bus</li><li><time>13:41</time>reached your inbox</li><li><time>14:05</time>seen by you (not an answer)</li></ol></div>
</div>
</aside>
</div>
</body>
</html>
@@ -0,0 +1,119 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Tasks · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="vh"><h1>Tasks</h1><div class="right"><span class="seg" role="group" aria-label="Layout"><span aria-pressed="true" class="kb">Kanban</span><span aria-pressed="false" class="ls">List</span></span><span class="btn"><svg class="i" aria-hidden="true"><use href="#i-plus"/></svg>New task</span></div>
<p class="sub">12 tasks from 3 organizations. Each organization keeps its own task store; this view reads all of them and writes each change back to the store it came from.</p></div>
<div class="trackers"><span class="label">Trackers</span><span><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital <span class="trk vik">Vikunja</span></span><span><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge <span class="trk">built-in</span></span><span><span class="av " data-org="home" aria-hidden="true">HM</span>Home <span class="trk">built-in</span></span><span class="note">Ids keep their tracker's form: V-112 is Vikunja, #41 is built in. Archiving an organization removes only its tasks.</span></div>
<div class="toolbar">
<div class="chips" role="group" aria-label="Organization"><span class="f plain" aria-pressed="true">All <span class="n">12</span></span><span class="f" aria-pressed="false"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital<span class="n">5</span></span><span class="f" aria-pressed="false"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge<span class="n">4</span></span><span class="f" aria-pressed="false"><span class="av " data-org="home" aria-hidden="true">HM</span>Home<span class="n">3</span></span></div>
<span class="sel"><span class="label">Project</span>All 10<svg class="i sm" aria-hidden="true"><use href="#i-chevron"/></svg></span>
<span class="sel"><span class="label">Assignee</span>Anyone<svg class="i sm" aria-hidden="true"><use href="#i-chevron"/></svg></span>
<div class="right"><span class="search"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg>Find a task</span></div>
</div>
<div class="board">
<section class="col"><div class="col-h"><h3>To do</h3><span class="n">5</span><span class="mix" title="Organizations in this column"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="av xs" data-org="home" aria-hidden="true">HM</span></span></div><article class="kcard edge" data-org="forge">
<div class="top"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="id">#44</span><span class="trk">built-in</span></div>
<div class="tt">Settings page: model picker</div>
<div class="bot"><span>Web app</span></div>
</article><article class="kcard edge" data-org="ddigital">
<div class="top"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="id">V-120</span><span class="trk vik">Vikunja</span></div>
<div class="tt">Logo shortlist</div>
<div class="bot"><span>Brand refresh</span></div>
</article><article class="kcard edge" data-org="ddigital">
<div class="top"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="id">V-121</span><span class="trk vik">Vikunja</span></div>
<div class="tt">November issue outline</div>
<div class="bot"><span>Monthly newsletter</span></div>
</article><article class="kcard edge you" data-org="home">
<div class="top"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="id">#12</span><span class="trk">built-in</span></div>
<div class="tt">Insurance renewal comparison</div>
<div class="bot"><span>Household admin</span><span class="chip warn">needs you</span></div>
</article><article class="kcard edge" data-org="home">
<div class="top"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="id">#14</span><span class="trk">built-in</span></div>
<div class="tt">Shortlist ferry dates</div>
<div class="bot"><span>Trip planning 2027</span></div>
</article></section>
<section class="col"><div class="col-h"><h3>In progress</h3><span class="n">3</span><span class="mix" title="Organizations in this column"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span></span></div><article class="kcard edge" data-org="forge">
<div class="top"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="id">#41</span><span class="trk">built-in</span></div>
<div class="tt">Rate limiter: per-key buckets</div>
<div class="bot"><span>API gateway</span><span class="dot s-working"><b aria-hidden="true">●</b>coder</span></div>
</article><article class="kcard edge" data-org="ddigital">
<div class="top"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="id">V-112</span><span class="trk vik">Vikunja</span></div>
<div class="tt">Launch email, draft 2</div>
<div class="bot"><span>Spring launch</span><span class="dot s-working"><b aria-hidden="true">●</b>copywriter</span></div>
</article><article class="kcard edge" data-org="ddigital">
<div class="top"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="id">V-118</span><span class="trk vik">Vikunja</span></div>
<div class="tt">Teaser reel, 30 s cut</div>
<div class="bot"><span>Spring launch</span><span class="dot s-working"><b aria-hidden="true">●</b>montage-producer</span></div>
</article></section>
<section class="col"><div class="col-h"><h3>Review</h3><span class="n">2</span><span class="mix" title="Organizations in this column"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span></span></div><article class="kcard edge" data-org="forge">
<div class="top"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="id">#38</span><span class="trk">built-in</span></div>
<div class="tt">Load test at 2k rps</div>
<div class="bot"><span>API gateway</span><span class="dot s-idle"><b aria-hidden="true">–</b>reviewer</span></div>
</article><article class="kcard edge" data-org="ddigital">
<div class="top"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span class="id">V-97</span><span class="trk vik">Vikunja</span></div>
<div class="tt">Chapter 7 revision</div>
<div class="bot"><span>The Lantern Coast</span><span class="dot s-waiting"><b aria-hidden="true">◐</b>editor, on you</span></div>
</article></section>
<section class="col"><div class="col-h"><h3>Done</h3><span class="n">2</span><span class="mix" title="Organizations in this column"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="av xs" data-org="home" aria-hidden="true">HM</span></span></div><article class="kcard edge" data-org="forge">
<div class="top"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span class="id">#29</span><span class="trk">built-in</span></div>
<div class="tt">zsh completion</div>
<div class="bot"><span>CLI tools</span></div>
</article><article class="kcard edge" data-org="home">
<div class="top"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span class="id">#9</span><span class="trk">built-in</span></div>
<div class="tt">Order seed potatoes</div>
<div class="bot"><span>Garden plan</span></div>
</article></section>
</div>
<section class="tasklist">
<div class="sec-h"><h2>List layout</h2><span class="note">same filters, one row per task, grouped by state</span></div>
<div class="tbl"><table><thead><tr><th>Org</th><th>Id</th><th>Task</th><th class="hide-m">Project</th><th>State</th><th class="hide-m">Assignee</th></tr></thead><tbody>
<tr class="edge" data-org="forge"><td><span class="av " data-org="forge" aria-hidden="true">FG</span></td><td class="mono">#44<span class="t2">built-in</span></td><td><span class="t1">Settings page: model picker</span><span class="t2 show-m">Web app</span></td><td class="hide-m">Web app</td><td>To do</td><td class="hide-m"><span class="muted">nobody</span></td></tr><tr class="edge" data-org="ddigital"><td><span class="av " data-org="ddigital" aria-hidden="true">DD</span></td><td class="mono">V-120<span class="t2">Vikunja</span></td><td><span class="t1">Logo shortlist</span><span class="t2 show-m">Brand refresh</span></td><td class="hide-m">Brand refresh</td><td>To do</td><td class="hide-m"><span class="muted">nobody</span></td></tr><tr class="edge" data-org="ddigital"><td><span class="av " data-org="ddigital" aria-hidden="true">DD</span></td><td class="mono">V-121<span class="t2">Vikunja</span></td><td><span class="t1">November issue outline</span><span class="t2 show-m">Monthly newsletter</span></td><td class="hide-m">Monthly newsletter</td><td>To do</td><td class="hide-m"><span class="muted">nobody</span></td></tr><tr class="edge" data-org="home"><td><span class="av " data-org="home" aria-hidden="true">HM</span></td><td class="mono">#12<span class="t2">built-in</span></td><td><span class="t1">Insurance renewal comparison</span><span class="t2 show-m">Household admin</span></td><td class="hide-m">Household admin</td><td>To do</td><td class="hide-m"><span class="chip warn">you</span></td></tr><tr class="edge" data-org="home"><td><span class="av " data-org="home" aria-hidden="true">HM</span></td><td class="mono">#14<span class="t2">built-in</span></td><td><span class="t1">Shortlist ferry dates</span><span class="t2 show-m">Trip planning 2027</span></td><td class="hide-m">Trip planning 2027</td><td>To do</td><td class="hide-m"><span class="muted">nobody</span></td></tr><tr class="edge" data-org="forge"><td><span class="av " data-org="forge" aria-hidden="true">FG</span></td><td class="mono">#41<span class="t2">built-in</span></td><td><span class="t1">Rate limiter: per-key buckets</span><span class="t2 show-m">API gateway</span></td><td class="hide-m">API gateway</td><td>In progress</td><td class="hide-m"><span class="dot s-working"><b aria-hidden="true">●</b>coder</span></td></tr><tr class="edge" data-org="ddigital"><td><span class="av " data-org="ddigital" aria-hidden="true">DD</span></td><td class="mono">V-112<span class="t2">Vikunja</span></td><td><span class="t1">Launch email, draft 2</span><span class="t2 show-m">Spring launch</span></td><td class="hide-m">Spring launch</td><td>In progress</td><td class="hide-m"><span class="dot s-working"><b aria-hidden="true">●</b>copywriter</span></td></tr><tr class="edge" data-org="ddigital"><td><span class="av " data-org="ddigital" aria-hidden="true">DD</span></td><td class="mono">V-118<span class="t2">Vikunja</span></td><td><span class="t1">Teaser reel, 30 s cut</span><span class="t2 show-m">Spring launch</span></td><td class="hide-m">Spring launch</td><td>In progress</td><td class="hide-m"><span class="dot s-working"><b aria-hidden="true">●</b>montage-producer</span></td></tr><tr class="edge" data-org="forge"><td><span class="av " data-org="forge" aria-hidden="true">FG</span></td><td class="mono">#38<span class="t2">built-in</span></td><td><span class="t1">Load test at 2k rps</span><span class="t2 show-m">API gateway</span></td><td class="hide-m">API gateway</td><td>Review</td><td class="hide-m"><span class="dot s-idle"><b aria-hidden="true">–</b>reviewer</span></td></tr><tr class="edge" data-org="ddigital"><td><span class="av " data-org="ddigital" aria-hidden="true">DD</span></td><td class="mono">V-97<span class="t2">Vikunja</span></td><td><span class="t1">Chapter 7 revision</span><span class="t2 show-m">The Lantern Coast</span></td><td class="hide-m">The Lantern Coast</td><td>Review</td><td class="hide-m"><span class="dot s-waiting"><b aria-hidden="true">◐</b>editor</span></td></tr><tr class="edge dim" data-org="forge"><td><span class="av " data-org="forge" aria-hidden="true">FG</span></td><td class="mono">#29<span class="t2">built-in</span></td><td><span class="t1">zsh completion</span><span class="t2 show-m">CLI tools</span></td><td class="hide-m">CLI tools</td><td>Done</td><td class="hide-m"><span class="muted">nobody</span></td></tr><tr class="edge dim" data-org="home"><td><span class="av " data-org="home" aria-hidden="true">HM</span></td><td class="mono">#9<span class="t2">built-in</span></td><td><span class="t1">Order seed potatoes</span><span class="t2 show-m">Garden plan</span></td><td class="hide-m">Garden plan</td><td>Done</td><td class="hide-m"><span class="muted">nobody</span></td></tr>
</tbody></table></div>
</section>
</main>
</div>
</body>
</html>
@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Side by side · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main flush">
<div class="panebar"><h1>Side by side</h1><span class="note">2 panes · each pane reads its own organization's bus and keeps its own state</span>
<span class="seg pane-tabs" role="group" aria-label="Shown pane"><span aria-pressed="true"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span> Spring launch</span><span aria-pressed="false"><span class="av xs" data-org="forge" aria-hidden="true">FG</span> API gateway</span></span>
<span class="btn hide-m" style="margin-left:auto"><svg class="i" aria-hidden="true"><use href="#i-plus"/></svg>Add pane</span></div>
<div class="panes">
<section class="pane" data-org="ddigital">
<div class="pane-h"><span class="av md" data-org="ddigital" aria-hidden="true">DD</span><span class="ttl">Spring launch<small>Distinctly Digital</small></span><span class="tools"><span title="Open in full view"><svg class="i sm" aria-hidden="true"><use href="#i-split"/></svg></span><span title="Close pane"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-close"/></svg></span></span></div>
<div class="pane-body">
<div class="subtabs"><span aria-current="page">Overview</span><span>Tasks</span><span>Sessions</span><span>Docs</span></div>
<div class="grid g3 mini-tiles"><div class="tile"><span class="label">Open tasks</span><span class="big">2</span></div><div class="tile"><span class="label">Sessions</span><span class="big">2<small>working</small></span></div><div class="tile"><span class="label">Needs you</span><span class="big">0</span></div></div>
<div class="sec-h"><h3>copywriter · live</h3><span class="note">V-112 Launch email, draft 2</span></div>
<pre class="term"><span class="m">14:26</span> read brand-voice.md, launch-plan.md
<span class="m">14:27</span> drafted subject lines (3), kept the shortest
<span class="m">14:28</span> saved drafts/launch-email-v2.md 182 words
<span class="m">14:29</span> next: tighten the call to action
<span class="c">▍</span></pre>
<div class="sec-h"><h3>montage-producer · live</h3><span class="note">V-118 Teaser reel</span></div>
<pre class="term"><span class="m">13:55</span> rendered cut 3 of 4 00:30 1080x1920
<span class="m">14:10</span> waiting on the music bed licence check
<span class="c">▍</span></pre>
<div class="sec-h"><h3>Activity</h3></div>
<ol class="trail"><li><time>14:28</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>copywriter</b> saved V-112 Launch email, draft 2 <span class="src">· Distinctly Digital · Spring launch</span></span></li><li><time>13:55</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>montage-producer</b> rendered teaser cut 3 of 4 <span class="src">· Distinctly Digital · Spring launch</span></span></li><li><time>11:20</time><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="what"><b>chief</b> updated the launch plan <span class="src">· Distinctly Digital · Spring launch</span></span></li></ol></div>
</section>
<section class="pane alt" data-org="forge">
<div class="pane-h"><span class="av md" data-org="forge" aria-hidden="true">FG</span><span class="ttl">API gateway<small>Forge</small></span><span class="tools"><span title="Open in full view"><svg class="i sm" aria-hidden="true"><use href="#i-split"/></svg></span><span title="Close pane"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-close"/></svg></span></span></div>
<div class="pane-body">
<div class="subtabs"><span aria-current="page">Overview</span><span>Tasks</span><span>Sessions</span><span>Docs</span></div>
<div class="grid g3 mini-tiles"><div class="tile"><span class="label">Open tasks</span><span class="big">2</span></div><div class="tile"><span class="label">Sessions</span><span class="big">1<small>working</small></span></div><div class="tile"><span class="label">Needs you</span><span class="big">1<small>gated</small></span></div></div>
<ul class="needs" style="margin-top:12px"><li class="edge" data-org="forge"><span class="av md" data-org="forge" aria-hidden="true">FG</span><span class="q">Merge the rate limiter branch into main?</span><span class="src">chief · 52 min · <a href="05-inbox.html">Open in inbox</a></span><span class="chip gated">gated</span></li></ul>
<div class="sec-h"><h3>coder · live</h3><span class="note">#41 Rate limiter: per-key buckets</span></div>
<pre class="term"><span class="m">14:30</span> npm test 312 passed, 0 failed
<span class="m">14:31</span> bench: 2,140 rps at p99 41 ms
<span class="m">14:32</span> pushed 3 commits to rate-limiter-41
<span class="m">14:32</span> waiting for review
<span class="c">▍</span></pre>
<div class="sec-h"><h3>Activity</h3></div>
<ol class="trail"><li><time>14:32</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>coder</b> pushed 3 commits on #41 Rate limiter <span class="src">· Forge · API gateway</span></span></li><li><time>13:40</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>chief</b> raised a gated decision: merge the rate limiter <span class="src">· Forge · API gateway</span></span></li><li><time>12:40</time><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="what"><b>load-tester</b> finished run 7 of 7 <span class="src">· Forge · API gateway</span></span></li></ol></div>
</section>
</div>
</main>
</div>
</body>
</html>
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Agents · U0 sample</title>
<link rel="stylesheet" href="workspace.css">
</head>
<body>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="i-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol><symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10.5 12 4l8 6.5"/><path d="M6 9v10.5h12V9"/><path d="M10 19.5v-5h4v5"/></symbol><symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/></symbol><symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/></symbol><symbol id="i-split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M13 4.5v15M16.75 10.5v3M15.25 12h3"/></symbol><symbol id="i-restart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/></symbol><symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></symbol><symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M4 12h16M4 17h16"/></symbol><symbol id="i-doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/></symbol></svg>
<div class="app ">
<header class="cmdbar">
<button class="menu" aria-label="Open navigation" aria-expanded="false"><svg class="i" aria-hidden="true"><use href="#i-menu"/></svg></button>
<a class="mk" href="02-dashboard.html"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
<span class="sim" title="Static design sample">U0 sample</span>
<button class="cmdk"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-search"/></svg><span>Go to an org, project, task or decision</span><kbd>Ctrl K</kbd></button>
<div class="fresh"><span class="wide">Read <b>3 of 3</b> organization buses · 4 s ago</span><span class="me" title="Operator">OP</span></div>
</header>
<nav class="side" aria-label="Workspace">
<a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a>
<a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a>
<a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a>
<a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a>
<a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a>
<a class="nav" href="08-agents.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a>
<a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Organizations</span><span class="n">3</span><span class="add" title="New organization"><svg class="i sm" aria-hidden="true"><use href="#i-plus"/></svg></span></div>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="t">Distinctly Digital</span><span class="ic" title="Business"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-business"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="t">Forge</span><span class="ic" title="Coding"><svg class="i sm" aria-hidden="true"><use href="../../icons.svg#i-term"/></svg></span></a>
<a class="nav org-row" href="03-org-dashboard.html"><span class="av " data-org="home" aria-hidden="true">HM</span><span class="t">Home</span><span class="ic" title="Personal"><svg class="i sm" aria-hidden="true"><use href="#i-home"/></svg></span></a>
</div>
<hr>
<div class="grp">
<div class="grp-h" role="button" aria-expanded="true"><svg class="i chev" aria-hidden="true"><use href="#i-chevron"/></svg><span class="label">Projects</span><span class="seg xs" role="group" aria-label="Project layout"><span aria-pressed="true">By org</span><span aria-pressed="false">Flat</span></span></div>
<div class="pgroup" data-org="ddigital"><div class="pgroup-h"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span><span>Distinctly Digital</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Spring launch</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">The Lantern Coast</span><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span></a><a class="nav proj" href="#"><span class="t">Brand refresh</span></a><a class="nav proj" href="#"><span class="t">Monthly newsletter</span></a></div></div>
<div class="pgroup" data-org="forge"><div class="pgroup-h"><span class="av xs" data-org="forge" aria-hidden="true">FG</span><span>Forge</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">API gateway</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span></a><a class="nav proj" href="#"><span class="t">Web app</span><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span></a><a class="nav proj" href="#"><span class="t">CLI tools</span><span class="n">paused</span></a></div></div>
<div class="pgroup" data-org="home"><div class="pgroup-h"><span class="av xs" data-org="home" aria-hidden="true">HM</span><span>Home</span></div><div class="projs"><a class="nav proj" href="#"><span class="t">Household admin</span><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span></a><a class="nav proj" href="#"><span class="t">Garden plan</span></a><a class="nav proj" href="#"><span class="t">Trip planning 2027</span><span class="n">paused</span></a></div></div>
</div>
<div class="foot"><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg><span class="t">Settings</span></a></div>
</nav>
<nav class="strip" aria-label="Sections"><a class="nav" href="02-dashboard.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-board"/></svg><span class="t">Dashboard</span></a><a class="nav" href="05-inbox.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-inbox"/></svg><span class="t">Inbox</span><span class="n hot">3</span></a><a class="nav" href="06-tasks.html"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-tasks"/></svg><span class="t">Tasks</span><span class="n">10</span></a><a class="nav" href="04-projects.html"><svg class="i" aria-hidden="true"><use href="#i-folder"/></svg><span class="t">Projects</span><span class="n">10</span></a><a class="nav" href="07-side-by-side.html"><svg class="i" aria-hidden="true"><use href="#i-columns"/></svg><span class="t">Side by side</span></a><a class="nav" href="08-agents.html" aria-current="page"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-agents"/></svg><span class="t">Agents</span></a><a class="nav" href="#"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-talk"/></svg><span class="t">Assistant</span><span class="dot s-working" title="working"><b aria-hidden="true">●</b></span></a></nav>
<main class="main ">
<div class="vh"><h1>Agents</h1><div class="right"><span class="btn"><svg class="i" aria-hidden="true"><use href="../../icons.svg#i-settings"/></svg>Edit root agents</span></div>
<p class="sub">Agents are defined at three levels. A project can use the union of your root agents, its organization's agents and its own. Every agent except the assistant works inside one organization.</p></div>
<div class="span-band"><span class="av all md" aria-hidden="true" title="Assistant, all organizations"><svg class="mosaic-logo" viewBox="0 0 36 36" style="width:70%;height:auto" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg></span><b>assistant</b><span class="lvl root">root</span><span class="dot s-working"><b aria-hidden="true">●</b>working</span><span>Works across</span><span class="orgs"><span class="av " data-org="ddigital" aria-hidden="true">DD</span><span class="av " data-org="forge" aria-hidden="true">FG</span><span class="av " data-org="home" aria-hidden="true">HM</span></span><span class="muted">One shared memory. Talks with you and hands each organization's work to its chief; it does not direct agents, create tasks or launch sessions.</span></div>
<div class="levels" style="margin-top:12px">
<div class="lh hide-m"></div><div class="oh hide-m" data-org="ddigital"><span class="av " data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</div><div class="oh hide-m" data-org="forge"><span class="av " data-org="forge" aria-hidden="true">FG</span>Forge</div><div class="oh hide-m" data-org="home"><span class="av " data-org="home" aria-hidden="true">HM</span>Home</div>
<div class="lh"><span class="lvl root">root</span><small>Your root configuration. Only you change it. Each organization runs its own copy, with memory, files and credentials kept in that organization.</small></div>
<div data-org="ddigital"><span class="mlabel"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>reviewer <small>copy</small></span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>researcher <small>copy</small></span></div>
<div data-org="forge"><span class="mlabel"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>Forge</span><span class="agent "><span class="dot s-error" title="error"><b aria-hidden="true">▲</b><span class="sr">error</span></span>reviewer <small>copy</small></span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>researcher <small>copy</small></span></div>
<div data-org="home"><span class="mlabel"><span class="av xs" data-org="home" aria-hidden="true">HM</span>Home</span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>researcher <small>copy</small></span><span class="note" style="display:block;margin-top:4px">reviewer turned off for Home</span></div>
<div class="lh"><span class="lvl org">org</span><small>Defined in the organization file. The chief hires and fires here; you can override any setting.</small></div>
<div data-org="ddigital"><span class="mlabel"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</span><span class="agent cos"><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>chief</span><span class="agent "><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span>copywriter</span><span class="agent "><span class="dot s-waiting" title="waiting"><b aria-hidden="true">◐</b><span class="sr">waiting</span></span>editor</span><span class="agent "><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span>montage-producer</span></div>
<div data-org="forge"><span class="mlabel"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>Forge</span><span class="agent cos"><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>chief</span><span class="agent "><span class="dot s-working" title="working"><b aria-hidden="true">●</b><span class="sr">working</span></span>coder</span></div>
<div data-org="home"><span class="mlabel"><span class="av xs" data-org="home" aria-hidden="true">HM</span>Home</span><span class="agent cos"><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>chief</span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>planner</span></div>
<div class="lh last"><span class="lvl proj">project</span><small>Defined in one project and available only there. The chief hires and fires here too.</small></div>
<div class="last" data-org="ddigital"><span class="mlabel"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Distinctly Digital</span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>social-scheduler <small>Spring launch</small></span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>continuity-checker <small>The Lantern Coast</small></span></div>
<div class="last" data-org="forge"><span class="mlabel"><span class="av xs" data-org="forge" aria-hidden="true">FG</span>Forge</span><span class="agent "><span class="dot s-idle" title="idle"><b aria-hidden="true">–</b><span class="sr">idle</span></span>load-tester <small>API gateway</small></span></div>
<div class="last" data-org="home"><span class="mlabel"><span class="av xs" data-org="home" aria-hidden="true">HM</span>Home</span><span class="muted">none</span></div>
</div>
<div class="split" style="margin-top:4px">
<section>
<div class="sec-h"><h2>Available in a project</h2><span class="sel"><span class="av xs" data-org="ddigital" aria-hidden="true">DD</span>Spring launch<svg class="i sm" aria-hidden="true"><use href="#i-chevron"/></svg></span></div>
<div class="tbl"><table><thead><tr><th>Agent</th><th>Level</th><th class="hide-m">Defined in</th><th class="hide-m">Memory</th><th>Status</th><th class="hide-m">Model</th></tr></thead><tbody>
<tr><td class="t1">assistant</td><td><span class="lvl root">root</span></td><td class="hide-m">Your root configuration</td><td class="hide-m">Shared across all organizations</td><td><span class="dot s-working"><b aria-hidden="true">●</b>working</span></td><td class="hide-m mono">opus-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">reviewer</td><td><span class="lvl root">root</span></td><td class="hide-m">Your root configuration</td><td class="hide-m">This organization only</td><td><span class="dot s-idle"><b aria-hidden="true">–</b>idle</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">researcher</td><td><span class="lvl root">root</span></td><td class="hide-m">Your root configuration</td><td class="hide-m">This organization only</td><td><span class="dot s-idle"><b aria-hidden="true">–</b>idle</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">chief</td><td><span class="lvl org">org</span></td><td class="hide-m">Distinctly Digital file</td><td class="hide-m">This organization only</td><td><span class="dot s-idle"><b aria-hidden="true">–</b>idle</span></td><td class="hide-m mono">opus-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">copywriter</td><td><span class="lvl org">org</span></td><td class="hide-m">Distinctly Digital file</td><td class="hide-m">This organization only</td><td><span class="dot s-working"><b aria-hidden="true">●</b>working</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">editor</td><td><span class="lvl org">org</span></td><td class="hide-m">Distinctly Digital file</td><td class="hide-m">This organization only</td><td><span class="dot s-waiting"><b aria-hidden="true">◐</b>waiting</span></td><td class="hide-m mono">opus-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">montage-producer</td><td><span class="lvl org">org</span></td><td class="hide-m">Distinctly Digital file</td><td class="hide-m">This organization only</td><td><span class="dot s-working"><b aria-hidden="true">●</b>working</span></td><td class="hide-m mono">sonnet-5.5 <a href="#">Change</a></td></tr><tr><td class="t1">social-scheduler</td><td><span class="lvl proj">project</span></td><td class="hide-m">Spring launch project</td><td class="hide-m">This organization only</td><td><span class="dot s-idle"><b aria-hidden="true">–</b>idle</span></td><td class="hide-m mono">haiku-5.5 <a href="#">Change</a></td></tr>
</tbody></table></div>
<p class="note" style="margin-top:8px">Not available here: continuity-checker (The Lantern Coast only) and every Forge or Home agent.</p>
</section>
<section>
<div class="sec-h"><h2>Who changes what</h2></div>
<div class="panel tight rules">
<p><span class="lvl root">root</span> You add, change and remove root agents. The chief cannot.</p>
<p><span class="lvl org">org</span> <span class="lvl proj">project</span> The chief hires and fires to meet your needs, within the role rules. Each hire lands in the trail.</p>
<p><span class="lvl proj">override</span> You can change any agent's settings at any level, at any time. An override shows where it was set.</p>
<p class="note">Changing an agent's model is a trailed human action, applied to its running session on reload.</p>
</div>
</section>
</div>
</main>
</div>
</body>
</html>
@@ -0,0 +1,92 @@
// Renders the U0 samples to PNG with headless Chromium: every page at 1440x900 and 390x844,
// light and dark (prefers-color-scheme, no data-mode), 32 shots for 8 pages.
// Usage: node render.mjs <out-dir>
// Serves the repository over HTTP on 127.0.0.1, port 0, because Chromium refuses cross-file SVG
// <use> under file://. Only docs/design/ and the WebUI font directory are served.
// Checks each shot: no script error, no failed request, no sideways page scroll, fonts loaded.
import { createServer } from 'node:http';
import { readFile, mkdir } from 'node:fs/promises';
import { join, normalize, extname, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { browser } from '../../../../packages/webui/tests/browser.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const repo = normalize(join(here, '../../../..'));
const out = process.argv[2];
if (!out) { console.error('usage: node render.mjs <out-dir>'); process.exit(2); }
await mkdir(out, { recursive: true });
const ALLOW = ['docs/design/', 'packages/webui/src/public/assets/fonts/'];
const TYPES = { '.html': 'text/html', '.css': 'text/css', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.js': 'text/javascript' };
const failed = [];
const server = createServer(async (req, res) => {
const path = normalize(decodeURIComponent(new URL(req.url, 'http://x').pathname)).replace(/^\/+/, '');
if (path === 'favicon.ico') { res.writeHead(204).end(); return; }
if (path.includes('..') || !ALLOW.some((a) => path.startsWith(a))) { failed.push(`403 ${path}`); res.writeHead(403).end(); return; }
try {
const body = await readFile(join(repo, path));
res.writeHead(200, { 'content-type': TYPES[extname(path)] ?? 'application/octet-stream' }).end(body);
} catch { failed.push(`404 ${path}`); res.writeHead(404).end(); }
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}/docs/design/samples/2026-11-09-unified-workspace/`;
const PAGES = ['01-sidebar', '02-dashboard', '03-org-dashboard', '04-projects', '05-inbox', '06-tasks', '07-side-by-side', '08-agents'];
const SIZES = [[1440, 900], [390, 844]];
const THEMES = ['light', 'dark'];
const CHECK = `(() => {
const vw = innerWidth, wide = [];
for (const el of document.querySelectorAll('body *')) {
const r = el.getBoundingClientRect();
if (!r.width || r.right <= vw + 1) continue;
let clipped = false;
for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
const s = getComputedStyle(p);
if (/(auto|hidden|scroll|clip)/.test(s.overflowX) || s.position === 'fixed' && s.display === 'none') { clipped = true; break; }
}
if (!clipped && getComputedStyle(el).visibility !== 'hidden') wide.push(el.tagName.toLowerCase() + (el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\\s+/).join('.') : ''));
}
const faces = [...document.fonts].map((f) => f.family.replace(/"/g, '') + ' ' + f.weight + ' ' + f.status);
return { sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth, wide: wide.slice(0, 8), faces,
manrope800: document.fonts.check('800 16px Manrope'), dark: matchMedia('(prefers-color-scheme: dark)').matches,
canvas: getComputedStyle(document.body).backgroundColor };
})()`;
const b = await browser({ machine: false });
const errors = [];
b.on('Runtime.exceptionThrown', (p) => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text));
b.on('Runtime.consoleAPICalled', (p) => { if (p.type === 'error') errors.push(p.args.map((a) => a.value ?? a.description).join(' ')); });
const report = [];
let bad = 0;
try {
for (const [w, h] of SIZES) {
await b.viewport(w, h);
for (const theme of THEMES) {
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: theme }] });
for (const name of PAGES) {
errors.length = 0; failed.length = 0;
await b.navigate(base + name + '.html');
await new Promise((r) => setTimeout(r, 250));
const info = await b.evaluate(CHECK);
const file = `${name}-${w}-${theme}.png`;
await b.screenshot(join(out, file));
const problems = [];
if (errors.length) problems.push('script: ' + errors.join(' | '));
if (failed.length) problems.push('requests: ' + failed.join(', '));
if (info.sw > info.cw) problems.push(`sideways scroll ${info.sw}>${info.cw}`);
if (info.wide.length) problems.push('past the edge: ' + info.wide.join(', '));
if (info.faces.some((f) => !f.endsWith('loaded') && !f.endsWith('unloaded'))) problems.push('fonts: ' + info.faces.join(', '));
if (!info.faces.some((f) => f.startsWith('Manrope') && f.endsWith('loaded') && !f.endsWith('unloaded'))) problems.push('Manrope not loaded');
if (info.dark !== (theme === 'dark')) problems.push('theme emulation failed');
if (problems.length) bad++;
report.push({ file, canvas: info.canvas, problems });
}
}
}
} finally {
await b.close();
server.close();
}
for (const r of report) console.log(`${r.problems.length ? 'FAIL' : 'ok '} ${r.file} ${r.canvas}${r.problems.length ? '\n ' + r.problems.join('\n ') : ''}`);
console.log(`${report.length} shots, ${bad} with problems`);
process.exit(bad ? 1 : 0);
@@ -0,0 +1,450 @@
/* U0 unified workspace samples, 2026-11-09. Static mocks, not product code.
Colours, type, spacing and radii come from tokens.css. The few additions are marked EXT
and listed in the U0 design report; nothing here redefines a token. */
@import url("../../tokens.css");
/* Fonts: the WebUI's self-hosted files, so the samples render with no network. The four
manrope-*.woff2 files are one variable font (weights 200 to 800), so one face covers 400-800. */
@font-face{font-family:"Manrope";font-style:normal;font-weight:200 800;font-display:swap;src:url("../../../../packages/webui/src/public/assets/fonts/manrope-400.woff2") format("woff2")}
@font-face{font-family:"JetBrains Mono";font-style:normal;font-weight:400;font-display:swap;src:url("../../../../packages/webui/src/public/assets/fonts/jetbrains-mono-400.woff2") format("woff2")}
@font-face{font-family:"JetBrains Mono";font-style:normal;font-weight:500;font-display:swap;src:url("../../../../packages/webui/src/public/assets/fonts/jetbrains-mono-500.woff2") format("woff2")}
@font-face{font-family:"JetBrains Mono";font-style:normal;font-weight:700;font-display:swap;src:url("../../../../packages/webui/src/public/assets/fonts/jetbrains-mono-700.woff2") format("woff2")}
/* EXT 1: org identity colours. Each organization's mark takes the action colour of the palette
its theme uses (11-07 ruling 7), copied from tokens.json for the three sample palettes, with
that palette's onAction for the initials. The shell keeps the user's own palette. */
:root{--org-grove:#377752;--org-rosewood:#a53b72;--org-atlantic:#19719a;--org-on:#ffffff}
@media (prefers-color-scheme:dark){:root:not([data-mode="light"]):not([data-mode="dim"]){--org-grove:#8fcca9;--org-rosewood:#d487af;--org-atlantic:#73c3e8;--org-on:#10141a}}
:root[data-mode="dark"]{--org-grove:#8fcca9;--org-rosewood:#d487af;--org-atlantic:#73c3e8;--org-on:#10141a}
:root[data-mode="dim"]{--org-grove:#8fcca9;--org-rosewood:#dc9ebe;--org-atlantic:#73c3e8;--org-on:#10141a}
[data-org="home"]{--org:var(--org-grove)}
[data-org="ddigital"]{--org:var(--org-rosewood)}
[data-org="forge"]{--org:var(--org-atlantic)}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
html,body{height:100%}
body{margin:0;background:var(--canvas);color:var(--text);font:400 14px/1.5 var(--font)}
h1,h2,h3,h4{color:var(--strong);margin:0;line-height:1.2;text-wrap:balance}
p{margin:0}
a{color:var(--action)}
code,kbd,.mono{font-family:var(--mono);font-size:12.5px}
kbd{border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:0 .35em;background:var(--surface);color:var(--strong);white-space:nowrap;font-size:11px}
button,select,input{font:inherit;color:inherit}
:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
.tnum{font-variant-numeric:tabular-nums}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
svg.i{width:18px;height:18px;flex:none;fill:none;stroke:currentColor;stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round}
svg.i.sm{width:15px;height:15px}
.label{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
.note{font-size:13px;color:var(--muted)}
.muted{color:var(--muted)}
.sim{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--accent);border:1px dashed var(--accent);border-radius:4px;padding:0 5px;white-space:nowrap}
/* ---------- shell: command bar, section list, main, inspector (d3 layout) ---------- */
.app{display:grid;grid-template-columns:240px minmax(0,1fr);grid-template-rows:auto minmax(0,1fr);height:100vh}
.app.with-insp{grid-template-columns:240px minmax(0,1fr) minmax(300px,360px)}
.cmdbar{grid-column:1/-1;display:flex;align-items:center;gap:12px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--surface);min-width:0}
.cmdbar .mk{display:flex;align-items:center;gap:8px;font-weight:800;color:var(--strong);letter-spacing:-.01em;white-space:nowrap;text-decoration:none}
.cmdbar .mk svg{width:24px;height:auto}
/* The WebUI's temporary four-tile logo (index.html, processing.css), kept by owner ruling 11-09 until a real mark is chosen. */
.mosaic-logo{flex:none;fill:var(--action)}.mosaic-logo rect:nth-child(2){fill:color-mix(in srgb,var(--action) 75%,var(--strong))}.mosaic-logo rect:nth-child(4){fill:color-mix(in srgb,var(--action) 45%,var(--canvas))}
.cmdbar .menu{display:none;border:1px solid var(--line);background:var(--canvas);border-radius:var(--r);padding:4px;color:var(--strong)}
.cmdk{flex:1;max-width:520px;display:flex;align-items:center;gap:8px;border:1px solid var(--line);background:var(--canvas);border-radius:var(--r);padding:5px 10px;color:var(--muted);text-align:left;min-width:0}
.cmdk span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fresh{display:flex;align-items:center;gap:8px;margin-left:auto;font-size:12px;color:var(--muted);white-space:nowrap}
.fresh b{color:var(--success);font-weight:600}
.me{display:inline-grid;place-items:center;width:28px;height:28px;border-radius:50%;background:var(--raised);color:var(--strong);font-weight:700;font-size:12px;flex:none}
.side{border-right:1px solid var(--line);background:var(--surface);overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent;padding:10px 8px 14px;display:flex;flex-direction:column;gap:2px;min-height:0}
.nav{display:flex;align-items:center;gap:9px;padding:5px 8px;border-radius:var(--r);color:var(--text);text-decoration:none;font-weight:600;font-size:13.5px;min-width:0}
.nav:hover{background:var(--raised)}
.nav[aria-current="page"]{background:color-mix(in srgb,var(--action) 13%,var(--surface));color:var(--strong)}
.nav[aria-current="page"] svg.i{color:var(--action)}
.nav .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.n{margin-left:auto;font:600 11px var(--mono);color:var(--muted);font-variant-numeric:tabular-nums}
.n.hot{color:var(--surface);background:var(--danger);border-radius:999px;padding:0 6px}
.side hr,.specimen .mini hr{border:0;border-top:1px solid var(--line);margin:6px 4px}
.side .foot{margin-top:auto;padding-top:8px}
/* EXT 2: collapsible sidebar group. A label row with a chevron; closed groups keep their count. */
.grp{display:flex;flex-direction:column;gap:1px}
.grp-h{display:flex;align-items:center;gap:6px;padding:10px 8px 4px;color:var(--muted)}
.grp-h .label{flex:1}
.grp-h svg.i{width:14px;height:14px;transition:transform .15s}
.grp-h[aria-expanded="false"] svg.chev{transform:rotate(-90deg)}
.grp-h .seg{margin-left:auto}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:var(--r);overflow:hidden;background:var(--surface)}
.seg > span,.seg > button{border:0;background:none;padding:2px 8px;color:var(--muted);font-size:12px;font-weight:600;display:inline-flex;align-items:center;gap:5px;white-space:nowrap}
.seg [aria-pressed="true"]{background:var(--action);color:var(--onAction)}
.seg.xs > span{padding:0 6px;font-size:11px}
/* EXT 3: org mark. Avatar tile in the org's colour with its initials (or image), always beside the
name or initials in text: colour is never the only cue. Sizes 16, 20, 28, 44. */
.av{display:inline-grid;place-items:center;flex:none;width:20px;height:20px;border-radius:5px;background:var(--org,var(--muted));color:var(--org-on);font:700 9.5px/1 var(--font);letter-spacing:.02em}
.av.xs{width:16px;height:16px;border-radius:4px;font-size:8.5px;letter-spacing:0}
.av.md{width:28px;height:28px;border-radius:7px;font-size:12px}
.av.lg{width:44px;height:44px;border-radius:10px;font-size:17px}
.av.all{background:var(--raised);color:var(--strong)}
.orgref{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;color:var(--strong);font-weight:600}
.orgref.quiet{color:var(--text);font-weight:500}
.org-row .ic{color:var(--muted)}
.org-row .ic svg.i{width:15px;height:15px}
/* Org edge: a 3px stripe in the org colour on cards, rows and panes that belong to one org. */
.edge{box-shadow:inset 3px 0 0 var(--org)}
.pgroup{display:flex;flex-direction:column;gap:1px;margin-bottom:4px}
.pgroup-h{display:flex;align-items:center;gap:7px;padding:4px 8px 2px;font-size:12px;font-weight:700;color:var(--strong)}
.pgroup-h svg.i{width:13px;height:13px;color:var(--muted)}
.proj{padding-left:20px;padding-top:3px;padding-bottom:3px;font-weight:500;font-size:13px}
.proj .dot-s{font:400 12px/1 var(--glyph);width:12px;text-align:center}
.main{overflow:auto;min-width:0;min-height:0;padding:18px 24px 28px}
.crumb{font-size:12.5px;color:var(--muted);margin-bottom:4px;display:flex;gap:6px;align-items:center;flex-wrap:wrap}
.crumb a{color:var(--muted)}
.vh{display:flex;flex-wrap:wrap;gap:8px 14px;align-items:center;margin-bottom:14px}
.vh h1{font-size:28px;font-weight:800;letter-spacing:-.02em}
.vh .sub{flex-basis:100%;font-size:13.5px;color:var(--muted)}
.vh .right{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
h2.sec{font-size:20px;font-weight:800;letter-spacing:-.015em}
h3.q{font-size:16px;font-weight:700}
.sec-h{display:flex;align-items:baseline;gap:10px;margin:22px 0 8px;flex-wrap:wrap}
.sec-h h2,.sec-h h3{font-size:15px;font-weight:700}
.sec-h .note{flex:1}
.sec-h a{font-size:13px;font-weight:600;text-decoration:none}
.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);padding:16px;min-width:0}
.panel.tight{padding:12px 14px}
.grid{display:grid;gap:var(--gap)}
.g2{grid-template-columns:repeat(2,minmax(0,1fr))}
.g3{grid-template-columns:repeat(3,minmax(0,1fr))}
.g4{grid-template-columns:repeat(4,minmax(0,1fr))}
.g5{grid-template-columns:repeat(5,minmax(0,1fr))}
.split{display:grid;gap:16px;grid-template-columns:minmax(0,1.45fr) minmax(0,1fr);align-items:start}
/* status vocabulary: symbol plus word, never colour alone */
.dot{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
/* EXT 10, status glyph font: Manrope has no geometric shapes, and the generic fallback (Liberation Sans here)
draws the waiting half-circle at half the size of the dot. Name fonts that draw all five at one size. */
:root{--glyph:"DejaVu Sans","Segoe UI Symbol","Apple Symbols","Noto Sans Symbols 2",var(--font)}
.dot b{font:400 13px/1 var(--glyph);width:14px;text-align:center}
.s-working b{color:var(--success)}.s-waiting b{color:var(--warning)}.s-error b{color:var(--danger)}
.s-offline b,.s-idle b,.s-unknown b{color:var(--muted)}
.chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;padding:1px 7px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap}
.chip.gated{background:color-mix(in srgb,var(--danger) 14%,var(--surface));color:var(--danger)}
.chip.block{background:var(--danger);color:var(--surface)}
.chip.cross{background:color-mix(in srgb,var(--accent) 16%,var(--surface));color:var(--accent)}
.chip.within,.chip.routine{background:var(--raised);color:var(--muted)}
.chip.warn{background:color-mix(in srgb,var(--warning) 16%,var(--surface));color:var(--warning)}
.chip.ok{background:color-mix(in srgb,var(--success) 15%,var(--surface));color:var(--success)}
.chip.outline{background:none;border:1px solid var(--line);color:var(--text);font-weight:500}
.chips{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.chips .f{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:999px;padding:2px 10px 2px 4px;font-size:12.5px;font-weight:600;color:var(--text);white-space:nowrap}
.chips .f.plain{padding-left:10px}
.chips .f[aria-pressed="true"]{background:var(--strong);color:var(--surface);border-color:var(--strong)}
.chips .f .av{width:18px;height:18px;border-radius:999px;font-size:8.5px;letter-spacing:0}
.chips .f .n{margin-left:2px;color:inherit;opacity:.8}
.btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font-size:13px;font-weight:600;color:var(--strong);white-space:nowrap;text-decoration:none}
.btn.pri{background:var(--action);border-color:var(--action);color:var(--onAction)}
.btn.ghost{border-color:transparent;background:none;color:var(--muted)}
.btn svg.i{width:15px;height:15px}
.sel{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:3px 8px;font-size:13px;color:var(--strong);white-space:nowrap}
.sel .label{font-size:10px}
.sel svg.i{width:13px;height:13px;color:var(--muted)}
.search{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:3px 8px;color:var(--muted);font-size:13px;min-width:180px}
.toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;margin-bottom:12px}
.toolbar .right{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
/* dense tables */
.tbl{overflow-x:auto;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface)}
table{border-collapse:collapse;width:100%;font-size:13px}
th{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;background:var(--surface)}
th.sorted{color:var(--strong)}
th .ar{font-family:var(--mono);margin-left:3px;color:var(--action)}
td{padding:7px 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);vertical-align:middle}
tr:last-child td{border-bottom:0}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
td.mono{font-family:var(--mono);font-size:12.5px;white-space:nowrap}
td .t1{color:var(--strong);font-weight:600}
td .t2{display:block;font-size:12px;color:var(--muted)}
tbody tr.row[aria-selected="true"]{background:color-mix(in srgb,var(--action) 10%,var(--surface));box-shadow:inset 3px 0 0 var(--action)}
tbody tr.dim td{color:var(--muted)}
/* EXT 4: metric tile with a per-org breakdown. The total is the headline; the stacked bar and the
labelled list under it carry the same numbers by org, so the bar is never read on colour alone. */
.tile{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-table);padding:12px 14px;min-width:0;display:grid;gap:6px;align-content:start}
.tile .big{font-size:24px;font-weight:800;color:var(--strong);letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.1}
.tile .big small{font-size:13px;font-weight:600;color:var(--muted);letter-spacing:0;margin-left:4px}
.tile .delta{font-size:12px;color:var(--muted)}
.tile .delta b{color:var(--success);font-weight:600}
.tile .delta b.down{color:var(--danger)}
.bar{display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--raised);gap:2px}
.bar i{display:block;background:var(--org,var(--action))}
.by{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:12px;color:var(--text)}
.by span{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;font-variant-numeric:tabular-nums}
.by .av{width:14px;height:14px;border-radius:3px;font-size:7px}
/* activity trail across orgs */
.trail{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface)}
.trail li{display:grid;grid-template-columns:48px 20px minmax(0,1fr);gap:10px;padding:7px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);font-size:13px;align-items:center}
.trail li:last-child{border-bottom:0}
.trail time{font-family:var(--mono);font-size:12px;color:var(--muted)}
.trail .what{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.trail .what b{color:var(--strong);font-weight:600}
.trail .what .src{color:var(--muted)}
/* needs-you list (inbox rows on dashboards) */
.needs{list-style:none;margin:0;padding:0;display:grid;gap:8px}
.needs li{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:4px 10px;align-items:center;background:var(--surface);border:1px solid var(--line);border-radius:var(--r-table);padding:9px 12px}
.needs .q{color:var(--strong);font-weight:600;font-size:13.5px}
.needs .src{grid-column:2;font-size:12px;color:var(--muted)}
.needs .chip{grid-row:1/3;grid-column:3}
.kv{display:grid;grid-template-columns:auto 1fr;gap:5px 12px;margin:0;font-size:13px}
.kv dt{color:var(--muted)}
.kv dd{margin:0;color:var(--strong);min-width:0;overflow-wrap:anywhere}
/* EXT 5: settings index. Every configurable is one row: group, current value, where the value comes
from, and a link to its editor. The dashboard reaches every setting without showing all inline. */
.cfg{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface);overflow:hidden}
.cfg li{display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:2px 10px;align-items:center;padding:9px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);border-right:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
.cfg li:nth-child(2n){border-right:0}
.cfg li:nth-last-child(-n+2){border-bottom:0}
.cfg svg.i{color:var(--muted);width:16px;height:16px}
.cfg .k{font-weight:600;color:var(--strong);font-size:13px}
.cfg .v{grid-column:2;font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cfg .go{grid-row:1/3;grid-column:3;font-size:12.5px;font-weight:600;text-decoration:none}
.src-tag{font:500 11px var(--mono);color:var(--accent)}
.docs{list-style:none;margin:0;padding:0;display:grid;gap:2px}
.docs li{display:grid;grid-template-columns:18px minmax(0,1fr) auto;gap:10px;align-items:center;padding:6px 4px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);font-size:13px}
.docs li:last-child{border-bottom:0}
.docs svg.i{width:16px;height:16px;color:var(--muted)}
.docs .d{color:var(--strong);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.docs time{font-size:12px;color:var(--muted);white-space:nowrap}
/* org header on the org dashboard */
.orghead{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px}
.orghead h1{font-size:28px;font-weight:800;letter-spacing:-.02em;display:flex;align-items:center;gap:10px}
.orghead h1 svg.i{width:20px;height:20px;color:var(--muted)}
.orghead .meta{flex-basis:100%;display:flex;flex-wrap:wrap;gap:4px 14px;font-size:13px;color:var(--muted);margin-top:-6px;padding-left:58px}
.orghead .right{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}
.typechips{display:flex;flex-wrap:wrap;gap:6px}
/* EXT 6: Kanban. Columns by state; a card carries the org mark, the task id from its tracker, the
project and the assignee's status. Cards from several orgs share one column. */
.board{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;align-items:start}
.col{background:color-mix(in srgb,var(--raised) 55%,var(--canvas));border:1px solid var(--line);border-radius:var(--r-lg);padding:8px;display:grid;gap:8px;min-width:0}
.col-h{display:flex;align-items:center;gap:8px;padding:2px 4px}
.col-h h3{font-size:13px;font-weight:700}
.col-h .n{margin-left:0}
.col-h .mix{margin-left:auto;display:flex;gap:2px}
.kcard{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-table);padding:8px 10px 8px 12px;display:grid;gap:5px;min-width:0}
.kcard .top{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
.kcard .top .id{font:500 11.5px var(--mono);color:var(--muted)}
.kcard .top .trk{margin-left:auto}
.kcard .tt{color:var(--strong);font-weight:600;font-size:13px;line-height:1.35}
.kcard .bot{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);flex-wrap:wrap}
.kcard.you{border-color:color-mix(in srgb,var(--warning) 55%,var(--line))}
/* EXT 7: tracker badge. Names the task tracker module behind an org: built-in or Vikunja. */
.trk{display:inline-flex;align-items:center;gap:4px;font:600 10.5px var(--mono);letter-spacing:.02em;padding:0 5px;border-radius:3px;border:1px solid var(--line);color:var(--muted);white-space:nowrap}
.trk.vik{border-style:dashed;color:var(--accent);border-color:color-mix(in srgb,var(--accent) 60%,var(--line))}
.trackers{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center;font-size:12.5px;color:var(--text);padding:8px 12px;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface);margin-bottom:12px}
.trackers > span{display:inline-flex;align-items:center;gap:6px}
/* EXT 8: side-by-side panes. Each pane has its own header with the org mark and edge, and its own
scroll; panes never share state. Two panes at 1440; one pane with a pane switcher on a phone. */
.panes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0;height:100%;min-height:0}
.pane{display:flex;flex-direction:column;min-width:0;min-height:0;border-right:1px solid var(--line)}
.pane:last-child{border-right:0}
.pane-h{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--surface);box-shadow:inset 0 3px 0 var(--org);min-width:0}
.pane-h .ttl{font-weight:700;color:var(--strong);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pane-h .ttl small{font-weight:500;color:var(--muted);margin-left:6px}
.pane-h .tools{margin-left:auto;display:flex;gap:2px}
.pane-h .tools span{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:var(--r);color:var(--muted)}
.pane-body{overflow:auto;padding:14px;min-height:0;flex:1}
.pane-tabs{display:none}
.main.flush{padding:0;overflow:hidden;display:flex;flex-direction:column}
.panebar{display:flex;align-items:center;gap:10px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--canvas);flex-wrap:wrap}
.panebar h1{font-size:16px;font-weight:700}
/* EXT 9: agent level badge and the three-level map. */
.lvl{display:inline-flex;align-items:center;gap:4px;font:700 10.5px var(--font);letter-spacing:.06em;text-transform:uppercase;padding:1px 6px;border-radius:3px;white-space:nowrap}
.lvl.root{background:var(--strong);color:var(--surface)}
.lvl.org{background:color-mix(in srgb,var(--org,var(--action)) 18%,var(--surface));color:var(--strong);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--org,var(--action)) 50%,transparent)}
.lvl.proj{background:none;color:var(--text);box-shadow:inset 0 0 0 1px var(--line)}
.levels{display:grid;grid-template-columns:200px repeat(3,minmax(0,1fr));border:1px solid var(--line);border-radius:var(--r-lg);overflow:hidden;background:var(--surface)}
.levels > div{padding:10px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);border-right:1px solid color-mix(in srgb,var(--line) 60%,transparent);min-width:0}
.levels > div:nth-child(4n){border-right:0}
.levels .lh{background:color-mix(in srgb,var(--raised) 45%,var(--surface));display:grid;gap:4px;align-content:start}
.levels .lh small{font-size:11.5px;color:var(--muted);line-height:1.35}
.levels .lh .lvl{justify-self:start}
.levels .oh{display:flex;align-items:center;gap:8px;font-weight:700;color:var(--strong);background:color-mix(in srgb,var(--raised) 45%,var(--surface))}
.levels .span3{grid-column:2/5;border-right:0}
.levels .last{border-bottom:0}
.agent{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:2px 10px 2px 8px;font-size:12.5px;font-weight:600;color:var(--strong);background:var(--canvas);white-space:nowrap;margin:2px 4px 2px 0}
.agent .dot b{width:auto}
.agent.assist{border-color:var(--action);background:color-mix(in srgb,var(--action) 10%,var(--surface))}
.agent.cos{border-style:solid}
.agent small{font-weight:500;color:var(--muted)}
.inst{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
.span-band{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:8px 12px;border-radius:var(--r-table);border:1px solid var(--action);background:color-mix(in srgb,var(--action) 8%,var(--surface));font-size:13px;color:var(--strong)}
.span-band .orgs{display:flex;gap:4px}
/* inspector */
.insp{border-left:1px solid var(--line);background:var(--surface);overflow:auto;min-height:0;display:flex;flex-direction:column}
.insp header{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--surface);z-index:1}
.insp header .x{margin-left:auto;color:var(--muted);display:inline-flex}
.insp .body{padding:14px;display:grid;gap:14px}
.insp h3{font-size:16px;font-weight:700}
.opts{list-style:none;margin:0;padding:0;display:grid;gap:8px}
.opts li{border:1px solid var(--line);border-radius:var(--r-table);padding:9px 10px;display:grid;gap:6px;background:var(--canvas)}
.opts li.rec{border-color:var(--action)}
.opts .k{font:700 13px var(--mono);color:var(--strong)}
.opts .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.cmd{display:flex;gap:6px;align-items:center;min-width:0}
.cmd code{flex:1;min-width:0;overflow-x:auto;white-space:nowrap;background:var(--surface);border:1px solid var(--line);border-radius:4px;padding:3px 6px;font-size:12px;color:var(--strong)}
ol.hist{list-style:none;margin:0;padding:0;display:grid;gap:6px;font-size:12.5px}
ol.hist li{display:grid;grid-template-columns:48px 1fr;gap:8px}
ol.hist time{font-family:var(--mono);color:var(--muted)}
.auth{border:1px solid var(--line);border-left:3px solid var(--org,var(--action));border-radius:0 6px 6px 0;padding:8px 10px;background:var(--canvas);font-size:13px;color:var(--strong)}
/* inbox list */
.ibx td.src{white-space:nowrap}
.ibx .q{color:var(--strong);font-weight:600}
.ibx .via{display:block;font-size:12px;color:var(--muted)}
/* sidebar details: add button, collapsed marks, project rail */
.grp-h .add{display:inline-grid;place-items:center;width:22px;height:22px;border-radius:var(--r);color:var(--muted);border:1px solid var(--line)}
.grp-h .n{margin-left:0}
.collapsed-marks{display:flex;gap:4px;padding:0 8px 6px 28px}
.pgroup .projs{margin-left:17px;border-left:2px solid var(--org);display:flex;flex-direction:column;gap:1px}
.pgroup .proj{padding-left:10px}
.proj.flat{padding-left:8px}
.proj .n{font-family:var(--font);font-weight:500;font-size:11px}
.s-ok b{color:var(--success)}
/* tasks by state on the main dashboard */
.states{display:grid;gap:8px}
.srow{display:grid;grid-template-columns:112px 24px minmax(0,1fr) auto;gap:10px;align-items:center;font-size:13px}
.srow .sl{color:var(--strong);font-weight:600}
.srow .sn{font:700 13px var(--mono);color:var(--strong);text-align:right}
.srow .bar{height:8px;max-width:100%}
.needs li > .av{grid-row:1/3}
/* settings index, one column, with the destructive row set apart */
.cfg.one{grid-template-columns:minmax(0,1fr)}
.cfg.one li{grid-template-columns:18px 132px minmax(0,1fr) auto auto;gap:0 10px;border-right:0;padding:7px 12px}
.cfg.one .v{grid-column:3;grid-row:1}
.cfg.one .src-tag{grid-column:4;grid-row:1}
.cfg.one .go{grid-column:5;grid-row:1}
.cfg.one li:nth-last-child(2){border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
.cfg li.danger .k,.cfg li.danger svg.i,.cfg li.danger .go{color:var(--danger)}
/* live transcript, mini tiles and sub tabs inside a pane */
.term{margin:0;font:400 12px/1.55 var(--mono);background:var(--canvas);color:var(--text);border:1px solid var(--line);border-radius:var(--r-table);padding:8px 10px;white-space:pre-wrap;overflow-wrap:anywhere}
.term .m{color:var(--muted)}
.term .c{color:var(--action)}
.mini-tiles .tile{padding:8px 10px;gap:2px}
.mini-tiles .tile .big{font-size:20px}
.subtabs{display:flex;gap:2px;border-bottom:1px solid var(--line);margin:-4px 0 12px}
.subtabs span{padding:4px 10px;font-size:13px;font-weight:600;color:var(--muted);border-bottom:2px solid transparent;margin-bottom:-1px}
.subtabs [aria-current="page"]{color:var(--strong);border-bottom-color:var(--org,var(--action))}
.rules{display:grid;gap:10px;font-size:13px}
.rules p{display:flex;gap:6px;flex-wrap:wrap;align-items:baseline}
/* one-line session rows on the main dashboard */
.sess td.who{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:0;width:100%}
.sess td.who .orgref{vertical-align:middle}
.sess td.st{white-space:nowrap}
/* docs row on the org dashboard */
.docrow{margin-top:8px}
.docrow .doc{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:2px 8px;font-size:12.5px;font-weight:600;color:var(--strong);text-decoration:none;white-space:nowrap}
.docrow .doc svg.i{width:14px;height:14px;color:var(--muted)}
.docrow .doc small{font-weight:500;color:var(--muted)}
.docrow .more{font-size:13px;font-weight:600;text-decoration:none;margin-left:4px}
/* source line inside a row, used on a phone where the source column is hidden */
.msrc{align-items:center;gap:5px;font-size:12px;color:var(--muted);margin-bottom:2px}
.av.mob{display:none}
/* phone-only labels and rows; desktop hides them */
.show-m,.mlabel{display:none}
td .t2.show-m{display:none}
/* mobile strip and drawer */
.strip{display:none}
.specimen{display:grid;gap:14px;grid-template-columns:repeat(3,minmax(0,1fr));align-items:start}
.specimen .mini{border:1px solid var(--line);border-radius:var(--r-lg);background:var(--surface);padding:8px;display:flex;flex-direction:column;gap:2px}
.specimen figcaption{font-size:12.5px;color:var(--muted);margin-top:6px}
.specimen figure{margin:0}
.callouts{margin:14px 0 0;padding:0 0 0 18px;display:grid;gap:6px;font-size:13px}
.callouts b{color:var(--strong)}
@media (max-width:1100px){
.app.with-insp{grid-template-columns:220px minmax(0,1fr)}
.app.with-insp .insp{position:fixed;right:0;top:49px;bottom:0;width:min(380px,100%);box-shadow:-12px 0 30px -12px color-mix(in srgb,#000 35%,transparent);z-index:10}
.g5{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media (max-width:760px){
.app,.app.with-insp{display:block;height:auto;min-height:100vh}
.cmdbar{position:sticky;top:0;z-index:20;gap:8px;padding:8px 12px}
.cmdbar .menu{display:inline-flex}
.cmdbar .mk span,.cmdbar .sim,.fresh .wide{display:none}
.cmdk{max-width:none}
.cmdk kbd{display:none}
.fresh{margin-left:0}
.side{display:none}
.strip{display:flex;gap:2px;overflow-x:auto;padding:6px 8px;border-bottom:1px solid var(--line);background:var(--surface)}
.strip .nav{white-space:nowrap;flex:none}
.app.drawer .side{display:flex;position:fixed;inset:49px 0 0 0;z-index:30;border-right:0;padding:10px 12px 20px}
.app.drawer .strip{display:none}
.main{padding:14px 16px 24px;overflow:visible}
.vh h1,.orghead h1{font-size:24px}
.g2,.g3,.g4,.g5,.split,.specimen{grid-template-columns:minmax(0,1fr)}
.g4.keep2,.g5.keep2{grid-template-columns:repeat(2,minmax(0,1fr))}
.app.with-insp .insp{position:static;display:flex;width:auto;box-shadow:none;border-left:0;border-top:1px solid var(--line);margin:0 0 24px}
.show-m,td .t2.show-m{display:block}
span.show-m{display:inline-flex}
span.msrc.show-m{display:flex}
.av.mob{display:inline-grid;margin-right:6px;vertical-align:-3px}
.strip{scrollbar-width:none}
.g5.keep2 > :last-child:nth-child(odd){grid-column:1/-1}
.sec-h .note{flex-basis:100%;order:3}
.grid.g3.mini-tiles{grid-template-columns:repeat(3,minmax(0,1fr))}
.mini-tiles .tile .big{font-size:18px}
.mini-tiles .tile .big small{display:block;margin-left:0;font-size:11.5px}
.cfg.one li{grid-template-columns:18px minmax(0,1fr) auto auto;gap:2px 10px}
.cfg.one .k{grid-column:2;grid-row:1}
.cfg.one .src-tag{grid-column:3;grid-row:1}
.cfg.one .go{grid-column:4;grid-row:1/3}
.cfg.one .v{grid-column:2/4;grid-row:2}
.mlabel{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:700;color:var(--strong);margin-bottom:6px}
.board,.tasklist .sec-h{display:none}
.vh .seg .kb{background:none;color:var(--muted)}
.vh .seg .ls{background:var(--action);color:var(--onAction)}
.srow{grid-template-columns:92px 20px minmax(0,1fr)}
.srow .by{grid-column:1/-1}
.cfg{grid-template-columns:minmax(0,1fr)}
.cfg li{border-right:0}
.cfg li:nth-last-child(2){border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
.orghead .meta{padding-left:0;margin-top:0}
.orghead .right{margin-left:0}
.levels{grid-template-columns:minmax(0,1fr)}
.levels > div{border-right:0}
.levels .span3{grid-column:auto}
.levels .hide-m{display:none}
.panes{grid-template-columns:minmax(0,1fr);height:auto}
.pane{border-right:0}
.pane.alt{display:none}
.pane-tabs{display:flex}
.main.flush{overflow:visible}
.hide-m{display:none}
.trail li{grid-template-columns:42px 18px minmax(0,1fr);gap:8px}
.trail .what{white-space:normal}
.toolbar .right{margin-left:0}
.search{min-width:0;flex:1}
}
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
+1 -2
View File
@@ -1,7 +1,6 @@
# PRD version 1.20, draft 2: unified workspace
- Status: draft, not approved. Jason approves the exact text before it
enters `docs/prd/mosaic-stack.md` as version 1.20.
- Status: approved as version 1.20 on 2026-11-09; landed in docs/prd/mosaic-stack.md
- Source: `docs/plans/2026-11-09_unified-workspace-owner-direction.md`,
Jason's direction and answers of 2026-11-09.
- Drafted by Mos (lead).
@@ -231,9 +231,46 @@ into another user's organization, and access checks on the cross-org read
model. The design lands with Phase U so the read model has membership from
the start.
## U0 approval (11-09, 19:26Z)
Owner text, verbatim:
> The U0 screen designs look good. We need to retain the temporary logo
> already used in the site.
> I don't like these module names either. It isn't clicking for me. That may
> need to be a later decision.
What follows from it:
1. The samples in `docs/design/samples/2026-11-09-unified-workspace/` are the
acceptance target for U2 to U5.
2. The WebUI's four-tile mark stays the logo. The samples now draw it in
place of the design system's Relay placeholder, and the build does not
adopt Relay.
3. The Montage module name stays open. Two naming rounds produced no pick.
The label "Mosaic Montage" stays until Jason names it; nothing in phase U
depends on the name.
The ten open questions in the U0 report were not answered one by one. Under
the lead's standing authority to decide routine choices, phase U takes the
report's recommendations, and Jason can overturn any of them:
- Every decide command copied from a shared view carries `--org`.
- The chief may propose a root-level hire only as a gated inbox item the user
approves.
- An org's colour is its own theme's action colour, the same for every member.
- Side by side on a phone is a two-way switch with one pane visible.
- Sidebar projects default to By org, remembered per viewer.
- Tasks on a phone default to List, with Kanban one tap away.
- The CLI verbs for the new header controls are `mosaic org reload <id>` and
`mosaic console restart`, added before U2 wires the buttons.
- Members and invites shows in the settings index, labelled "not built".
- Messages becomes Assistant. Docs live on org dashboards and in panes. Queue
opens from each org dashboard.
## Open items
- The final name for the Mosaic Montage module.
- The final name for the Mosaic Montage module (deferred by Jason, 11-09).
- Which "sign-in (model proxy)" step Jason hit. F3 inventories every manual
step on the dev instance and reports it.
+126 -20
View File
@@ -1,12 +1,11 @@
# PRD: Mosaic Stack
- Status: approved, version 1.19. Jason approved the exact text in Q289.
- Version 1.19 adds disk-space governance, launch-capacity holds,
human-controlled retention rules, and the protected `data.delete.policy`
route. It preserves per-batch `data.delete` hard waits. Jason approved
the exact text at `e5400456` under REQ-WAIT-2: "1.18: yes" (DECISIONS
Q289, relayed by Mos in mos-2300). Q288 approved the recurring-rule principle.
- Prior approval history, through version 1.18: Jason approved draft 0.4 unchanged as
- Status: approved, version 1.20. Jason approved draft 2 on 2026-11-09.
- Version 1.20 adds the unified workspace: every organization visible at once,
cross-organization views, three levels of agent configuration, per-org task
stores behind the task tracker kind, system type plug-ins and self-service
operation. It amends REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3.
- Prior approval history, through version 1.19: Jason approved draft 0.4 unchanged as
1.0 on 2026-10-05 (lead decision 61). They approved version 1.1, the
Organization requirements, in decision 03130d08-ddce-480d-a601-e59f64744fc3
on 2026-10-14. They approved version 1.2, the chief of staff, the
@@ -64,7 +63,18 @@
the approved row 255 design. The stack prints the providers entry and
never writes config.json. What the requirement allows or forbids is
unchanged (mos-1671, sage-4418). Doc-only, so it takes the 1.11 path:
one seat drafts, a second reviews, Sage lands.
one seat drafts, a second reviews, Sage lands. Version 1.19 adds
disk-space governance, launch-capacity holds, human-controlled retention
rules, and the protected `data.delete.policy` route. It preserves per-batch
`data.delete` hard waits. Jason approved the exact text at `e5400456` under
REQ-WAIT-2: "1.18: yes" (DECISIONS Q289, relayed by Mos in mos-2300).
Q288 approved the recurring-rule principle. Jason approved the exact text in Q289.
- Version 1.20 source: Mos asked Jason to approve draft 2 in
`docs/plans/2026-11-09_prd-1-20-draft.md`, with the Montage name deferred.
Jason replied on 2026-11-09 at 22:57Z: "The file looks fine other than that."
The exception is the module name, which he had deferred at 19:26Z: "I don't
like these module names either. It isn't clicking for me. That may need to be
a later decision."
- Version 1.19 source, Jason's DECISIONS Q287, verbatim:
> All of the learnings from this drive space issue need to be integrated into Mosaic Stack. We need watchers, cleanups, enforcement tooling and agents to check for space waste, etc.
@@ -475,9 +485,11 @@ Added in version 1.2:
names the roles and how many of each. The chief of staff launches them
through role.launch, within the agent limits (REQ-LIMIT-1). (Version
1.5.)
- **REQ-ASSIST-1.** The assistant is the user's main agent. Each user has
one assistant role per organization. It talks with the user, and it
passes work for the organization to the chief of staff. It doesn't
- **REQ-ASSIST-1.** The assistant is the user's main agent. Each user has one
assistant across all of the organizations they
belong to. It knows the user's organizations, projects and sessions and
keeps one shared memory. It talks with the user and passes each
organization's work to that organization's chief of staff. It doesn't
direct other agents, create or assign tasks, or launch sessions.
- **REQ-ASSIST-2.** The broker limits the assistant's messages, not its
contract alone:
@@ -522,6 +534,24 @@ Added in version 1.7:
- Every hire and fire is logged with the chief's instance, its run and
the reason.
### Agent levels
Added in version 1.20.
- **REQ-AGENT-1.** Agents are configured at three levels: the user's root,
the organization and the project. A project's available agents are the
union of the three.
- **REQ-AGENT-2.** The user can see and change the configuration of agents,
organizations, projects and sessions, and can override any setting at any
time, through the write path (REQ-ROLE-3).
- **REQ-AGENT-3.** The chief of staff hires and fires agents to meet the
user's needs. At the organization and project levels it acts under
REQ-COS-3. At the root level its hire or fire changes the user's root
configuration, which every organization of that user sees, so it reaches
outside the chief's own organization. A root-level hire or fire is a
decision the user approves in the inbox (REQ-DEC-2) before it takes
effect. The user can also change root-level agents directly.
### Variables
- **REQ-VAR-1.** Variables come in four layers: system, business, project
@@ -879,8 +909,15 @@ consumed ordinary decision as standing permission.
### Tasks
- **REQ-TASK-1.** Tasks live in Vikunja, reached through `/api/v2` with a
bot token for each role. Each task cites a requirement id. Each task
- **REQ-TASK-1.** Each organization stores its own tasks, through the task tracker
plug-in kind (REQ-PLUG-1). The default module is a store built into Mosaic
Stack. Vikunja is an optional module, and when an organization selects it
the 1.19 rules for Vikunja (bot tokens per role, one writing role per field,
REQ-TASK-2 polling) apply to that organization. Every task tracker module
supports create, read, update, archive and delete, ordering and grouping.
Archiving or removing an organization removes its tasks without touching
another organization's.
Each task cites a requirement id. Each task
field has one writing role, listed in the slice 1 brief's field table,
and the assigned role writes the task's state. Since version 1.19, the
scoped disk-service principal in REQ-DISK-12 may initialize incident
@@ -1031,10 +1068,40 @@ Added in version 1.7:
- The decision event records via=console and the (iss, sub) pair.
- Without a signed-in session, the Console resolves nothing and shows
the `mosaic decide` line instead.
- **REQ-WEB-3.** The Console has controls to restart the bus and to
reload the organization file. Each control is a gated, trailed action.
A reload applies `launch.instances` changes to running sessions without
a manual `systemctl restart`. (Version 1.4, decision 021af0c8.)
- **REQ-WEB-3.** The Console has controls to restart itself, to restart an
organization's bus, to reload the organization file, and to change the model
an agent instance runs on and apply it. Each control is a gated, trailed
action that a human takes. No agent can take it. A reload applies
`launch.instances` changes to running sessions without a manual
`systemctl restart`. On a dev instance (`--dev-local-owner`) the controls
need no sign-in.
### Workspace
Added in version 1.20.
- **REQ-WS-1.** The WebUI shows every organization the user belongs to at
all times. A collapsible Organizations item in the sidebar lists them.
There is no organization switcher.
- **REQ-WS-2.** The Console reads across the buses of the user's
organizations to build shared views. Each organization keeps its own bus
as the tenant boundary (REQ-ORG-1). A shared view shows only
organizations the user belongs to.
- **REQ-WS-3.** The main dashboard sums activity, work and metrics across
all of the user's organizations and projects.
- **REQ-WS-4.** Each organization has a dashboard with its docs, every
configurable setting, its KPIs and metrics, and its active agent
sessions.
- **REQ-WS-5.** The projects list covers every organization. It sorts and
filters by organization, status, name and activity. Each project shows
its owning organization by icon, color and avatar.
- **REQ-WS-6.** One inbox holds every item that needs the user, from every
organization, project and session.
- **REQ-WS-7.** One view shows activity across all organizations and
projects. Organizations and projects can be open side by side.
- **REQ-WS-8.** The tasks view aggregates every organization's tasks, with
Kanban and list layouts, filtered by organization and project. It works
the same for every task tracker module.
### Launching
@@ -1173,6 +1240,17 @@ Added in version 1.3 (Q155).
changes it, not in one rewrite (Q155). Until it converts, Technical
considerations lists where it is hard-coded.
### System types
Added in version 1.20.
- **REQ-SYS-1.** System types are a plug-in kind. Its modules include
coding, creative writing, marketing, Mosaic Montage (the OpenMontage
fork) and business management. An organization enables a list of them.
- **REQ-SYS-2.** A project may use any system type its organization
enables, and none is required. An agent working in a project gets the
toolsets of the system types that project uses.
### Meta-harness
- **REQ-HARN-1.** The meta-harness generates each session's launch bundle
@@ -1329,9 +1407,13 @@ Added in version 1.1 (decision 03130d08).
- **REQ-ORG-2.** The data model is install, then organization, then
projects, then threads, so v1 doesn't lock in one human per
organization.
- **REQ-USER-1.** Users and role-based access control exist at install
and organization level. An agent belongs to one organization and
reaches only its allowed projects and threads.
- **REQ-USER-1.** Users and role-based access control exist at install and
organization level. An agent other than the assistant works in one
organization at a time. Its memory, files and credentials are scoped to that
organization, even when its definition comes from the user's root
configuration. It reaches only that organization's allowed projects and
threads. The assistant is the exception in REQ-ASSIST-1, and it reaches only
organizations its user belongs to.
- **REQ-USER-2.** Only the user who owns a thread reads it or writes to
it, plus that user's assistant and the chief of staff's replies on work
the thread sent. This fills in REQ-USER-1's "threads" for the chat
@@ -1462,6 +1544,15 @@ design `docs/plans/2026-10-21_t1-sessions.md`, 2c843d190 on `huey/t1-sessions-de
with `processing-halted` and changes nothing. A clear never sets or
clears the kill switch. (Design D8, test T6.)
### Accounts
Added in version 1.20.
- **REQ-ACCT-1.** A deployed system requires user sign-in (OIDC, Q203). A
user creates organizations and belongs to any number of them.
- **REQ-ACCT-2.** An organization's owner invites another user into it.
The invited user joins with the grant the owner chose (REQ-USER-3).
### Creative library
Added in version 1.12 (Q176 Case 2, Q211). Drafted from the Case 2
@@ -1788,6 +1879,17 @@ Added in version 1.11 (Q187, Q189, Q211):
monitored coverage. Install and upgrade enable no deletion policies
(REQ-DISK-1, REQ-DISK-4, REQ-DISK-10).
### Setup
Added in version 1.20.
- **REQ-SETUP-1.** Quick Setup leaves its project with its records
initialized.
- **REQ-SETUP-2.** After Quick Setup, the assistant offers an onboarding
interview. It asks for the project's outcome and limits and drafts the
north star and a first PRD. The user approves each through the existing
approval path.
## Acceptance criteria and success measures
v1 is done when Jason gives one-sentence requests to the assistant, in a
@@ -2367,6 +2469,10 @@ gate that slice.
## Risks and open questions
- The final name for the Mosaic Montage module is deferred to a later owner
decision. Mosaic Montage remains a working name; the existing four-tile
logo stays. (Version 1.20.)
- On each v1 instance, the stack runs as that instance's own OS user,
without sudo (Q181). A managed role session runs in a sandbox
(REQ-HARN-2): it writes only its workspace and run directory, sees no
+16
View File
@@ -639,3 +639,19 @@ CREATE TRIGGER request_snapshot_events_no_replace BEFORE INSERT ON request_snaps
CREATE TRIGGER request_project_events_no_update BEFORE UPDATE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
CREATE TRIGGER request_project_events_no_delete BEFORE DELETE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
CREATE TRIGGER request_project_events_no_replace BEFORE INSERT ON request_project_events WHEN EXISTS (SELECT 1 FROM request_project_events WHERE seq=NEW.seq OR (business=NEW.business AND project=NEW.project AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
-- Per-project selections are a human-authored append-only settings trail.
CREATE TABLE project_system_types (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
types TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX project_system_types_project ON project_system_types (business, project, seq);
CREATE TRIGGER project_system_types_no_update BEFORE UPDATE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_delete BEFORE DELETE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_types WHEN EXISTS (SELECT 1 FROM project_system_types WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END;
+43 -2
View File
@@ -165,7 +165,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
'thread.share', 'thread.unshare', 'thread.shares']);
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
// refuses it human-required before its own checks, and these refuse every other cap.
export const CONSOLE_VERBS = Object.freeze(['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
@@ -5560,6 +5560,28 @@ export class Broker {
return result;
}).catch(e => { throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request); });
}
if (request.verb === 'system-types.console') {
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
keys(args, ['project']);
if (!this.#organization) fail('not-configured');
const enabled = this.#organization(s.business, 'system-types.current', {}, s.human);
if (!Array.isArray(enabled)) fail('not-configured');
let project = null;
if (args.project !== undefined) {
const id = identifier(args.project);
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', id, s.business)) fail('project-not-found');
const row = this.#store.get('SELECT types FROM project_system_types WHERE business=? AND project=? ORDER BY seq DESC LIMIT 1', s.business, id);
const selected = row ? JSON.parse(row.types) : [];
project = { project: id, selected, active: selected.filter(x => enabled.includes(x)), inactive: selected.filter(x => !enabled.includes(x)) };
}
return Promise.resolve(this.#organization(s.business, 'system-types.installed', {}, s.human))
.then(installed => {
if (!Array.isArray(installed)) fail('not-configured');
const result = { installed, enabled, project };
this.#secretCheck(result);
return result;
});
}
if (request.verb === 'project.remote.get.console' || request.verb === 'project.remote.set.console') {
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
if (!this.#organization) fail('not-configured');
@@ -5666,7 +5688,7 @@ export class Broker {
if (["limits", "plugins", "instances", "routes", "organization.revision"].includes(request.verb)) keys(args, []);
else if (request.verb === "plugins.set") {
keys(args, ["revision", "plugins", "usage"], ["revision", "plugins"]);
if (s.tester && object(args.plugins) && Object.hasOwn(args.plugins, 'model-provider')) fail('human-required');
if (s.tester && object(args.plugins) && (Object.hasOwn(args.plugins, 'model-provider') || Object.hasOwn(args.plugins, 'system-type'))) fail('human-required');
}
// Row 165 (#1662): instances.set is human-only and outside ORGANIZATION_KEYS, so
// organization.set and the chief of staff's tool never reach it (sage-0918).
@@ -7566,6 +7588,25 @@ export class Broker {
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null))
.map((p) => (p.state === 'archive' ? { id: p.id, at: p.at, name: p.name, repo: p.repo, archived: true } : { id: p.id, at: p.at, name: p.name, repo: p.repo }));
}
case 'project.system-types':
case 'project.system-types.set': {
this.#human(s);
const write = verb === 'project.system-types.set';
keys(a, write ? ['project', 'systemTypes'] : ['project'], write ? ['project', 'systemTypes'] : ['project']);
const project = identifier(a.project);
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', project, s.business)) fail('project-not-found');
// When configured, read the trusted file each time; a stale bus snapshot must not reactivate a disabled type.
const enabled = this.#organization ? this.#organization(s.business, 'system-types.current', {}, s.human) : (b.plugins?.['system-type'] ?? []);
if (!Array.isArray(enabled)) fail('not-configured');
if (write) {
if (!Array.isArray(a.systemTypes) || a.systemTypes.some(x => typeof x !== 'string') || new Set(a.systemTypes).size !== a.systemTypes.length) fail('invalid-request');
if (a.systemTypes.some(x => !enabled.includes(x))) fail('system-type-not-enabled');
this.#store.run('INSERT INTO project_system_types(at,business,project,types,by) VALUES(?,?,?,?,?)', this.#now(), s.business, project, JSON.stringify(a.systemTypes), this.#owner(s));
}
const row = this.#store.get('SELECT types FROM project_system_types WHERE business=? AND project=? ORDER BY seq DESC LIMIT 1', s.business, project);
const selected = row ? JSON.parse(row.types) : [];
return { project, selected, active: selected.filter(x => enabled.includes(x)), inactive: selected.filter(x => !enabled.includes(x)) };
}
// #797 MVP3 (owner report 2026-11-06): the owner's reversible retirement of a project. The
// human gate is project.create's minus the tester carve-out: tester, user and agent sessions
// are refused here. Each verb appends one project_archives row (the audit trail) and deletes
+16
View File
@@ -0,0 +1,16 @@
-- Per-project selections are a human-authored append-only settings trail.
CREATE TABLE project_system_types (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
types TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX project_system_types_project ON project_system_types (business, project, seq);
CREATE TRIGGER project_system_types_no_update BEFORE UPDATE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_delete BEFORE DELETE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_types WHEN EXISTS (SELECT 1 FROM project_system_types WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END;
+9 -1
View File
@@ -1,7 +1,7 @@
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
import { createRequirementCatalog } from './requirement-catalog.mjs';
import { readPrdMaterial } from '../../project/src/prd.mjs';
import { lstatSync, readdirSync, readFileSync } from 'node:fs';
import { lstatSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from './store.mjs';
@@ -22,6 +22,7 @@ import { readSessions, sessionsFile, stopSession } from '../../seat/src/session.
import { compactionCount, storeDir } from '../../seat/src/compactions.mjs';
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
import { pluginSettings } from '../../organization/src/plugin-settings.mjs';
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
import { ProxyPage } from './proxy-page.mjs';
@@ -360,6 +361,11 @@ export async function startBroker({
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
// #1886 (F3 defect l): a store reference with no file yet reads as no key (design D3a), but Credentials
// refuses a missing store root, and only issue() made it. Start makes it the same way issue() does when
// any reference names the store. A root that exists with loose modes or as a symlink still refuses.
if (storeRoot && Object.values(references).some(refs => Object.values(refs ?? {}).some(ref => ref?.store === true)))
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held,
modelAgents: proxy ? Object.entries(businesses).flatMap(([id, b]) => Object.keys(b.roles).map(role => id + '/' + role)) : [] });
if (proxy) {
@@ -746,6 +752,8 @@ export async function startBroker({
known.set(id, canonicalJson(next.projects));
return getProjectRemote(writer, id, args.project);
}
if (verb === 'system-types.installed') return installedSystemTypes();
if (verb === 'system-types.current') return settings.current(id).plugins?.['system-type'] ?? [];
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='plugins')return {...plugins.read(id), available:{harness:levels.names ?? []}};
// Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
+6 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = '1cec88b0b697c648bcab18b45f86d85526aa1701a7619046367e16c38476aee8';
export const SCHEMA_VERSION = '3t';
export const SCHEMA_SHA256 = '6f6f0c0fd471b7895648a71aa9f320a769b145da792c4152ee8282006eff30c7';
export const SCHEMA_VERSION = '3u';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -112,6 +112,9 @@ const migrations = [{
// and its guards; no existing row changes and nothing is deleted.
from: '3s', to: '3t',
sql: readFileSync(new URL('./migrations/3s-3t.sql', import.meta.url), 'utf8'),
}, {
from: '3t', to: '3u',
sql: readFileSync(new URL('./migrations/3t-3u.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -138,6 +141,7 @@ const timedTables = [
'request_snapshot_events',
'request_project_events',
'project_archives',
'project_system_types',
];
const hex = (value) => createHash('sha256').update(value).digest('hex');
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
@@ -19,7 +19,7 @@ const here = (p) => new URL(p, import.meta.url);
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -94,6 +94,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -101,7 +102,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -151,7 +152,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
});
@@ -163,14 +164,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+3 -1
View File
@@ -35,7 +35,9 @@ const baselines = {
// Row 239 (#1745): outcome-unknown-revoked joins OPEN (duckworth-0165 C1), and STATUS gets
// revision-conflict-revoked 409 and outcome-unknown-revoked 503; no proof or transport change.
// #1839: artifact Console replies alone may use 2 MiB; the proof and other limits are unchanged.
"packages/webui/src/bus.mjs": "34fa64ffa17886d86243d7712fdb6f632c22b96412b642d785ddbc05fbd79692",
// #1873 U1: humanCall, busCaller, userCall and userCaller take an optional signal and absolute
// deadline; userCall shares transport(). Nonces, argv and limits are unchanged.
"packages/webui/src/bus.mjs": "3ef8064dd7911f9eee4475b52c74a1d551583a065474b1bcee968cd45f722747",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
+2 -2
View File
@@ -616,7 +616,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -640,7 +640,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3u';")
.replace('}];\nconst timedTables', `}, { from: '3t', to: '3u', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3v';")
.replace('}];\nconst timedTables', `}, { from: '3u', to: '3v', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3t ready/);
assert.match(migrated.stdout, /bus schema 3u ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+1 -1
View File
@@ -89,7 +89,7 @@ function setup(t) {
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
// no unsuffixed verb is ever a console verb.
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
+29 -3
View File
@@ -4,7 +4,7 @@
// scratch root only.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
@@ -18,7 +18,7 @@ const OTHER = 'fake_other_token_5555555555';
const DATE = '2099-01-01';
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
async function setup(t) {
async function setup(t, { storeRoot = true } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
@@ -26,7 +26,7 @@ async function setup(t) {
doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE };
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
if (storeRoot) mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
// The bus's resolved business carries the same references; a store one names its derived file.
const demo = {
@@ -161,3 +161,29 @@ test('T5: issue, rotate, revoke and delete are live, rewrite the business file a
for (const path of walk(c.root))
for (const token of [TOKEN, ROTATED, OTHER]) assert.equal(readFileSync(path).includes(token), false, path);
});
test('#1886: a store reference with no store root yet starts the bus, makes the root private and issues', async (t) => {
const c = await setup(t, { storeRoot: false });
const dir = join(c.root, 'secrets', 'agents');
assert.equal(statSync(dir).mode & 0o777, 0o700);
assert.equal(statSync(join(c.root, 'secrets')).mode & 0o077, 0);
assert.deepEqual(c.status('coder'), { gitea: 'not-issued', vikunja: 'valid' });
const issued = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN + '\n', date: DATE });
assert.deepEqual(issued, { agent: 'coder', verb: 'issue', service: 'gitea', date: DATE, state: 'valid' });
assert.equal(statSync(c.store('coder', 'gitea')).mode & 0o777, 0o600);
});
test('#1886: a store root that exists with loose modes still refuses start', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-loose-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o750 });
chmodSync(join(root, 'secrets', 'agents'), 0o750);
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: {},
roles: { coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
};
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/);
});
+7 -6
View File
@@ -36,11 +36,11 @@ function source3j(t) {
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -84,6 +84,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -105,7 +106,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -131,7 +132,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
});
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -142,14 +143,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3j,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3j';")
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
assert.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+7 -6
View File
@@ -22,7 +22,7 @@ const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1',
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -42,7 +42,7 @@ function source3i(t) {
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -87,6 +87,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -110,7 +111,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path).filter(r => !LATER.includes(r.name));
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -135,7 +136,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
});
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -146,14 +147,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3i,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3i';")
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
assert.throws(() => new Store3i(root), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+1 -1
View File
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
+2 -1
View File
@@ -198,6 +198,7 @@ test('3p through 3s migration preserves existing request rows and the append-onl
const path = join(root, 'bus/bus.sqlite'), ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
// 3s-3t (#797) adds project_archives; the 3p fixture predates it and the replayed chain recreates it.
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
.replace(",'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced'", '');
const db = new DatabaseSync(path); db.exec(ddl);
const at = '2026-11-03T00:00:00.000Z';
@@ -213,7 +214,7 @@ test('3p through 3s migration preserves existing request rows and the append-onl
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); t.after(() => store.close());
assert.deepEqual(store.all('SELECT * FROM request_events'), before);
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')", at);
assert.throws(() => store.run('DELETE FROM request_events'), /append-only/);
assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/);
+4 -4
View File
@@ -143,7 +143,7 @@ const here = (p) => new URL(p, import.meta.url);
const migrationSQL = readFileSync(here('../src/migrations/3s-3t.sql'), 'utf8');
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
assert.equal(currentDDL.split(migrationSQL).length, 2, 'schema.sql holds the 3s→3t step exactly once');
const ddl3s = currentDDL.replace(migrationSQL, '');
const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), '');
function source3s(t) {
const root = mkdtempSync(join(tmpdir(), 'project-archive-3s-'));
@@ -178,7 +178,7 @@ test('a 3s store migrates to 3t: project_archives is new and empty, every row st
assert.deepEqual(master(path), was, 'the refused open changed nothing');
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
assert.equal(store.get("SELECT id FROM events").id, 'preserved');
assert.equal(store.get("SELECT name FROM projects").name, 'kept');
assert.deepEqual(store.all('SELECT seq,at,business,project,op,by FROM project_archives'), []);
@@ -211,7 +211,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3s).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3s';")
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3s';")
.replace(/\}, \{\n {2}\/\/ #797 MVP3[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3s';") && edited.includes('3r-3s') && !edited.includes('3s-3t'), 'the 3s engine has no 3s→3t step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -221,7 +221,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
const Store3s = (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
assert.throws(() => new Store3s(root), /schema-mismatch/);
assert.throws(() => new Store3s(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
assert.deepEqual(master(path), was);
// The same engine opens a fresh 3s store, so the refusal is the version's and not a broken copy.
const { root: old } = source3s(t);
+4 -4
View File
@@ -90,7 +90,7 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
test('the console verb list includes the artifact read and existing owner controls', () => {
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
});
@@ -302,7 +302,7 @@ test('a cancel is refused when the owner-to-PM edge closed, with no withdrawn de
});
test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => {
assert.equal(SCHEMA_VERSION, '3t');
assert.equal(SCHEMA_VERSION, '3u');
const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-'));
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), AT = '2026-11-03T00:00:00.000Z';
@@ -311,7 +311,7 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
// the replayed chain recreates it, so the step's text is removed here like the kinds are.
const step3t = readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8');
// 3p-3q shipped the PRD kinds and mode.changed; 3q-3r adds the three Task 3 kinds after them.
const ddl3q = current.replace(step3t, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
const ddl3q = current.replace(step3t, '').replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
assert.notEqual(ddl3q, current, 'the fixture is the narrower schema');
const migration = readFileSync(new URL('../src/migrations/3q-3r.sql', import.meta.url), 'utf8');
assert.ok(migration.includes("'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'"), 'the step carries every 3q kind plus its own');
@@ -328,7 +328,7 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n');
const store = new Store(root);
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes, including the 3q kinds');
const fresh = new DatabaseSync(':memory:'); fresh.exec(current);
@@ -230,6 +230,7 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
// 3s-3t (#797) adds project_archives; the 3r fixture predates it and the replayed chain recreates it.
db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
.replace(",'advanced'", ''));
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3r');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
@@ -250,7 +251,7 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before);
assert.deepEqual(store.all("SELECT name,sql FROM sqlite_master WHERE type='trigger' AND tbl_name IN ('request_events','request_effects') ORDER BY name"), guards);
assert.equal(store.get("SELECT name FROM sqlite_master WHERE type='index' AND name='request_events_request'").name, 'request_events_request');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',16,'advanced',?,'{}')", at);
assert.equal(store.get("SELECT max(seq) n FROM request_events").n, 42);
assert.throws(() => store.run("UPDATE request_events SET kind='opened'"), /append-only/);
+4 -2
View File
@@ -278,6 +278,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
// 3s-3t (#797) adds project_archives after the request tables; the pre-3t fixtures below
// predate it and the replayed chain recreates it, so the step's text comes out here too.
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8');
assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length);
@@ -290,7 +291,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
db.close(); chmodSync(path, 0o600); const before = readFileSync(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n'); assert.equal(SCHEMA_VERSION, '3t');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n'); assert.equal(SCHEMA_VERSION, '3u');
const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved');
for (const table of ['request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0);
store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0);
@@ -313,6 +314,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
// The 3o schema is the current one with the narrower kind list.
const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z';
const db = new DatabaseSync(path); db.exec(ddl3o);
@@ -329,7 +331,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n');
const store = new Store(root);
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes');
assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects);
+1 -1
View File
@@ -45,7 +45,7 @@ test('requirements returns the committed list to a holding role session, and no
const pm = agent('pm');
const list = call(b, pm, 'requirements');
assert.deepEqual(list, loadRequirements());
assert.equal(list.requirements.length, 101);
assert.equal(list.requirements.length, 118);
assert.deepEqual(call(b, agent('coder'), 'requirements'), list);
assert.throws(() => call(b, human, 'requirements'), /agent-required/);
assert.throws(() => call(b, reader, 'requirements'), /read-only/);
+3 -2
View File
@@ -527,7 +527,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards.
// Task 4 (3p to 3q), Task 3 (3q to 3r) and Task 6b (3r to 3s) widen request_events.kind in place.
// #797 MVP3 (3s to 3t) adds project_archives and its five guards; project-archive.test.mjs tests them.
assert.equal(count('table') - 1, 31);
assert.equal(count('trigger'), 119);
// 3t to 3u adds project_system_types and four guards.
assert.equal(count('table') - 1, 32);
assert.equal(count('trigger'), 123);
assert.equal(count('view'), 5);
});
+4 -4
View File
@@ -530,9 +530,9 @@ test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
const after = tables(path);
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives']) {
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types']) {
assert.deepEqual(after[name], [], name);
delete after[name];
}
@@ -571,7 +571,7 @@ async function engine3m(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3m';")
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3m';")
.replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -588,7 +588,7 @@ test('T4 schema: the 3m engine refuses a 3n store, migrating or not, and leaves
const Store3m = await engine3m(root);
assert.throws(() => new Store3m(root), /schema-mismatch/);
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
+3 -3
View File
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
@@ -430,7 +430,7 @@ async function engine3k(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3k';")
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3k';")
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
+107
View File
@@ -0,0 +1,107 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
test('project selections require enabled types, retain inactive selections, and only the owner writes', t => {
const root = mkdtempSync(join(tmpdir(), 'sys-'));
const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: [], crossRole: [] } } }, projects: {}, plugins: { 'system-type': ['coding'] }, vars: { 'bus.tester': true } };
const broker = new Broker({ store, businesses: { demo: business } });
const owner = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = broker.bindTester({ business: 'demo' });
const agent = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
const call = (verb, args = {}, cap = owner) => broker.request(cap, { verb, args });
const project = call('project.create', { name: 'example' }).id;
assert.deepEqual(call('project.system-types', { project }), { project, selected: [], active: [], inactive: [] });
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['montage'] }), /system-type-not-enabled/);
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['coding'] }, tester), /tester-refused/);
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['coding'] }, agent), /human-required/);
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['coding', 'coding'] }), /invalid-request/);
call('project.system-types.set', { project, systemTypes: ['coding'] });
assert.deepEqual(call('project.system-types', { project }).active, ['coding']);
broker.reloadBusiness('demo', { ...business, plugins: { 'system-type': [] } });
assert.deepEqual(call('project.system-types', { project }), { project, selected: ['coding'], active: [], inactive: ['coding'] });
assert.deepEqual(store.all('SELECT by FROM project_system_types').map(r => r.by), ['jason']);
assert.throws(() => store.run("UPDATE project_system_types SET types='[]'"), /append-only/);
assert.throws(() => store.run('DELETE FROM project_system_types'), /append-only/);
assert.throws(() => store.run("INSERT INTO project_system_types(at,business,project,types,by) VALUES(?,?,?,?,?)", new Date().toISOString(), 'other', project, '[]', 'jason'), /belongs to its project/);
});
test('console owner can read system types but cannot write project selections', async t => {
const root = mkdtempSync(join(tmpdir(), 'sys-'));
const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: [], crossRole: [] } } }, projects: {}, plugins: { 'system-type': ['coding'] }, vars: { 'bus.tester': true } };
let discoveries = 0, enabled = ['coding'];
const broker = new Broker({ store, businesses: { demo: business }, organization: (_id, verb) => { if (verb === 'system-types.current') return enabled; if (verb === 'system-types.installed') { discoveries++; return installedSystemTypes(); } } });
const owner = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const consoleOwner = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
const tester = broker.bindTester({ business: 'demo' });
assert.throws(() => broker.request(tester, { verb: 'plugins.set', args: { revision: 'a'.repeat(64), plugins: { 'system-type': ['coding'] } } }), /human-required/);
const project = broker.request(owner, { verb: 'project.create', args: { name: 'example' } }).id;
const result = await broker.request(consoleOwner, { verb: 'system-types.console', args: { project } });
assert.deepEqual(result.enabled, ['coding']);
assert.equal(result.installed.length, 5);
assert.equal(discoveries, 1);
broker.request(owner, { verb: 'project.system-types.set', args: { project, systemTypes: ['coding'] } });
enabled = [];
assert.deepEqual(broker.request(owner, { verb: 'project.system-types', args: { project } }).inactive, ['coding']);
assert.throws(() => broker.request(owner, { verb: 'project.system-types.set', args: { project, systemTypes: ['coding'] } }), /system-type-not-enabled/);
assert.deepEqual(result.project, { project, selected: [], active: [], inactive: [] });
assert.throws(() => broker.request(consoleOwner, { verb: 'project.system-types.set', args: { project, systemTypes: ['coding'] } }), /human-required/);
});
test('3t store migrates offline without altering existing project rows', async t => {
const { DatabaseSync } = await import('node:sqlite');
const { readFileSync, mkdirSync, chmodSync } = await import('node:fs');
const { schemaDigest } = await import('../src/store.mjs');
const root = mkdtempSync(join(tmpdir(), 'sys-migration-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const dbpath = join(root, 'bus/bus.sqlite');
const db = new DatabaseSync(dbpath);
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
const step = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
assert.equal(ddl.split(step).length, 2);
db.exec(ddl.replace(step, ''));
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3t');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', '2026-01-01T00:00:00.000Z');
db.prepare('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)').run('old', '2026-01-01T00:00:00.000Z', 'demo', 'old', null, 'jason');
db.close(); chmodSync(dbpath, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const migrated = new Store(root, { migrateSchema: true }); t.after(() => migrated.close());
assert.equal(migrated.get('SELECT name FROM projects WHERE id=?', 'old').name, 'old');
assert.equal(migrated.get('SELECT count(*) AS n FROM project_system_types').n, 0);
assert.equal(migrated.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
});
test('runtime reads the current organization file for project selections and console discovery', async t => {
const { mkdirSync } = await import('node:fs');
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'sys-runtime-'));
const rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
doc.plugins = { 'system-type': ['coding'] };
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: business }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const consoleOwner = bus.broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
const call = (verb, args = {}, cap = human) => bus.broker.request(cap, { verb, args });
const project = call('project.create', { name: 'example' }).id;
call('project.system-types.set', { project, systemTypes: ['coding'] });
const view = await call('system-types.console', { project }, consoleOwner);
assert.deepEqual(view.enabled, ['coding']);
assert.deepEqual(view.project.active, ['coding']);
assert.equal(view.installed.length, 5);
const plugins = call('plugins');
call('plugins.set', { revision: plugins.revision, plugins: { 'system-type': [] } });
assert.deepEqual(call('project.system-types', { project }).inactive, ['coding']);
});
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3t ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3u ready', ''].join('\n'));
});
+4 -4
View File
@@ -403,12 +403,12 @@ test('a 3l store migrates to 3m: thread_shares is new and empty, and every other
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
const after = tables(path);
assert.deepEqual(after.thread_shares, []);
delete after.thread_shares;
// Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too.
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives']) {
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types']) {
assert.deepEqual(after[name], [], name);
delete after[name];
}
@@ -439,7 +439,7 @@ async function engine3l(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3l';")
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3l';")
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -456,7 +456,7 @@ test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it wa
const Store3l = await engine3l(root);
assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3u');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
+9
View File
@@ -1289,3 +1289,12 @@ first attempt. Completed moves are not rolled back. A held lifecycle lock can
mean a bus start is still in progress; if a process crashed and left its lock,
inspect it before removing it and resuming. Moves use rename and therefore
require the source and deleted folder to be on the same filesystem.
System types are installed plug-ins. `mosaic org system-types installed` prints
module metadata; `mosaic org system-types enabled ID` prints an organization's enabled
ids. The owner changes the enabled list through the existing organization file
update or `mosaic plugins set`. `mosaic project system-types PROJECT --org ID`
shows selected, active and inactive ids. The owner replaces the selection with
`mosaic project system-types-set PROJECT --types coding,montage --org ID`; an
empty `--types ''` clears it. Disabled selections stay recorded but become
inactive. Project settings changes are append-only and require human proof.
+1 -1
View File
@@ -74,7 +74,7 @@ export function readAccounting({ dataRoot,business,start,end }) {
const db = new DatabaseSync(join(dataRoot,'bus/bus.sqlite'),{readOnly:true});
try {
db.exec('BEGIN');
if (!['3s','3t'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
if (!['3s','3t','3u'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
const store = { all:(sql,...args) => db.prepare(sql).all(...args) };
const records = turnRecords(store,business);
const raw = store.all("SELECT * FROM usage_records WHERE business=? AND source<>'@proxy/cliproxy-queue' ORDER BY seq",business);
+1 -1
View File
@@ -79,7 +79,7 @@ test('accounting reads a real store at the current bus schema version',t=>{
const records=readAccounting({dataRoot:f.dataRoot,business:f.c.business,start:f.c.start,end:f.c.end});
assert.equal(records.length,1);assert.equal(records[0].input,1);
});
for(const version of ['3s','3r','3u',null]) test(`accounting ${version==='3s'?'accepts':'refuses'} schema ${version??'missing'}`,t=>{
for(const version of ['3s','3r','3v',null]) test(`accounting ${version==='3s'?'accepts':'refuses'} schema ${version??'missing'}`,t=>{
const f=setup(t,100);
// Only this disposable fixture is altered; the production meta table is append-only.
f.store.run('DROP TRIGGER meta_no_update');f.store.run('DROP TRIGGER meta_no_delete');
+18 -3
View File
@@ -43,7 +43,7 @@ import { SeatError } from "../../seat/src/seat.mjs";
import { OrgError, resolveOrg, withOrgLifecycle } from "../../organization/src/org-registry.mjs";
import { orgSelector } from "./org-selector.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 { orgCommand, ORG_USAGE } from "./org.mjs";
@@ -91,6 +91,7 @@ export const USAGE = `usage:
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 stop|status
mosaic console restart
${DEV_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`);
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();
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");
@@ -583,7 +592,13 @@ async function consoleCommand(rest, io, deps) {
const receipt = await consoleStart(organizationContext(io, deps), { devLocalOwner: devOwner, detach: parsed.flags.has("--detach"), port: parsed.values["--port"] ?? "auto", ...(lan ? { bind: lan.bind, allowHosts: lan.allowHosts } : {}), warn: s => io.stderr.write(s) });
io.stdout.write(`${JSON.stringify(receipt)}\n`);
if (!parsed.flags.has("--detach")) {
const code = await receipt.done;
// F3: this process supervises the Console. A stop signal (the unit's SIGTERM, Ctrl-C) goes to
// the Console and ends supervision cleanly; a restart the Console asks for is relaunched here.
const stop = () => receipt.stop();
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, stop);
let code;
try { code = await receipt.done; }
finally { for (const signal of ["SIGINT", "SIGTERM"]) process.off(signal, stop); }
if (code !== 0) throw new CliError("console stopped unexpectedly", code);
}
}
@@ -712,7 +727,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
if (verb === "project") return ["create", "list", "archive", "unarchive"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
if (verb === "project") return ["create", "list", "archive", "unarchive", "system-types", "system-types-set"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
if (verb === "session") return sessionCommand(rest, io, (p) => channelCallFor(p, io, deps));
if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
+11 -6
View File
@@ -43,12 +43,17 @@ function checkedState(org) {
if (s.host && !lock) fail('bus host lock is missing');
return s;
}
function answer(path) {
export function probeBusSocket(path, { signal, deadline } = {}) {
return new Promise(resolve => {
const remaining = deadline === undefined ? 1000 : Math.min(1000, deadline - Date.now());
if (signal?.aborted || remaining <= 0) return resolve(false);
let done = false, data = '';
const socket = connect(path);
const finish = value => { if (done) return; done = true; socket.destroy(); resolve(value); };
socket.setTimeout(1000, () => finish(false));
const abort = () => finish(false);
const finish = value => { if (done) return; done = true; signal?.removeEventListener('abort', abort); socket.destroy(); resolve(value); };
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) return abort();
socket.setTimeout(remaining, () => finish(false));
socket.on('error', () => finish(false));
socket.on('connect', () => socket.write('{}\n'));
socket.on('data', chunk => {
@@ -62,9 +67,9 @@ function answer(path) {
socket.on('end', () => finish(false));
});
}
export async function busStatus(ctx, id) {
export async function busStatus(ctx, id, bound) {
const org = await target(ctx, id), state = checkedState(org);
const healthy = Boolean(state.host?.live && state.socket && await answer(org.paths.socket));
const healthy = Boolean(state.host?.live && state.socket && await probeBusSocket(org.paths.socket, bound));
return { org: id, instance: ctx.instance, root: org.root, socket: org.paths.socket, healthy, pid: state.host?.pid ?? null, boot: readBootStatus(org.root) };
}
export async function busStop(ctx, id) {
@@ -123,7 +128,7 @@ export async function busStart(ctx, id, { detach = false, devLocalOwner = false,
while (Date.now() < deadline && !exited) {
// During boot the host holds its lock before its state is published.
const state = readHostState(org.root);
if (ready && state?.live && state.pid === child.pid && state.business === id && await answer(org.paths.socket)) {
if (ready && state?.live && state.pid === child.pid && state.business === id && await probeBusSocket(org.paths.socket)) {
child.disconnect();
if (detach) child.unref();
const receipt = { org: id, pid: child.pid, socket: org.paths.socket, root: org.root };
+178 -31
View File
@@ -2,6 +2,7 @@
import { randomBytes } from 'node:crypto';
import { processState } from '../../seat/src/process-state.mjs';
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 { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -64,29 +65,22 @@ function takeLock(ctx) {
let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); }
return () => { closeSync(fd); rmSync(path, { force: true }); };
}
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
const value = port === 'auto' ? 0 : Number(port);
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
let lan;
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
const release = takeLock(ctx);
let child = null, done = null;
// The exit code a supervised Console uses to ask for a relaunch (EX_TEMPFAIL). Only the Console's
// own restart route (POST /api/console/restart, a human action) exits with it.
export const RESTART_EXIT = 75;
const sleep = ms => new Promise(r => setTimeout(r, ms));
let lastStamp = 0;
// One Console process: spawn it, wait for the URL it prints, check the address and port.
async function launch(ctx, args, lan, detach) {
lastStamp = Math.max(Date.now(), lastStamp + 1);
const log = join(directory(ctx), `console-${process.pid}-${lastStamp}.log`);
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
let child;
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
finally { closeSync(fd); }
const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
let exited = false; done.then(() => { exited = true; });
try {
const prior = state(ctx);
if (prior && !removeStale(ctx, prior, warn)) fail('console state exists; stop or inspect the console first');
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
if (org) await resolveOrg(ctx, org.id);
// The pre-S5 Console still accepts a business preselection.
const args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []),
...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : [])];
const log = join(directory(ctx), `console-${process.pid}-${Date.now()}.log`);
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
finally { closeSync(fd); }
done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
let exited = false; done.then(() => { exited = true; });
const deadline = Date.now() + 30000;
while (Date.now() < deadline && !exited) {
const text = readFileSync(log, 'utf8');
@@ -97,22 +91,99 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false,
if (forbidden.has(Number(match[3]))) fail('console selected a forbidden port');
const startTime = startTimeOf(child.pid);
if (!startTime) fail('console exited during startup');
writeFileSync(stateFile(ctx), JSON.stringify({ pid: child.pid, startTime, url: match[1], ...(lan ?? {}) }) + '\n', { mode: 0o600, flag: 'wx' });
if (detach) child.unref();
// The child's stderr goes to the log; the person who started it sees the warning here.
if (lan) warn(lanWarning(lan.bind, match[3]));
const result = { url: match[1], pid: child.pid, ...(lan ?? {}) };
Object.defineProperty(result, 'done', { value: done });
return result;
return { child, done, url: match[1], port: match[3], startTime };
}
await new Promise(r => setTimeout(r, 50));
await sleep(50);
}
fail(`console failed to start; see ${log}`);
} catch (e) {
if (child && child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
throw e;
}
}
const record = (run, lan) => JSON.stringify({ pid: run.child.pid, startTime: run.startTime, url: run.url, ...(lan ?? {}) }) + '\n';
// F3: a relaunch keeps the arguments and the address. The state file must still name the process
// that asked for the restart; a stop that ran in between wins, and nothing is relaunched.
async function relaunch(ctx, old, args, lan) {
const next = args.map((a, i) => (args[i - 1] === '--port' ? old.port : a));
let release;
for (let n = 0; !release; n++) {
try { release = takeLock(ctx); } catch (e) { if (n >= 50) throw e; await sleep(100); }
}
try {
const s = state(ctx);
if (!s || s.pid !== old.child.pid || s.startTime !== old.startTime || s.url !== old.url) fail('console state changed during restart');
let fresh;
// The old listener has closed; a refused bind is retried briefly.
for (let n = 0; !fresh; n++) {
try { fresh = await launch(ctx, next, lan, false); } catch (e) { if (n >= 4) throw e; await sleep(500); }
}
try {
if (fresh.url !== old.url) fail('console restarted on another address');
const tmp = `${stateFile(ctx)}.tmp-${randomBytes(4).toString('hex')}`;
writeFileSync(tmp, record(fresh, lan), { mode: 0o600, flag: 'wx' });
renameSync(tmp, stateFile(ctx));
} catch (e) { fresh.child.kill('SIGTERM'); await fresh.done; throw e; }
return fresh;
} finally { release(); }
}
// Foreground supervision: relaunch on RESTART_EXIT, end on any other exit. `stop` forwards a
// stop signal (the unit's SIGTERM, a terminal's Ctrl-C) to the Console; a stop is a clean end.
function supervise(ctx, first, args, lan, warn) {
let current = first, stopping = false;
const stop = () => {
if (stopping) return;
stopping = true;
const child = current.child;
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill('SIGTERM');
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }, 15000).unref();
};
const done = (async () => {
for (;;) {
const code = await current.done;
if (stopping) return 0;
if (code !== RESTART_EXIT) return code;
warn('mosaic: console asked for a restart; relaunching on the same arguments\n');
try { current = await relaunch(ctx, current, args, lan); }
catch (e) { warn(`mosaic: console restart failed: ${e.message}\n`); return 1; }
if (stopping) { stopping = false; stop(); await current.done; return 0; }
}
})();
return { done, stop };
}
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
// In the foreground the Console runs with --supervised: its restart route exits RESTART_EXIT and
// this process relaunches it in place (F3). A detached Console has no supervisor.
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
const value = port === 'auto' ? 0 : Number(port);
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
let lan;
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
const release = takeLock(ctx);
let run, args;
try {
const prior = state(ctx);
if (prior && !removeStale(ctx, prior, warn)) fail('console state exists; stop or inspect the console first');
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
if (org) await resolveOrg(ctx, org.id);
// The pre-S5 Console still accepts a business preselection.
args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []),
...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : []), ...(detach ? [] : ['--supervised'])];
run = await launch(ctx, args, lan, detach);
try { writeFileSync(stateFile(ctx), record(run, lan), { mode: 0o600, flag: 'wx' }); }
catch (e) { run.child.kill('SIGTERM'); await run.done; throw e; }
} finally { release(); }
if (detach) run.child.unref();
// The child's stderr goes to the log; the person who started it sees the warning here.
if (lan) warn(lanWarning(lan.bind, run.port));
const result = { url: run.url, pid: run.child.pid, ...(lan ?? {}) };
const supervision = detach ? { done: run.done, stop: () => run.child.kill('SIGTERM') } : supervise(ctx, run, args, lan, warn);
Object.defineProperty(result, 'done', { value: supervision.done });
Object.defineProperty(result, 'stop', { value: supervision.stop });
return result;
}
export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } = {}) {
const release = takeLock(ctx);
try {
@@ -149,3 +220,79 @@ export function consoleStatus(ctx, { warn = s => process.stderr.write(s) } = {})
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 } : {}) };
}
// 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;
}
+12 -2
View File
@@ -2,11 +2,14 @@ import { readFile, writeFile } from 'node:fs/promises';
import { configDir } from '../../organization/src/organization-config.mjs';
import { listOrgs, orgLock } from '../../organization/src/org-registry.mjs';
import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs';
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
import { loadSystem } from './config.mjs';
import { CliError } from './errors.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 system-types installed | system-types enabled ID | 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 }; }
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 = {}) {
@@ -16,12 +19,19 @@ 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]; };
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; }
// 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);
if (words[0] === 'system-types' && words[1] === 'installed' && words.length === 2) { allowed(['--json'], []); io.stdout.write(JSON.stringify(await installedSystemTypes(), null, 2) + '\n'); return; }
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] === 'system-types' && words[1] === 'enabled' && words.length === 3) { allowed(['--json'], []); const org = await showOrg(ctx, words[2]); result = { organization: words[2], enabled: org.doc.plugins?.['system-type'] ?? [] }; }
else 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] === '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 (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] === '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)) {
+15 -2
View File
@@ -14,6 +14,8 @@ export const PROJECT_USAGE = 'mosaic project init <business> <project> [--tester
// and check above work on the repository on disk and never contact the bus, except that
// init --tester (row 175, #1673) binds the tester once before it writes.
export const PROJECT_BUS_USAGE = [
'mosaic project system-types <name-or-id> [--business <id>] [--json]',
'mosaic project system-types-set <name-or-id> --types <comma-separated-ids> [--business <id>]',
'mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]',
'mosaic project list [--archived] [--business <id>] [--json] [--tester]',
'mosaic project archive <name-or-id> [--business <id>]',
@@ -28,8 +30,8 @@ export async function projectBusCommand(args, io, callFor) {
? { flags: ['--tester'], values: ['--repo', '--business'] }
: sub === 'list'
? { flags: ['--json', '--tester', '--archived'], values: ['--business'] }
: { flags: [], values: ['--business'] });
const one = sub === 'create' || sub === 'archive' || sub === 'unarchive';
: { flags: sub === 'system-types' ? ['--json'] : [], values: sub === 'system-types-set' ? ['--business', '--types'] : ['--business'] });
const one = ['create', 'archive', 'unarchive', 'system-types', 'system-types-set'].includes(sub);
if (parsed.positional.length !== (one ? 1 : 0))
throw new CliError(`project ${sub} takes ${one ? (sub === 'create' ? 'one name' : 'one name or id') : 'no arguments'}\n${PROJECT_BUS_USAGE}`, 4);
const call = callFor(parsed);
@@ -45,6 +47,17 @@ export async function projectBusCommand(args, io, callFor) {
}
return io.stdout.write(`created project ${name} id ${made.id}\n`);
}
if (sub === 'system-types' || sub === 'system-types-set') {
const projects = await call('project.list', { includeArchived: true });
const ref = parsed.positional[0];
const hit = projects.find(p => p.id === ref) ?? projects.find(p => p.name === ref);
if (!hit) throw new CliError(`no project is named or numbered ${JSON.stringify(ref)}`, 2);
const write = sub === 'system-types-set';
if (write && parsed.values['--types'] === undefined) throw new CliError('--types is required (an empty string clears the list)', 4);
const systemTypes = parsed.values['--types'] ? parsed.values['--types'].split(',') : [];
const result = await call(write ? 'project.system-types.set' : 'project.system-types', { project: hit.id, ...(write ? { systemTypes } : {}) });
return io.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
// #797 MVP3: the owner's reversible archive. Resolution reads the full list, archived included,
// so an archived project can be named for unarchive.
if (sub === 'archive' || sub === 'unarchive') {
+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);
});
+78 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync, statSync } from 'node:fs';
import { get, request } from 'node:http';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
@@ -133,3 +133,80 @@ test('mosaic console start refuses the LAN flags without the dev owner and allow
assert.deepEqual(JSON.parse(none.stdout), { running: false });
assert.equal(mosaic(['console', 'status', '--json']).status, 4);
});
// F3: the owner restarts the Console from Settings. A foreground `mosaic console start` runs the
// Console with --supervised and relaunches it on the same arguments and address when it exits 75.
const WEBUI = join(REPO, 'packages/webui/src/cli.mjs');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const argvOf = pid => readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').slice(0, -1);
function call(port, path, { method = 'GET', body = null, headers = {} } = {}) {
const h = { host: `127.0.0.1:${port}`, ...(method === 'GET' ? {} : { origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }), ...headers };
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
return new Promise((resolve, reject) => {
const r = request({ host: '127.0.0.1', port, path, method, headers: h }, res => {
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch {} resolve({ status: res.statusCode, body: json }); });
});
r.on('error', reject); r.end(body ?? undefined);
});
}
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
test('a foreground console relaunches on the same address when the owner restarts it, and a stop ends it', { timeout: 90000 }, async t => {
const { ctx } = 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(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(port));
assert.deepEqual(argvOf(result.pid), [process.execPath, WEBUI, 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner', '--supervised']);
const before = (await call(port, '/api/config')).body;
assert.equal(before.consoleRestart, true); assert.equal(typeof before.boot, 'string');
const stateFile = join(ctx.dataRoot, 'console/console.json');
const trailFile = join(ctx.dataRoot, 'console/actions.jsonl');
// Another origin or site restarts nothing.
for (const headers of [{ origin: 'http://evil.example' }, { origin: null }, { 'sec-fetch-site': 'cross-site' }, { host: `evil.example:${port}` }]) {
assert.equal((await call(port, '/api/console/restart', { method: 'POST', body: '{}', headers })).status, 403, JSON.stringify(headers));
}
assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).pid, result.pid);
assert.equal(existsSync(trailFile), false);
const answer = await call(port, '/api/console/restart', { method: 'POST', body: '{}' });
assert.equal(answer.status, 202);
assert.deepEqual(answer.body, { outcome: 'restarting', boot: before.boot });
let saved;
for (const deadline = Date.now() + 30000; ; await sleep(100)) {
saved = JSON.parse(readFileSync(stateFile, 'utf8'));
if (saved.pid !== result.pid) break;
if (Date.now() > deadline) assert.fail('the Console was not relaunched');
}
// Same address, same arguments; the auto port is now the port it had.
assert.equal(saved.url, result.url);
assert.deepEqual(argvOf(saved.pid), [process.execPath, WEBUI, 'serve', '--port', String(port), '--business', 'alpha', '--dev-local-owner', '--supervised']);
assert.equal(statSync(stateFile).mode & 0o777, 0o600);
const after = (await call(port, '/api/config')).body;
assert.notEqual(after.boot, before.boot);
assert.equal(after.consoleRestart, true);
const [line, ...more] = readFileSync(trailFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
assert.deepEqual(more, []);
assert.ok(Number.isFinite(Date.parse(line.at)));
assert.deepEqual({ ...line, at: undefined }, { at: undefined, 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']);
assert.deepEqual(consoleStatus(ctx), { running: true, pid: saved.pid, url: result.url, bind: '127.0.0.1' });
result.stop();
assert.equal(await result.done, 0);
assert.equal(consoleStatus(ctx).running, false);
});
test('a detached console has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => {
const { ctx } = 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(!argvOf(result.pid).includes('--supervised'));
assert.equal((await call(port, '/api/config')).body.consoleRestart, false);
const r = await call(port, '/api/console/restart', { method: 'POST', body: '{}' });
assert.equal(r.status, 409); assert.equal(r.body.error, 'not-supervised');
assert.equal((await call(port, '/api/config')).status, 200);
assert.equal(existsSync(join(ctx.dataRoot, 'console/actions.jsonl')), false);
assert.equal((await consoleStop(ctx)).stopped, true);
});
+1
View File
@@ -12,6 +12,7 @@ test('organization CLI renders, validates, creates, updates and initializes git'
const file = join(home, 'alpha.json'); const invoke = (...args) => { const r = run(env, ...args); assert.equal(r.status, 0, r.stderr); return r.stdout; };
invoke('org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file);
invoke('org', 'validate', file); invoke('org', 'create', 'alpha', '--from', file);
assert.deepEqual(JSON.parse(invoke('org', 'system-types', 'enabled', 'alpha')), { organization: 'alpha', enabled: [] });
const bootCheck = spawnSync(process.execPath, ['--input-type=module', '-e', `import {loadSystem, bootConfig} from './packages/cli/src/config.mjs'; const system=loadSystem({env:process.env, instance:'orgtest'}); bootConfig({system,businessId:'alpha',env:process.env});`], { cwd: repo, env, encoding: 'utf8' }); assert.equal(bootCheck.status, 0, bootCheck.stderr);
let info = JSON.parse(invoke('org', 'show', 'alpha', '--json')); assert.equal(info.id, 'alpha'); assert.equal(JSON.parse(invoke('org', 'list', '--json')).length, 1);
invoke('org', 'update', 'alpha', '--from', file, '--if-revision', String(info.revision));
+5 -5
View File
@@ -306,7 +306,7 @@ test('#811 migration readers follow the engine order and refuse malformed pairs'
assert.notEqual(engineSource,source);
const engine=await import(`data:text/javascript;base64,${Buffer.from(engineSource).toString('base64')}`);
const pairs=engine.migrations.map(({from,to})=>[from,to]);
assert.equal(pairs.length,18);assert.deepEqual(pairs.at(-1),['3s','3t']);
assert.equal(pairs.length,19);assert.deepEqual(pairs.at(-1),['3t','3u']);
const release=readFileSync(join(ROOT,'scripts/stack-release.sh'),'utf8');
const fn=(name)=>{const m=release.match(new RegExp(`^${name}\\(\\) \{[\\s\\S]*?^\}`, 'm'));assert.ok(m,name);return m[0]};
const functions=['copy_schema','target_schema','plan_steps'].map(fn).join('\n');
@@ -885,7 +885,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' ');
const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1];
// sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
// well as T46.
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','project_archives','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events'];
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','project_archives','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_system_types'];
const LATE='2999-01-01T00:00:00.000Z';
const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()};
@@ -903,7 +903,7 @@ test('T45 row 181 items 2 and 3: the restore guard counts events and every COUNT
both(store('3f.sqlite',['events'],['events','instance_names']),'1\t1 event(s)\n');
both(store('none.sqlite',[],['instance_names']),'0\t0 event(s)\n');
});
for(const x of ['instance_names','usage_records'])await t.test(`one ${x} row and no event refuses before any stop; --events 1 restores`,async(t)=>{
for(const x of ['instance_names','usage_records','project_system_types'])await t.test(`one ${x} row and no event refuses before any stop; --events 1 restores`,async(t)=>{
const e=await mk(t);await seed(e);
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
addRow(e.store,x);
@@ -925,10 +925,10 @@ test('T45 row 181 items 2 and 3: the restore guard counts events and every COUNT
assert.equal(r.code,3,r.out+r.err);assert.ok(r.err.endsWith(`stack-release: refusing restore-mismatch: cannot count the rows after ${stamp}\n`),r.err);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).at(-1).code,'restore-mismatch');
});
await t.test('the in-run recount counts the listed tables too',async(t)=>{
for(const x of ['thread_opens','project_system_types'])await t.test(`the in-run recount counts the listed tables too (${x})`,async(t)=>{
const e=await mk(t);await seed(e);
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
fault(e,'stop-writes-row','thread_opens');
fault(e,'stop-writes-row',x);
const r=await run(e,'rollback','acme','--restore',act);assert.equal(r.code,1,r.out+r.err);
assert.equal(r.err,rolledBack('post-backup-events',C));
assert.ok(r.out.includes('; 0 event(s) were written after the backup\n'),r.out);
+53
View File
@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { orgCommand } from '../src/org.mjs';
import { projectBusCommand } from '../src/project.mjs';
test('org installed system types and project selection reads print JSON', async () => {
let output = '';
const io = { stdout: { write: s => { output += s; } }, env: {} };
await orgCommand(['system-types', 'installed'], io);
assert.deepEqual(JSON.parse(output).map(x => x.id), ['business-management', 'coding', 'creative-writing', 'marketing', 'montage']);
output = '';
const calls = [];
await projectBusCommand(['system-types', 'work', '--business', 'demo', '--json'], io, () => async (verb, args) => {
calls.push([verb, args]);
return verb === 'project.list' ? [{ id: 'project-id', name: 'work' }] : { project: 'project-id', selected: ['coding'], active: ['coding'], inactive: [] };
});
assert.deepEqual(calls.map(x => x[0]), ['project.list', 'project.system-types']);
assert.deepEqual(JSON.parse(output).active, ['coding']);
});
// sys1-review P2: both commands go through run() and the real CLI channel module, so a verb the
// channel does not admit fails here. A disabled channel and an agent run still refuse.
test('project system-types and system-types-set pass the CLI channel', async t => {
const { run } = await import('../src/cli.mjs');
const { fixture, humanEnv, io: mkio, tmp } = await import('./helpers.mjs');
const root = tmp(t), f = fixture(root);
const P = '10000000-0000-4000-8000-000000000001';
const transportFor = channels => {
const calls = [];
const transport = () => async (verb, args) => {
if (verb === 'plugins') return { business: 'acme', revision: 'a'.repeat(64), plugins: { channel: channels } };
calls.push([verb, args]);
if (verb === 'project.list') return [{ id: P, at: '2026-11-09T00:00:00.000Z', name: 'work', repo: null }];
if (verb === 'project.system-types') return { project: P, selected: ['coding'], active: ['coding'], inactive: [] };
if (verb === 'project.system-types.set') return { project: P, selected: args.systemTypes, active: args.systemTypes, inactive: [] };
throw Object.assign(new Error(verb), { code: 'bus-refused' });
};
return { calls, transport };
};
const read = transportFor(['cli']), rx = mkio(humanEnv(f.env));
assert.equal(await run(['project', 'system-types', 'work', '--business', 'acme'], rx, { transport: read.transport }), 0, rx.err?.text);
assert.deepEqual(read.calls, [['project.list', { includeArchived: true }], ['project.system-types', { project: P }]]);
assert.deepEqual(JSON.parse(rx.out.text).active, ['coding']);
const write = transportFor(['cli']), wx = mkio(humanEnv(f.env));
assert.equal(await run(['project', 'system-types-set', 'work', '--types', 'coding,marketing', '--business', 'acme'], wx, { transport: write.transport }), 0, wx.err?.text);
assert.deepEqual(write.calls.at(-1), ['project.system-types.set', { project: P, systemTypes: ['coding', 'marketing'] }]);
const off = transportFor(['webui']), ox = mkio(humanEnv(f.env));
assert.equal(await run(['project', 'system-types', 'work', '--business', 'acme'], ox, { transport: off.transport }), 3);
assert.deepEqual(off.calls, []);
const agent = transportFor(['cli']);
assert.notEqual(await run(['project', 'system-types-set', 'work', '--types', 'coding', '--business', 'acme'], mkio({ ...humanEnv(f.env), CODEX_THREAD_ID: 'fixture-agent' }), { transport: agent.transport }), 0);
assert.deepEqual(agent.calls, []);
});
+45 -13
View File
@@ -5,7 +5,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { existsSync, lstatSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { main } from "../src/cli.mjs";
@@ -124,10 +124,10 @@ test("row 175: --tester on limits set, reset and migrate and every write form ou
// W7 through the real tester transport: the CLI runs as its own process, so the tester-cli.mjs it
// spawns can reach the runtime in this one.
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
function mosaic(args, env) {
function command(executable, args, env) {
const clean = Object.fromEntries(Object.entries(env).filter(([k]) => !/^MOSAIC_BUS_(TESTER|CLI)_NONCE$/.test(k)));
return new Promise((resolve, reject) => {
const child = spawn(MOSAIC, args, { env: clean, timeout: 60000 });
const child = spawn(executable, args, { env: clean, timeout: 60000 });
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
@@ -136,6 +136,8 @@ function mosaic(args, env) {
child.on("close", (status) => resolve({ status, out, err }));
});
}
const mosaic = (args, env) => command(MOSAIC, args, env);
// sage-1081: these six fell through to the seat CLI, which refused with its own usage, so W1
// (`plugins set`) and W4 (`name`) failed through the wrapper. Each now reaches packages/cli.
test("row 175: scripts/mosaic sends plugins, name, names, follow, threads and thread to packages/cli, not the seat CLI", (t) => {
@@ -157,36 +159,55 @@ test("row 175: scripts/mosaic sends plugins, name, names, follow, threads and th
});
const listing = (dir) => readdirSync(dir, { recursive: true }).map(String).sort();
// Names, types and file bytes: a rewrite of an existing file shows up, not just a new path.
const contents = (dir) => listing(dir).map((name) => {
const path = join(dir, name), st = lstatSync(path);
return [name, st.isFile() ? readFileSync(path).toString("base64") : st.isDirectory() ? "dir" : `mode ${st.mode}`];
});
async function w7(t, vars, edit) {
const { root, f, org } = await orgFixture(t, "acme", edit);
const rt = await startBroker({ dataRoot: org.root, businesses: bus(vars) });
const root = tmp(t, "w7-");
const f = fixture(root, "acme", edit);
const rt = await startBroker({ dataRoot: f.dataRoot, businesses: bus(vars) });
t.after(() => rt.close());
return { root, f, agent: { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } };
// Production CLI routes its transport through an organization. Inject the real tester transport
// in a child so the legacy non-org project path can still exercise the broker and its proof.
const driver = join(root, "driver.mjs");
writeFileSync(driver, `
import { run } from ${JSON.stringify(new URL("../src/cli.mjs", import.meta.url).href)};
import { testerTransport } from ${JSON.stringify(new URL("../src/transport.mjs", import.meta.url).href)};
const [socket, ...args] = process.argv.slice(2);
process.exitCode = await run(args, undefined, {
testerTransport: business => testerTransport({ socket, business }),
});
`);
const agent = { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" };
const project = (args, env = agent) => command(process.execPath, [driver, join(f.dataRoot, "bus", "broker.sock"), ...args], env);
return { root, f, agent, project };
}
test("row 175 W7: project init --tester makes the repository under the project area, committed by tester", async (t) => {
const { f, agent } = await w7(t);
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
const { f, agent, project } = await w7(t);
const r = await project(["project", "init", "acme", "stack", "--tester"], agent);
assert.equal(r.status, 0, r.err);
const repo = join(f.dataRoot, "projects", "acme", "stack");
assert.match(r.out, new RegExp(`^initialized ${repo}\ncommit [0-9a-f]{40}\n$`));
const log = spawnSync("git", ["-C", repo, "log", "--format=%an|%cn"], { encoding: "utf8" });
assert.equal(log.stdout, "tester|tester\n");
// Without --tester the same init inside an agent refuses, as before.
const again = await mosaic(["project", "init", "acme", "stack"], agent);
const again = await project(["project", "init", "acme", "stack"], agent);
assert.equal(again.status, 3);
assert.match(again.err, /outside any agent run/);
});
test("row 175 W7: a repo outside the project area refuses tester-project-area before any bus call, and nothing changes", async (t) => {
// The switch is off: a bind would refuse tester-off (exit 3), so exit 2 shows the area check ran first.
const { f, agent } = await w7(t, null, (doc) => {
const { f, agent, project } = await w7(t, null, (doc) => {
doc.projects.stack.repo = join(doc.projects.stack.root, "..", "records");
return doc;
});
const records = f.doc.projects.stack.repo;
const data = listing(f.dataRoot);
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
const r = await project(["project", "init", "acme", "stack", "--tester"], agent);
assert.equal(r.status, 2, r.err);
assert.match(r.err, /tester-project-area/);
assert.equal(existsSync(records), false);
@@ -196,7 +217,7 @@ test("row 175 W7: a repo outside the project area refuses tester-project-area be
test("row 175 W7: the tester bind refuses tester-off and tester-required before any directory", async (t) => {
const off = await w7(t, null);
const data = listing(off.f.dataRoot);
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], off.agent);
const r = await off.project(["project", "init", "acme", "stack", "--tester"], off.agent);
assert.equal(r.status, 3, r.err);
assert.match(r.err, /tester-off/);
assert.deepEqual(listing(off.f.dataRoot), data);
@@ -204,10 +225,21 @@ test("row 175 W7: the tester bind refuses tester-off and tester-required before
const on = await w7(t);
const before = listing(on.f.dataRoot);
for (const marker of ["MOSAIC_RUN_ID", "MOSAIC_BUS_CAP", "MOSAIC_AGENT_RUN"]) {
const under = await mosaic(["project", "init", "acme", "stack", "--tester"], { ...on.agent, [marker]: "x" });
const under = await on.project(["project", "init", "acme", "stack", "--tester"], { ...on.agent, [marker]: "x" });
assert.equal(under.status, 3, `${marker}: ${under.err}`);
assert.match(under.err, /tester-required/, marker);
}
assert.deepEqual(listing(on.f.dataRoot), before);
assert.equal(existsSync(join(on.f.dataRoot, "projects")), false);
});
// Organization setup deliberately refuses tester init before creating a project record.
test("organization project init --tester refuses without writing", async (t) => {
const { f, org } = await orgFixture(t);
const before = contents(org.root);
const agent = { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" };
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
assert.equal(r.status, 3, r.err);
assert.match(r.err, /organization project init --tester is unavailable/);
assert.deepEqual(contents(org.root), before);
});
+5
View File
@@ -578,3 +578,8 @@ The local development owner is accepted only through the existing named-instance
selection on this release's throwaway development instances. Receipt subjects
name that principal, not Jason. The dev-purpose bootstrap fence is a separate
follow-up before person-used deployment.
The optional `plugins.system-type` list enables installed system-type modules for
an organization. Missing means none. Unknown ids refuse validation. Changes
are owner-only; project selections are separate bus settings and a selection
whose type is disabled remains recorded as inactive.
@@ -12,6 +12,7 @@ import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
import { END_PROTECTED } from "./vocabulary.mjs";
import { checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
import { hasModule } from "../../plugins/src/loader.mjs";
import { checkMapping } from "../../library/src/mapping.mjs";
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
import { checkAppearance } from "./appearance.mjs";
@@ -322,6 +323,9 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
// here; whether each module loads is the loader's check, at load time.
const plugins = document.plugins === undefined ? {}
: structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
for (const id of plugins["system-type"] ?? []) {
if (!hasModule("system-type", id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
}
const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage);
const appearance = checkAppearance(document.appearance, `${file} appearance`);
const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file);
@@ -105,6 +105,13 @@ function usageFence(old, document, actor) {
fail("organization-usage-human-only");
}
function systemTypesFence(old, document, actor) {
if (canonicalJson(old.plugins?.['system-type'] ?? []) === canonicalJson(document?.plugins?.['system-type'] ?? [])) return;
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
if (actor === old.human && actor !== 'tester' && !agents.includes(actor)) return;
fail('organization-system-types-human-only');
}
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is
// the business's human as the file stood before the write, and no agent or the tester under that
// name. Compared as M9 compares projects, on the document and again on what lands on disk.
@@ -286,6 +293,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
replyLimitsFence(old, document, actor);
modelQuotaFence(old, document, actor);
usageFence(old, document, actor);
systemTypesFence(old, document, actor);
validateBusinessDocument(document, file, { rolesDir });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
@@ -295,6 +303,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
replyLimitsFence(old, written, actor);
modelQuotaFence(old, written, actor);
usageFence(old, written, actor);
systemTypesFence(old, written, actor);
validateBusinessDocument(written, file, { rolesDir });
if (beforeWrite) {
beforeWrite();
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { validateBusinessDocument } from '../src/organization-config.mjs';
import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs';
test('organization enables only installed system types; absent means none', () => {
const root = tmp(), doc = businessDoc(root), file = join(root, 'businesses/acme.json');
const validate = () => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES });
assert.deepEqual(validate().plugins['system-type'] ?? [], []);
doc.plugins = { 'system-type': ['coding', 'montage'] };
assert.deepEqual(validate().plugins['system-type'], ['coding', 'montage']);
doc.plugins['system-type'] = ['not-installed'];
assert.throws(validate, /system-type.*not-installed/);
});
test('system type changes through the organization writer are owner-only', async t => {
const { rolesCopy, writeJson } = await import('./helpers.mjs');
const { pluginSettings } = await import('../src/plugin-settings.mjs');
const { rmSync } = await import('node:fs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root), doc = businessDoc(root), file = join(root, 'businesses/acme.json');
writeJson(file, doc);
const settings = pluginSettings({ dir: root, rolesDir });
const revision = settings.read('acme').revision;
assert.throws(() => settings.set('acme', { revision, plugins: { 'system-type': ['coding'] } }, 'tester'), /human-only/);
assert.deepEqual(settings.read('acme').plugins, {});
assert.deepEqual(settings.set('acme', { revision, plugins: { 'system-type': ['coding'] } }, doc.human).plugins['system-type'], ['coding']);
});
+9
View File
@@ -496,3 +496,12 @@ counts; it has no quota authority. See
record separation, bounded reads and per-key quota settings.
A harness declares `outputPolicy({ proxy })` as `adapter`, `non-newline-stdout`, or `stdout`. The launcher validates and serializes that declaration; old sessions retain their recorded activity behavior.
### System types
`system-type` modules export `interfaceVersion = 1` and `systemType()`, returning
`{ id, label, description, toolsets }`. The id is the module directory name;
`toolsets` is a distinct list of toolset ids. The installed list is discovered
from module directories, and malformed modules refuse discovery. An organization
selects modules with `plugins.system-type`; an omitted list enables none. The
initial five modules declare no toolsets yet.
+2
View File
@@ -6,6 +6,8 @@ const VERBS = new Set([
"project.create",
"project.archive",
"project.unarchive",
"project.system-types",
"project.system-types.set",
"thread.list",
"thread.create",
"thread.messages",
+4
View File
@@ -80,6 +80,10 @@ export const KINDS = Object.freeze({
"model-provider": Object.freeze({
kind: "model-provider", interfaceVersion: 1, exports: Object.freeze(["catalogue", "preflight", "route"]), switch: "list",
}),
// Project system types are installed modules, enabled per organization.
"system-type": Object.freeze({
kind: "system-type", interfaceVersion: 1, exports: Object.freeze(["systemType"]), switch: "list",
}),
// A Console colour theme (stream S4, PLAN C5). A module is data only: theme() takes nothing and returns
// { displayName, tokens: { light, dim, dark } }
// where each mode is a flat map of the Console's colour custom properties to #rrggbb strings. theme.mjs
+19
View File
@@ -0,0 +1,19 @@
import { loadInstalled } from './loader.mjs';
import { NAME } from './kinds.mjs';
// Discovery is fail-closed: malformed or unloadable installed modules cannot be advertised.
export async function installedSystemTypes() {
const { modules, refused } = await loadInstalled('system-type');
if (refused.length) throw Object.assign(new Error('system type module refused'), { code: 'system-type-invalid' });
return modules.map(({ name, module }) => {
const value = module.systemType();
if (!value || Object.keys(value).sort().join(',') !== 'description,id,label,toolsets' || value.id !== name ||
typeof value.label !== 'string' || !value.label.trim() || /[\r\n]/.test(value.label) ||
typeof value.description !== 'string' || !value.description.trim() || /[\r\n]/.test(value.description) ||
!Array.isArray(value.toolsets) || value.toolsets.some(t => typeof t !== 'string' || !NAME.test(t)) ||
new Set(value.toolsets).size !== value.toolsets.length) {
throw Object.assign(new Error(`invalid system type ${name}`), { code: 'system-type-invalid' });
}
return structuredClone(value);
});
}
@@ -0,0 +1,5 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "business-management", label: "Business management", description: "Coordinate and manage business operations.", toolsets: [] };
}
@@ -0,0 +1,5 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "coding", label: "Coding", description: "Build and maintain software.", toolsets: [] };
}
@@ -0,0 +1,5 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "creative-writing", label: "Creative writing", description: "Develop and write creative works.", toolsets: [] };
}
@@ -0,0 +1,5 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "marketing", label: "Marketing", description: "Plan and produce marketing work.", toolsets: [] };
}
@@ -0,0 +1,5 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "montage", label: "Mosaic Montage", description: "Create and manage media productions.", toolsets: [] };
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs";
const code = expected => e => e instanceof PluginError && e.code === expected;
test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1, limit-check at 2 (row 125), each a list", () => {
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "theme"]);
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "system-type", "theme"]);
// Stream S4 (PLAN C5): a theme is data. interfaceVersion 1, one export, a list switch.
assert.deepEqual({ ...KINDS.theme, exports: [...KINDS.theme.exports] }, { kind: "theme", interfaceVersion: 1, exports: ["theme"], switch: "list" });
assert.ok(Object.isFrozen(KINDS.theme) && Object.isFrozen(KINDS.theme.exports));
+5 -5
View File
@@ -12,7 +12,7 @@ import { validateBusinessDocument } from "../../organization/src/organization-co
const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] };
// What loaded() reports for GOOD: search-source is a kind too, switched off here.
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] };
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] };
// A tree with one good module of each kind, already loaded.
async function loadedTree(t, options) {
@@ -56,7 +56,7 @@ test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization
const loader = await s.loader();
const result = await loader.load(business.plugins);
assert.deepEqual(result.refused, []);
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage");
assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit");
assert.equal(loader.modules("limit-check")[0].mustStop(), false);
@@ -170,7 +170,7 @@ test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses a
assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/);
assert.deepEqual(result.refused[0].kept, ["meter"]);
assert.deepEqual(s.loader.logged.slice(before), result.refused);
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
} finally {
if (lock) chmodSync(lock, 0o755);
}
@@ -232,7 +232,7 @@ test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and t
const loader = await s.loader();
// meter loads, absent refuses: the whole kind stays off, not half on.
const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
assert.deepEqual(loader.loaded(), result.loaded);
assert.deepEqual(loader.modules("usage-source"), []);
assert.equal(loader.logged.length, 1);
@@ -260,7 +260,7 @@ test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole se
await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/);
// An absent key switches the kind off.
result = await s.loader.load({ "limit-check": ["cap"] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
});
test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => {
@@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createLoader, loadInstalled } from '../src/loader.mjs';
test('installed system types describe the five modules and unknown names refuse', async () => {
const { modules, refused } = await loadInstalled('system-type');
assert.deepEqual(refused, []);
assert.deepEqual(modules.map(m => m.name), ['business-management', 'coding', 'creative-writing', 'marketing', 'montage']);
for (const { name, module } of modules) {
const info = module.systemType();
assert.equal(info.id, name);
assert.ok(info.label && info.description && Array.isArray(info.toolsets));
if (name === 'montage') assert.equal(info.label, 'Mosaic Montage');
}
const loader = createLoader({ log() {} });
const result = await loader.load({ 'system-type': ['unknown'] });
assert.equal(result.refused.length, 1);
assert.deepEqual(result.loaded['system-type'], []);
});
+23 -6
View File
@@ -1,6 +1,6 @@
{
"prd": "docs/prd/mosaic-stack.md",
"version": "1.19",
"version": "1.20",
"requirements": [
{ "id": "REQ-ROLE-1", "title": "Each role is a reviewed roles/<name>.json: contract, authority map, credentials" },
{ "id": "REQ-ROLE-2", "title": "One holder per role instance, one per assistant session; the chief of staff may end a run, others need a decision" },
@@ -10,10 +10,13 @@
{ "id": "REQ-ROLE-6", "title": "Agents are proactive: each role contract says to act within its grants without waiting to be asked" },
{ "id": "REQ-COS-1", "title": "The chief of staff runs the organization: hires, terminates, launches; never talks to the user" },
{ "id": "REQ-COS-2", "title": "The PM asks the chief of staff for staff; a staff request names the roles and how many of each" },
{ "id": "REQ-ASSIST-1", "title": "The assistant is the user's main agent and passes organization work to the chief of staff" },
{ "id": "REQ-ASSIST-1", "title": "One assistant per user across organizations, with shared memory, passes work to each chief of staff" },
{ "id": "REQ-ASSIST-2", "title": "The broker limits the assistant to the user and the chief of staff in both directions; refusals logged" },
{ "id": "REQ-ASSIST-3", "title": "The assistant tells the user what other roles sent it in its own words and does not relay their text" },
{ "id": "REQ-COS-3", "title": "The chief of staff hires and fires role instances without a decision, within fixed bounds; the user forbids per agent" },
{ "id": "REQ-AGENT-1", "title": "Agents are configured at root, organization and project levels; a project's agents are their union" },
{ "id": "REQ-AGENT-2", "title": "The user can see and change agent, organization, project and session configuration through the write path" },
{ "id": "REQ-AGENT-3", "title": "The chief hires and fires at all levels; root-level changes need user approval" },
{ "id": "REQ-VAR-1", "title": "Variables in four layers, declared in code; limits intersect; unknown keys refused" },
{ "id": "REQ-VAR-2", "title": "One system config, never auto-written; secrets only as references the broker reads" },
{ "id": "REQ-CRED-1", "title": "Each role instance has its own Gitea and Vikunja token, held by the broker" },
@@ -37,7 +40,7 @@
{ "id": "REQ-WAIT-3", "title": "An action added to the vocabulary is a hard wait until a PRD version says it is not" },
{ "id": "REQ-MSG-1", "title": "Messages are addressed to a role, append-only, delivered to the current holder; assistant messages name a thread" },
{ "id": "REQ-MSG-2", "title": "Messages can be addressed by an instance name unique in its business; the message records the name and the instance" },
{ "id": "REQ-TASK-1", "title": "Tasks live in Vikunja, each citing a requirement id; the verbs enforce field writers" },
{ "id": "REQ-TASK-1", "title": "Each organization stores tasks through a task tracker module; Vikunja is optional" },
{ "id": "REQ-TASK-2", "title": "Polling is the source of truth: a 30-second board poll and an hourly reconcile" },
{ "id": "REQ-TASK-3", "title": "The installer points at an existing Vikunja or deploys the unmodified upstream image" },
{ "id": "REQ-TASK-4", "title": "The queue keeps the agents' lock, review rounds and audit log; no two-way sync" },
@@ -51,7 +54,15 @@
{ "id": "REQ-CLI-2", "title": "The assistant session mosaic talks to is launched and owned by the stack, not T3" },
{ "id": "REQ-WEB-1", "title": "The WebUI shows the CLI's data, a chat area of projects and threads, and Settings for agent limits" },
{ "id": "REQ-WEB-2", "title": "The Console resolves an open decision with one button per option, only for a human it signed in itself (OIDC)" },
{ "id": "REQ-WEB-3", "title": "The Console restarts the bus and reloads the organization file, gated and trailed; reload applies launch.instances live" },
{ "id": "REQ-WEB-3", "title": "Human-gated Console restart, bus restart, reload and agent model controls; dev-local owner needs no sign-in" },
{ "id": "REQ-WS-1", "title": "The WebUI always shows all organizations in a collapsible sidebar, with no switcher" },
{ "id": "REQ-WS-2", "title": "The Console builds shared views across the user's organization buses, subject to membership" },
{ "id": "REQ-WS-3", "title": "The main dashboard sums activity, work and metrics across the user's organizations and projects" },
{ "id": "REQ-WS-4", "title": "Each organization has a dashboard for docs, settings, KPIs, metrics and active sessions" },
{ "id": "REQ-WS-5", "title": "The projects list spans organizations, sorts and filters, and shows ownership" },
{ "id": "REQ-WS-6", "title": "One inbox holds items needing the user from all organizations, projects and sessions" },
{ "id": "REQ-WS-7", "title": "One view shows cross-organization activity; organizations and projects open side by side" },
{ "id": "REQ-WS-8", "title": "The tasks view aggregates every organization, with Kanban and list layouts and filters" },
{ "id": "REQ-LAUNCH-1", "title": "The chief of staff launches role sessions within the agent limits, logged and revocable; threads start on demand" },
{ "id": "REQ-LIMIT-1", "title": "Agent limits have no code ceiling and are set from WebUI Settings and mosaic: total, per provider, per model" },
{ "id": "REQ-LIMIT-2", "title": "A daily quota per OAuth account or CLIProxyAPI client key, counting only the stack's own use" },
@@ -59,6 +70,8 @@
{ "id": "REQ-LIMIT-4", "title": "Usage sources and limit checks are the first plug-in kinds; an unknown turn is logged and shown, never counted as zero" },
{ "id": "REQ-PLUG-1", "title": "Every part the core calls out to is a plug-in kind; modules load from packages/plugins, switched on in the org file" },
{ "id": "REQ-PLUG-2", "title": "New call-outs go through a plug-in kind from the start; a hard-coded part converts when a row changes it" },
{ "id": "REQ-SYS-1", "title": "System types are a plug-in kind; organizations enable a list of modules" },
{ "id": "REQ-SYS-2", "title": "Projects use enabled system types and their agents receive the corresponding toolsets" },
{ "id": "REQ-HARN-1", "title": "The meta-harness generates each session's launch bundle from role and variables" },
{ "id": "REQ-HARN-2", "title": "Credential scope, verbs, the tool limit and a container enforce the rules" },
{ "id": "REQ-EVT-1", "title": "Every slice 1 step emits an event; measures come from events" },
@@ -78,7 +91,7 @@
{ "id": "REQ-DISK-14", "title": "Events" },
{ "id": "REQ-ORG-1", "title": "Business becomes organization; an install holds 1-N, each with its own bus as tenant boundary" },
{ "id": "REQ-ORG-2", "title": "Data model: install > organization > projects > threads" },
{ "id": "REQ-USER-1", "title": "Users and RBAC at install and organization level; an agent reaches only its allowed projects and threads" },
{ "id": "REQ-USER-1", "title": "Users and RBAC at install and organization level; agents are scoped to one organization except the assistant" },
{ "id": "REQ-USER-2", "title": "Only a thread's owning user, their assistant and the chief of staff's replies reach the thread" },
{ "id": "REQ-THREAD-1", "title": "Threads live under projects" },
{ "id": "REQ-THREAD-2", "title": "The user creates projects and named threads; a thread holds independent assistant sessions, each with kept history" },
@@ -91,6 +104,8 @@
{ "id": "REQ-SESS-1", "title": "Each session shows its lifetime count of completed compactions on pi and Claude Code; an unproven count shows unknown" },
{ "id": "REQ-SESS-2", "title": "Clear context retires a session after a safe stop and starts a fresh successor with none of the old context" },
{ "id": "REQ-SESS-3", "title": "Every session start passes the launch checks: authority, route, revocation, kill switch, limits and admission" },
{ "id": "REQ-ACCT-1", "title": "Deployed systems require sign-in; users create and belong to organizations" },
{ "id": "REQ-ACCT-2", "title": "Organization owners invite users with a chosen grant" },
{ "id": "REQ-LIB-1", "title": "A human-set library maps the user's repository onto universes, books, types, playgrounds, supporting and archive folders" },
{ "id": "REQ-LIB-2", "title": "Scope comes from the path or a DOCX's source, lookups default to canon, and only canon files are ever cited as canon" },
{ "id": "REQ-LIB-3", "title": "Universes stay separate; a crossover is a hybrid universe only a human creates, reading its sources' canon" },
@@ -102,6 +117,8 @@
{ "id": "REQ-LIB-9", "title": "The Console's DOCX editor: only a human saves, each save is one commit, and the editor code is vendored and pinned" },
{ "id": "REQ-INST-1", "title": "The installer offers optional components: pinned, health-checked, removable, switched on in config; first is SearXNG" },
{ "id": "REQ-INST-2", "title": "With no OIDC provider to configure, the installer deploys a bundled Pocket ID, Q203 (post-v1)" },
{ "id": "REQ-INST-3", "title": "One-command install of a named instance from a profile, and one-command upgrade with backup and rollback" }
{ "id": "REQ-INST-3", "title": "One-command install of a named instance from a profile, and one-command upgrade with backup and rollback" },
{ "id": "REQ-SETUP-1", "title": "Quick Setup initializes the project records" },
{ "id": "REQ-SETUP-2", "title": "The assistant offers an onboarding interview and drafts a north star and first PRD for user approval" }
]
}
+5 -5
View File
@@ -18,12 +18,12 @@ test("the list holds the PRD's requirement ids, in its order, at its version", (
const ids = [...prd.matchAll(/^- \*\*(REQ-[A-Z]+-[0-9]+)\.\*\*/gm)].map((m) => m[1]);
// Any other mention of a bold id would be a bullet this pattern missed.
assert.equal(ids.length, [...prd.matchAll(/\*\*REQ-/g)].length);
assert.equal(ids.length, 101);
assert.equal(ids.length, 118);
assert.deepEqual(list.requirements.map((r) => r.id), ids);
assert.match(prd, new RegExp(`^- Status: approved, version ${list.version.replace('.', '\\.')}\\.`, 'm'));
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.19']);
assert.match(prd, /^- Status: approved, version 1\.19\. Jason approved the exact text in Q289\.$/m);
assert.match(prd, /^- Prior approval history, through version 1\.18:/m);
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.20']);
assert.match(prd, /^- Status: approved, version 1\.20\. Jason approved draft 2 on 2026-11-09\.$/m);
assert.match(prd, /^- Prior approval history, through version 1\.19:/m);
});
test('the committed list loads frozen', () => {
@@ -76,7 +76,7 @@ test('the schema refuses any other shape', () => {
const twice = raw();
twice.requirements.push(structuredClone(twice.requirements[0]));
writeFileSync(join(dir, 'twice.json'), JSON.stringify(twice));
assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[101].id: REQ-ROLE-1 twice'));
assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[118].id: REQ-ROLE-1 twice'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
+25 -6
View File
@@ -20,6 +20,8 @@ baseline CLI.
| Recover a stopped bus with stale host state | `mosaic bus recover <id>` | CLI only |
| Inspect or clear a dead lifecycle lock | `mosaic org lock show <id>` / `mosaic org lock clear <id>` | CLI only |
| Start, stop or restart a bus | `mosaic bus start <id>` / `mosaic bus stop <id>` / `mosaic bus restart <id>` | S6 integration hook |
| Restart (or first start) an organization's bus | `mosaic bus restart <id>`, or `mosaic bus start <id>` for a bus never started | Enabled (F3): the owner's **Restart bus** control, offered where a page says the bus is down or a change needs a restart; trailed |
| Restart the Console | Stop and run `mosaic console start` again | Enabled (F3) when `mosaic console start` runs it in the foreground: Settings > Console; the owner only; trailed. Other Consoles answer `not-supervised` |
| Create chat project linked to a configured repository | `mosaic project create <name> --repo <project> --org <id>` | Enabled |
| Create a thread | `mosaic thread create <project> <name> --org <id>` | Planned enabled |
| Open a thread | `mosaic thread open <thread> --org <id>` | Planned enabled |
@@ -34,19 +36,36 @@ baseline CLI.
| Mark a board session seen/unseen; reply to a board seat | No CLI equivalent identified | Unavailable |
| Approve or deny a decision | `mosaic decide <id> <choice> --org <id>` | Owner-only confirmation and resolution on the selected organization bus |
| Answer, attach, change mode, cancel or revoke a request | No equivalent CLI identified | Unavailable |
| Edit or reset launch/assistant limits | `mosaic limits set` / `mosaic limits reset` | Unavailable pending parity |
| Rename an instance | `mosaic name` | Unavailable pending parity |
| Stop an assistant | No equivalent CLI identified | Unavailable |
| Add, change or delete an instance; launch, regenerate or terminate an agent; change a veto | `mosaic org instance add`, `set`, `delete`, `launch`, `regenerate`, `terminate`; `mosaic org veto` (old spelling `mosaic business instance`, `mosaic business veto`) | Unavailable pending parity |
| Change routes or enabled harnesses/providers | `mosaic org instance set`; `mosaic plugins set` | Unavailable pending parity |
| Edit or reset launch/assistant limits | `mosaic limits set` / `mosaic limits reset` | Enabled (F3) for the owner on a selected organization's bus; unavailable on other Consoles pending parity |
| Rename an instance | `mosaic name` | Enabled (F3) for the owner on a selected organization's bus; unavailable elsewhere pending parity |
| Stop an assistant | No equivalent CLI identified | Enabled (F3) for the owner on a selected organization's bus; unavailable elsewhere |
| Add, change or delete an instance; launch, regenerate or terminate an agent; change a veto | `mosaic org instance add`, `set`, `delete`, `launch`, `regenerate`, `terminate`; `mosaic org veto` (old spelling `mosaic business instance`, `mosaic business veto`) | Enabled (F3) for the owner on a selected organization's bus, with **Restart NAME now** (terminate, then launch) after a route change; unavailable elsewhere pending parity |
| Change routes or enabled harnesses/providers | `mosaic org instance set`; `mosaic plugins set` | Enabled (F3) for the owner on a selected organization's bus; the page lists how the change reaches each saved agent; unavailable elsewhere pending parity |
| Halt or resume processing | `mosaic halt` / `mosaic resume` | Unavailable pending parity |
| Save a project file or revert library content | No equivalent CLI identified | Unavailable |
| Change a project remote | No equivalent CLI identified | Unavailable |
| Start, submit or cancel proxy sign-in; add an API key; set account priority or models | No equivalent CLI identified | Unavailable |
| Start, submit or cancel proxy sign-in; add an API key; set account priority or models | No equivalent CLI identified | Enabled (F3) for the owner on a selected organization's bus once its proxy is set up; setup itself stays `mosaic proxy init` in a terminal |
Browser navigation, local appearance preferences and unsaved drafts are local
state, not server writes. Signing out is a session action.
F3 writes (Console restart, bus restart, and the owner's routing, agent and
instance changes on an organization) append one line each to
`<dataRoot>/console/actions.jsonl` (0600): who (provider, issuer, subject,
name), when, what and the outcome. A restart that cannot write its line is
refused. On a Console with sign-in, no tester, granted user or API token
reaches these controls: each needs the signed-in owner, the page's exact Origin
and `Sec-Fetch-Site: same-origin`. Those headers stop another site's page, not
a local program, which can send any header it likes.
A dev local owner Console (`--dev-local-owner`) has no sign-in. Every request
that passes the Host and Origin checks acts as the owner, and this includes the
F3 controls. An agent launched on the same machine runs as the same OS user, so
it can restart the Console or a bus and change routing, agents and settings just
as the owner's page can. The startup warning says so. Use a dev instance for UI
testing only. The real boundary is sign-in (`--sso`); a refusal for local agent
peers in dev mode is a recorded follow-up.
### Route registration
A module may export `registerRoutes(table)` and be included with one entry in
+24
View File
@@ -0,0 +1,24 @@
// F3: the Console's trail of human operations (Console restart, org bus restart, route and
// instance changes made from the page). One JSON line per use: who, when, what, outcome.
// The file is <dataRoot>/console/actions.jsonl, owner-only. A write that cannot be made safely
// throws, and the caller refuses the operation (fail closed).
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, writeSync } from 'node:fs';
import { join } from 'node:path';
// The person, never a token: provider, issuer, subject and display name of the session.
export const actor = session => ({ provider: session.provider, iss: session.iss, sub: session.sub, ...(typeof session.name === 'string' ? { name: session.name } : {}) });
export function actionTrail(dir) {
const file = join(dir, 'actions.jsonl');
return entry => {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const st = lstatSync(dir);
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077) !== 0) throw new Error('unsafe trail directory');
const fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
try {
const f = fstatSync(fd);
if (!f.isFile() || f.uid !== process.getuid() || (f.mode & 0o777) !== 0o600 || f.nlink !== 1) throw new Error('unsafe trail file');
writeSync(fd, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`);
} finally { closeSync(fd); }
};
}
+21 -38
View File
@@ -27,19 +27,24 @@ export const subjectOk = s => typeof s === 'string' && s.length > 0 && s.length
// Same protocol as humanTransport in packages/cli/src/transport.mjs: the
// request on stdin, JSON on stdout, or a bus error code as the last
// `^[a-z-]{1,64}$` line of stderr.
export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
return (verb, args = {}) => transport({ cli, socket, env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, input: { business, verb, args }, timeoutMs, maxBytes });
export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024, signal, deadline }) {
return (verb, args = {}) => transport({ cli, socket, signal, deadline, env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, input: { business, verb, args }, timeoutMs, maxBytes });
}
function transport({ cli, socket, env, input, timeoutMs, maxBytes }) {
function transport({ cli, socket, env, input, timeoutMs, maxBytes, signal, deadline }) {
return new Promise((resolve, reject) => {
const remaining = deadline === undefined ? timeoutMs : Math.min(timeoutMs, deadline - Date.now());
if (signal?.aborted || (deadline !== undefined && remaining <= 0)) return reject(new BusReadError('outcome-unknown', 'the bus transport deadline expired'));
let child;
try {
child = spawn(process.execPath, [cli, socket], { env, stdio: ['pipe', 'pipe', 'pipe'] });
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
const out = [], err = [];
let size = 0, settled = false;
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs);
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); signal?.removeEventListener('abort', abort); fn(value); } };
const abort = () => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport deadline expired')); };
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, remaining);
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
child.stdout.on('data', chunk => {
size += chunk.length;
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); }
@@ -82,9 +87,10 @@ export function consoleCall({ dataRoot, socket, business = null, env = process.e
// The business is the --business flag, or else the live bus host's, read
// on every request so a host started after the Console is picked up.
export function busCaller({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) {
export function busCaller({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, signal, deadline }) {
return async (verb, args) => {
return humanCall({ socket, business: business || hostBusiness(dataRoot), env, cli, timeoutMs })(verb, args);
if (signal?.aborted || (deadline !== undefined && Date.now() >= deadline)) throw new BusReadError('outcome-unknown', 'the bus transport deadline expired');
return humanCall({ socket, business: business || hostBusiness(dataRoot), env, cli, timeoutMs, signal, deadline })(verb, args);
};
}
function hostBusiness(dataRoot) {
@@ -103,36 +109,10 @@ export const USER_CLI = fileURLToPath(new URL('../../bus/src/user-cli.mjs', impo
// The Console sets the user nonce itself, so user-cli.mjs doesn't re-exec and the broker sees the
// Console (`cli.mjs serve`) as its parent (principal.mjs). The human nonce is dropped, so this child
// can never pass as human-cli, and it only ever runs USER_CLI.
export function userCall({ socket, business, user, env = process.env, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
export function userCall({ socket, business, user, env = process.env, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024, signal, deadline }) {
const { MOSAIC_BUS_CLI_NONCE, ...rest } = env;
return (verb, args = {}) => new Promise((resolve, reject) => {
let child;
try {
child = spawn(process.execPath, [USER_CLI, socket], { env: { ...rest, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') }, stdio: ['pipe', 'pipe', 'pipe'] });
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
const out = [], err = [];
let size = 0, settled = false;
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs);
child.stdout.on('data', chunk => {
size += chunk.length;
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); }
out.push(chunk);
});
child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); });
child.on('error', () => done(reject, new BusReadError('outcome-unknown', 'the bus transport did not start')));
child.on('close', status => {
if (status === null) return done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish'));
if (status !== 0) {
const code = Buffer.concat(err).toString('utf8').trim().split('\n').reverse().find(l => /^[a-z-]{1,64}$/.test(l)) ?? 'invalid-response';
return done(reject, new BusReadError(code));
}
try { done(resolve, JSON.parse(Buffer.concat(out).toString('utf8'))); }
catch { done(reject, new BusReadError('invalid-response')); }
});
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
child.stdin.end(`${JSON.stringify({ business, user, verb, args })}\n`);
});
return (verb, args = {}) => transport({ cli: USER_CLI, socket, env: { ...rest, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') },
input: { business, user, verb, args }, timeoutMs, maxBytes, signal, deadline });
}
// The business is the --business flag, or else the live bus host's, read on every call, as busCaller does.
@@ -147,9 +127,12 @@ export function busBusiness({ dataRoot, business = null }) {
}
// One granted user's caller, for that user's session only.
export function userCaller({ dataRoot, socket, business = null, user, env = process.env, timeoutMs = 30000 }) {
export function userCaller({ dataRoot, socket, business = null, user, env = process.env, timeoutMs = 30000, signal, deadline }) {
const which = busBusiness({ dataRoot, business });
return async (verb, args) => userCall({ socket, business: which(), user, env, timeoutMs })(verb, args);
return async (verb, args) => {
if (signal?.aborted || (deadline !== undefined && Date.now() >= deadline)) throw new BusReadError('outcome-unknown', 'the bus transport deadline expired');
return userCall({ socket, business: which(), user, env, timeoutMs, signal, deadline })(verb, args);
};
}
// A send's error, sorted. The broker writes in one transaction, so any code it
+24 -9
View File
@@ -22,7 +22,9 @@ import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.m
import { createSso, readSlot } from './sso.mjs';
import { readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { isAbsolute } from 'node:path';
import { isAbsolute, join } from 'node:path';
import { actionTrail } from './action-trail.mjs';
import { RESTART_EXIT } from '../../cli/src/org-console.mjs';
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
import { selectorScope } from '../../organization/src/selector.mjs';
// The bus verifies the Console's exact same-tree script in /proc, whose argv preserves a
@@ -40,12 +42,12 @@ if (process.platform === 'linux') {
}
}
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--supervised] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null;
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null, supervised = false;
const devNames = [];
// --org and --business are one selector in this parser's scope (rename design r2). A flag-shaped
// or invalid value keeps the old wording.
@@ -62,6 +64,8 @@ try {
if (flag === '--dev-local-owner') { devOwner = true; continue; }
if (flag === '--sso') { ssoOn = true; continue; }
if (flag === '--tester-socket') { testerSocket = true; continue; }
// F3: `mosaic console start` supervises this process and relaunches it on exit 75.
if (flag === '--supervised') { supervised = true; continue; }
if (flag === '--tester-port') throw new Error('--tester-port retired; activate explicitly with --tester-socket');
const value = args.shift();
if (!value) throw new Error(`missing value for ${flag}`);
@@ -152,9 +156,12 @@ try {
const organizations = devOwner ? organizationConsole({
ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance },
listOrgs, showOrg, resolveOrg, orgPaths, busStatus: orgBusStatus,
options: ({ id, root, paths, session }) => {
const owner = busCaller({ dataRoot: root, socket: paths.socket, business: id });
const call = session.user === undefined ? owner : userCaller({ dataRoot: root, socket: paths.socket, business: id, user: session.user });
// U1 (#1873): the cross-organization read passes `timeoutMs` so a slow bus's children are
// killed at its deadline. The org pages pass none and keep the transport default.
options: ({ id, root, paths, session, timeoutMs, signal, deadline }) => {
const bound = timeoutMs === undefined ? {} : { timeoutMs, signal, deadline };
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 {
...consoleBus(call, { send: sendOn, ...(session.user === undefined ? {} : { owner }) }),
decisionWrite: session.user === undefined && sendOn ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
@@ -165,11 +172,19 @@ try {
};
},
}) : null;
let server;
let server, testerServer = null;
// F3: the restart route closes every listener and connection, then exits RESTART_EXIT for the
// supervisor to relaunch. Without --supervised the route answers not-supervised.
const restart = supervised ? () => {
sso?.close(); testerServer?.close();
setTimeout(() => process.exit(RESTART_EXIT), 2000).unref();
server.close(() => process.exit(RESTART_EXIT));
server.closeAllConnections();
} : null;
const trail = system ? actionTrail(join(system.dataRoot, 'console')) : null;
try {
server = await startServer({ ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, preselect: options.business ?? null });
server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, preselect: options.business ?? null });
} catch (err) { sso?.close(); throw err; }
let testerServer = null;
if (testerSocket) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
@@ -0,0 +1,39 @@
// F3: POST /api/console/restart. The owner restarts the Console from Settings.
//
// The route table's hook fence has already required a signed-in session on the page's own name,
// the exact Origin and Sec-Fetch-Site: same-origin. This handler adds the owner, --send, a JSON
// `{}` body and the trail. The Console then closes and exits RESTART_EXIT, and the supervisor in
// `mosaic console start` relaunches it on the same arguments and address. Org buses are separate
// processes and keep running. A Console with no supervisor answers not-supervised and stays up.
import { actor } from './action-trail.mjs';
const json = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
};
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
async function emptyBody(req) {
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(one(req, 'content-type') ?? '')) return 'JSON body required';
let text = '';
for await (const chunk of req) { text += chunk; if (text.length > 64) return 'body too large'; }
let value;
try { value = JSON.parse(text); } catch { return 'invalid JSON'; }
return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0 ? null : 'the body is {}';
}
export async function consoleRestartHTTP(req, res, { session = null, owner = false, send = false, restart = null, trail = null, boot, log = () => {} }) {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'restarting the Console is the owner\'s' });
if (!send) return json(res, 403, { error: 'read-only', message: 'start the Console with --send to make changes' });
const invalid = await emptyBody(req);
if (invalid) return json(res, invalid === 'JSON body required' ? 415 : 400, { error: 'invalid-request', message: invalid });
if (!restart) return json(res, 409, { error: 'not-supervised', message: 'This Console was not started in the foreground by mosaic console start, so nothing would start it again. Restart it where it was started.' });
if (!trail) return json(res, 503, { error: 'trail-unavailable', message: 'this Console has no action trail, so it does not restart itself' });
try { trail({ by: actor(session), action: 'console.restart', outcome: 'restarting', boot }); }
catch { return json(res, 500, { error: 'trail-unavailable', message: 'the action trail could not be written, so the Console did not restart' }); }
log('console: restart requested by the owner');
// The answer reaches the page first; then every listener and connection closes.
res.once('finish', () => restart());
return json(res, 202, { outcome: 'restarting', boot });
}
+50 -4
View File
@@ -16,6 +16,7 @@
// POST /api/orgs/ID/setup/queue C2 queueInit mosaic org queue init
// POST /api/orgs/ID/setup/project/P C2 projectRepoInit mosaic org project repo init
// POST /api/orgs/ID/setup/bus C3 busStart mosaic bus start --detach
// POST /api/orgs/ID/restart/bus C3 busRestart mosaic bus restart (F3; busStart when stopped)
//
// GET /api/orgs (the list) and other /api/orgs/ID/... routes are S5's; this
// handler returns false for them.
@@ -25,12 +26,13 @@ import { lstatSync } from 'node:fs';
import { checkProject, initProject, projectRepo, recordContext } from '../../project/src/index.mjs';
import { refuseInsideAgent } from '../../cli/src/transport.mjs';
import { join } from 'node:path';
import { actor } from './action-trail.mjs';
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
const MAX_BODY = 64 * 1024;
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
// The org-template placeholder (packages/organization/src/org-service.mjs templateRender).
export const placeholderRoot = (id, project) => `/__mosaic_org__/${id}/projects/${project}`;
// Project fields the server owns. The browser never sees or sends them.
@@ -47,6 +49,7 @@ export function verbFor(step, id, opts = {}) {
if (step === 'delete') return `mosaic org delete ${id} --confirm ${id} --if-revision ${opts.revision}`;
if (step === 'records') return `mosaic project init ${id} ${opts.project}`;
if (step === 'bus') return `mosaic bus start ${id} --dev-local-owner --detach`;
if (step === 'bus-restart') return `mosaic bus restart ${id}`;
throw new Error(`unknown step ${step}`);
}
@@ -295,7 +298,8 @@ export function createOrgAdmin({ service, registry = null, bus = null, context }
// The handler. Returns true when it answered. `owner` is the signed-in owner
// session (provider, iss, sub) or null; `send` says --send is on; `origin` is
// the page's own origin for the write fence.
export async function orgAdminHTTP(req, res, { path, search = '', admin, session = null, owner = false, send = false, origin = null, log = () => {} }) {
// `trail` (F3) records each bus restart: who, when, which organization and the outcome.
export async function orgAdminHTTP(req, res, { path, search = '', admin, session = null, owner = false, send = false, origin = null, log = () => {}, trail = null }) {
const org = path.match(ROUTE);
const top = ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin'].includes(path) || (path === '/api/orgs' && req.method === 'POST');
if (!org && !top) return false;
@@ -311,7 +315,7 @@ export async function orgAdminHTTP(req, res, { path, search = '', admin, session
const principal = { provider: session.provider, iss: session.iss, sub: session.sub };
if (![principal.provider, principal.iss, principal.sub].every(v => typeof v === 'string' && v)) { json(res, 403, { error: 'owner-only' }); return true; }
try {
await route(req, res, { path, search, org, admin, principal, write, log });
await route(req, res, { path, search, org, admin, principal, write, log, trail, by: actor(session) });
} catch (error) {
if (error?.status) json(res, error.status, { error: error.message, ...(error.detail ? { message: error.detail } : {}), ...(error.errors ? { errors: error.errors } : {}) });
else { log(`org-admin: ${path}: ${error?.code ?? error?.name ?? 'error'}`); json(res, 500, { error: 'internal' }); }
@@ -385,7 +389,7 @@ const gitOptions = (body, res, step) => {
return null;
};
async function route(req, res, { path, search, org, admin, principal, write, log }) {
async function route(req, res, { path, search, org, admin, principal, write, log, trail = null, by = null }) {
if (path === '/api/org-presets' && !write) {
if (new URLSearchParams(search).size) return json(res, 422, { error: 'invalid', message: 'unexpected query parameter' });
const service = await need(admin, 'service', res, 'presets'); if (!service) return;
@@ -508,6 +512,7 @@ async function route(req, res, { path, search, org, admin, principal, write, log
if (kind === 'setup' && !sub && !write) {
try { return json(res, 200, await readSetup(admin, ctx, id)); } catch (e) { return refusal(res, e, { step: 'setup', id }); }
}
if (kind === 'restart') return restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by });
if (kind !== 'setup' || !sub || !write) return json(res, 404, { error: 'not found' });
// Every setup write reads the step back first and never overwrites.
@@ -553,3 +558,44 @@ async function route(req, res, { path, search, org, admin, principal, write, log
}
return json(res, 404, { error: 'not found' });
}
// F3 (REQ-WEB-3): one control restarts an organization's bus and answers with a done receipt. A
// running host restarts on the options it was started with (C3 busRestart keeps --dev-local-owner);
// a stopped one starts as setup starts it. The trail line is written before anything changes, and
// a trail that cannot be written refuses the restart. Stale host state is never cleared here.
async function restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by }) {
const step = 'bus-restart';
if (sub !== 'bus' || !write) return json(res, 404, { error: 'not found' });
const body = await readBody(req); strictKeys(body, []);
try { await service.showOrg(ctx, id); } catch (e) { return refusal(res, e, { step, id }); }
const bus = await need(admin, 'bus', res, step); if (!bus) return;
if (!trail) return json(res, 503, { error: 'trail-unavailable', step, message: 'this Console has no action trail, so it does not restart buses' });
let status;
try { status = await bus.busStatus(ctx, id); }
catch (e) {
if (['org-unknown', 'org-mismatch', 'org-unavailable', 'org-archived'].includes(e?.code)) return refusal(res, e, { step, id });
if (isCliError(e)) {
// Refused before anything changed, but the owner asked: the trail records the attempt.
try { trail({ by, action: 'bus.restart', org: id, outcome: 'refused', running: null }); } catch { log(`org-admin: ${id} bus restart: trail write failed after refused`); }
return json(res, 409, { error: 'bus-refused', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.` });
}
return refusal(res, e, { step, id });
}
const running = Number.isSafeInteger(status?.pid);
try { trail({ by, action: 'bus.restart', org: id, outcome: 'requested', running }); }
catch { return json(res, 500, { error: 'trail-unavailable', step, message: 'the action trail could not be written, so nothing was restarted' }); }
const note = outcome => { try { trail({ by, action: 'bus.restart', org: id, outcome }); } catch { log(`org-admin: ${id} bus restart: trail write failed after ${outcome}`); } };
let receipt;
try {
receipt = running ? await bus.busRestart(ctx, id) : await bus.busStart(ctx, id, { detach: true, devLocalOwner: by?.provider === 'local-dev', pm: false });
} catch (e) {
note('refused');
if (!isCliError(e)) return refusal(res, e, { step, id, unknownOk: true });
return json(res, 409, { error: /^bus failed to start\b/.test(e.message) ? 'bus-failed' : 'bus-refused', step, message: plainMessage(ctx, e.message) });
}
note('done');
log(`org-admin: ${id} bus ${running ? 'restarted' : 'started'}`);
let healthy = null;
try { healthy = (await bus.busStatus(ctx, id)).healthy; } catch { /* the receipt still stands */ }
return json(res, 200, { outcome: 'done', step, restarted: running, healthy, receipt: forReceipt(ctx, receipt), verb: running ? verbFor('bus-restart', id) : verbFor('bus', id) });
}
+32
View File
@@ -35,8 +35,40 @@ export function organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPat
}
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(bound) {
let s;
try { s = await busStatus(ctx, id, bound); }
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: bound => options({ id, root: org.root, paths: orgPaths(org.root), session, context, doc: shown.doc, ...(timeoutMs === undefined ? {} : { timeoutMs }), ...bound }),
});
}
return result;
}
return Object.freeze({
list,
members,
async resolve(id, session) {
const { org, shown, context } = await admitted(id, session);
let busHealthy = false;
+84 -7
View File
@@ -10,8 +10,12 @@
const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const POLL_MS = 2000, POLL_LIMIT = 90;
const UNKNOWN = 'Outcome unknown. Read the agents again before trying; nothing was retried.';
// The same text bus.js shows for an instances.set answer (bus.js RESTART_TEXT, NO_RESTART_TEXT).
const RESTART_TEXT = 'Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
// The same text bus.js shows for an instances.set answer (bus.js restartText(), NO_RESTART_TEXT).
// F3: on an organization Console its owner restarts the bus from the receipt (console-restart.js).
const restartText = () => window.MosaicConsole?.busRestart
? 'Restart needed: the running bus keeps the roles it loaded at start. Restart the bus to load the change.'
: 'Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
const busControl = text => (String(text).includes('Restart needed:') ? window.MosaicConsole?.busButton('Restart bus') ?? '' : '');
const NO_RESTART_TEXT = 'No restart: the launcher reads the change at its next launch check.';
const DONE = { launch: 'Launched', regenerate: 'Regenerated', end: 'Terminated', delete: 'Deleted' };
let ctx = null, view = null, busy = false, receipt = '', confirming = null, pending = null, generation = 0;
@@ -30,7 +34,7 @@
const { txt, esc } = c, title = '<h2 id="agents-title" class="sr-only">Agents</h2>';
if (!v) return `<section class="panel" id="agents-section">${title}<p class="small warn">The agents could not be read, so there is nothing to act on. Read again to retry.</p></section>`;
const open=usable(), off=!open||busy||pending?' disabled':'', addOff=!open||busy||pending||c.instanceBusyOps.includes('add')?' disabled':'', changeOff=!open||busy||pending||c.instanceBusyOps.includes('change')?' disabled':'', defs=Object.keys(v.definitions||{}), accepted=defs.filter(d=>v.definitions[d]?.accepted);
const instanceStatus=op=>{const text=c.instanceReceipts[op]||'', current=c.instanceReceiptOp===op||(!c.instanceReceiptOp&&op==='add');return `<p role="status" data-instance-receipt="${op}"${current?' id="instances-receipt"':''} class="${text.startsWith('Outcome unknown')?'banner ref':text.includes('Draft kept')?'banner err':text.startsWith('Added')||text.startsWith('Changed')?'settings-success':''}">${txt(text)}</p>`;};
const instanceStatus=op=>{const text=c.instanceReceipts[op]||'', current=c.instanceReceiptOp===op||(!c.instanceReceiptOp&&op==='add');return `<p role="status" data-instance-receipt="${op}"${current?' id="instances-receipt"':''} class="${text.startsWith('Outcome unknown')?'banner ref':text.includes('Draft kept')?'banner err':text.startsWith('Added')||text.startsWith('Changed')?'settings-success':''}">${txt(text)}</p>${busControl(text)}`;};
const option=(name,selected,first)=>`<option value="">${first}</option>${accepted.map(d=>`<option value="${esc(d)}"${d===selected?' selected':''}>${txt(d)}</option>`).join('')}`;
const button=(action,name,label)=>`<button type="button"${['keep','unkeep'].includes(action)?' role="menuitem"':''} data-agent-action="${esc(action)}" data-agent-name="${esc(name)}"${off}>${txt(label)}</button>`;
const rows=Object.entries(v.instances).map(([name,e])=>{
@@ -45,7 +49,7 @@
const noHire=Array.isArray(v.noHire)?v.noHire:[], retired=Array.isArray(v.retired)?v.retired:[];
const hiring=!c.isTester?`<section class="panel"><h3>Hiring</h3><p>Turn hiring off to stop new agents of a definition.</p><div class="hiring-grid">${defs.map(n=>`<div>${open?`<button type="button" role="switch" aria-label="Hiring allowed for ${esc(n)}" aria-checked="${!noHire.includes(n)}" data-agent-action="${noHire.includes(n)?'hire':'nohire'}" data-agent-name="${esc(n)}"${off}><span class="switch-track"></span></button>`:`<span>${noHire.includes(n)?'Off':'On'}</span>`}<span class="hiring-name">${txt(n)}</span></div>`).join('')}</div>${retired.length?`<p>Retired names, never reused: ${retired.map(txt).join(', ')}.</p>`:''}</section>`:'';
const state=pending?`<p id="agents-pending">Request <code>${txt(pending.request)}</code> to ${txt(pending.action)} ${txt(pending.instance)}: ${txt(pending.state)}.</p>`:'';
return `<div id="agents-section">${title}${state}<section class="panel"><div class="table-wrap" role="region" aria-label="Agents"><table id="agents-table"><thead><tr><th id="agent-col-name" scope="col">Agent</th><th id="agent-col-definition" scope="col">Definition</th><th id="agent-col-launch" scope="col">Launch</th><th id="agent-col-kept" scope="col">Kept</th><th id="agent-col-running" scope="col">Running</th><th scope="col">Actions</th></tr></thead><tbody>${rows}</tbody></table></div><p role="status" id="agents-receipt">${txt(receipt)}</p>${!open||c.isTester?instanceStatus('add'):''}</section>${add}${hiring}<dialog id="agent-dialog" aria-labelledby="agent-dialog-title"><h2 id="agent-dialog-title"></h2><p id="agent-dialog-copy"></p><label id="agent-reason-label" hidden>Reason <input id="agent-dialog-reason" maxlength="500"></label><div><button type="button" id="agent-dialog-confirm">Confirm</button><button type="button" id="agent-dialog-cancel">Cancel</button></div></dialog></div>`;
return `<div id="agents-section">${title}${state}<section class="panel"><div class="table-wrap" role="region" aria-label="Agents"><table id="agents-table"><thead><tr><th id="agent-col-name" scope="col">Agent</th><th id="agent-col-definition" scope="col">Definition</th><th id="agent-col-launch" scope="col">Launch</th><th id="agent-col-kept" scope="col">Kept</th><th id="agent-col-running" scope="col">Running</th><th scope="col">Actions</th></tr></thead><tbody>${rows}</tbody></table></div><p role="status" id="agents-receipt">${txt(receipt)}</p>${busControl(receipt)}${!open||c.isTester?instanceStatus('add'):''}</section>${add}${hiring}<dialog id="agent-dialog" aria-labelledby="agent-dialog-title"><h2 id="agent-dialog-title"></h2><p id="agent-dialog-copy"></p><label id="agent-reason-label" hidden>Reason <input id="agent-dialog-reason" maxlength="500"></label><div><button type="button" id="agent-dialog-confirm">Confirm</button><button type="button" id="agent-dialog-cancel">Cancel</button></div></dialog></div>`;
}
// One POST. `null` means the page signed out; otherwise the status and the parsed answer.
@@ -79,7 +83,7 @@
const { ok, result } = answer;
if (!ok) { receipt = refused(result); redraw(); return; }
// Row 194 b6 (webby-0094 B1): a veto says whether the reload took, as a create does.
if (url === '/api/veto') { receipt = `${{ keep: 'Kept', unkeep: 'Unkept', nohire: 'No hire for', hire: 'Hiring allowed for' }[action]} ${name}. ${result.restart ? RESTART_TEXT : NO_RESTART_TEXT}`; reread(); return; }
if (url === '/api/veto') { receipt = `${{ keep: 'Kept', unkeep: 'Unkept', nohire: 'No hire for', hire: 'Hiring allowed for' }[action]} ${name}. ${result.restart ? restartText() : NO_RESTART_TEXT}`; reread(); return; }
if (!REQUEST_ID.test(result?.request ?? '')) { receipt = UNKNOWN; redraw(); return; }
if (action === 'end') delete reasons[name];
pending = { request: result.request, action, instance: name, state: 'open', tries: 0 };
@@ -122,7 +126,7 @@
try { answer = await post('/api/instances', { revision: view.revision, instance, op: 'add', definition }); } catch { answer = { ok: false, result: { sent: 'unknown' } }; }
busy = false;
if (answer === null) return;
if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}. ${answer.result.restart ? RESTART_TEXT : NO_RESTART_TEXT}`; reread(); }
if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}. ${answer.result.restart ? restartText() : NO_RESTART_TEXT}`; reread(); }
else { receipt = answer.ok ? UNKNOWN : `${refused(answer.result)} Draft kept.`; redraw(); }
}
@@ -151,5 +155,78 @@
// A sign-out stops any poll; nothing that was asked is retried.
document.addEventListener('mosaic-signed-out', () => { generation++; pending = null; confirming = null; });
window.MosaicAgents = Object.freeze({ section });
// F3: restart an agent so a route change reaches it now, offered by the Routing receipt. One
// terminate; then reads of the instances until the run has left (up to the 60 s regenerate
// allows); then one launch. A thread definition is not launched: its next message starts it.
// A refusal, a sign-out or an unknown outcome stops the chain and says so; nothing is retried.
const restarts = Object.create(null);
const RESTART_WAIT = 30;
function restartMarkup(name, thread = false) {
const r = restarts[name];
return `<span class="agent-restart"><button type="button" class="btn" data-agent-restart="${esc(name)}" data-agent-thread="${thread ? 'yes' : 'no'}"${r?.busy ? ' disabled' : ''}>${r?.busy ? 'Restarting…' : `Restart ${esc(name)} now`}</button> <span role="status" data-agent-restart-status="${esc(name)}">${esc(r?.text ?? '')}</span></span>`;
}
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const paintRestart = name => document.querySelectorAll('.agent-restart').forEach(el => {
const b = el.querySelector('[data-agent-restart]');
if (b?.dataset.agentRestart === name) el.outerHTML = restartMarkup(name, b.dataset.agentThread === 'yes');
});
async function send(url, payload) {
let res, result = null;
try { res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), cache: 'no-store', redirect: 'manual' }); }
catch { return { unknown: true }; }
if (res.type === 'opaqueredirect') return { signedOut: true };
try { result = await res.json(); } catch { return { unknown: true }; }
return { ok: res.ok, result };
}
// The request's closing read, or a reason the chain stops.
async function closing(request) {
for (let tries = 0; tries < POLL_LIMIT; tries++) {
await new Promise(r => setTimeout(r, POLL_MS));
let res, r = null;
try { res = await fetch(`/api/agent/request?id=${encodeURIComponent(request)}`, { cache: 'no-store', redirect: 'manual' }); } catch { continue; }
if (res.type === 'opaqueredirect') return { stop: 'Sign in again, then read the agents.' };
try { r = await res.json(); } catch { continue; }
if (res.ok && r?.request === request && r.state === 'closed') return { done: r };
}
return { stop: `Request ${request} is still open. Read the agents again later.` };
}
async function runs(name) {
try {
const res = await fetch('/api/bus/instances', { cache: 'no-store', redirect: 'manual' });
if (!res.ok || res.type === 'opaqueredirect') return null;
const e = (await res.json())?.rows?.[0]?.instances?.[name];
return e ? (Array.isArray(e.running) ? e.running.length : 0) : 0;
} catch { return null; }
}
async function restartAgent(name, thread) {
const r = restarts[name] = { busy: true, text: `Terminating ${name}…` };
const stop = text => { r.busy = false; r.text = text; paintRestart(name); };
paintRestart(name);
const asked = await send('/api/agent/terminate', { instance: name, reason: 'route change' });
if (asked.signedOut) return stop('Sign in again, then retry.');
if (asked.unknown || !asked.ok || !REQUEST_ID.test(asked.result?.request ?? '')) return stop(asked.ok || asked.unknown ? UNKNOWN : refused(asked.result));
const ended = await closing(asked.result.request);
if (ended.stop) return stop(ended.stop);
if (ended.done.reason) return stop(`${name}: ${ended.done.reason}. It was not restarted.`);
r.text = `Terminated ${name}. Waiting for its session to end…`; paintRestart(name);
let left = false;
for (let i = 0; i < RESTART_WAIT && !left; i++) { await new Promise(res => setTimeout(res, POLL_MS)); left = (await runs(name)) === 0; }
if (!left) return stop(`${name} was asked to end but its session is still running. Launch it from Agents once it has ended.`);
if (thread) return stop(`Terminated ${name} at ${new Date().toLocaleTimeString()}. Its next message starts it on the new route.`);
r.text = `Launching ${name}…`; paintRestart(name);
const launch = await send('/api/agent/launch', { instance: name });
if (launch.signedOut) return stop('Sign in again. It was terminated and not launched.');
if (launch.unknown || !launch.ok || !REQUEST_ID.test(launch.result?.request ?? '')) return stop(launch.ok || launch.unknown ? `${UNKNOWN} It was terminated.` : `${refused(launch.result)} It was terminated and not launched.`);
const started = await closing(launch.result.request);
if (started.stop) return stop(started.stop);
if (started.done.reason) return stop(`${name}: ${started.done.reason}. It was terminated and not launched.`);
return stop(`Restarted ${name} on the new route${started.done.done?.run ? ` (run ${started.done.done.run})` : ''}.`);
}
document.addEventListener('click', e => {
const b = e.target.closest?.('[data-agent-restart]');
if (!b || b.disabled || restarts[b.dataset.agentRestart]?.busy) return;
restartAgent(b.dataset.agentRestart, b.dataset.agentThread === 'yes');
});
window.MosaicAgents = Object.freeze({ section, restartMarkup, isThread: definition => threadDefinitions.has(definition) });
})();
+7 -4
View File
@@ -486,16 +486,19 @@
// good load, from the status `mosaic bus start` left. textContent only. Nothing here retries.
async function busDown() {
const box = $('bus-down');
// E2E5 review P2: the organization list is read first and on its own, so a failed bus-status read
// (likely when the bus is down) still refreshes the list, its banner and the Settings Start control.
if (window.MosaicOrg) {
const org = (await window.MosaicOrg.readList()).find(row => row.id === window.MosaicOrg.id);
if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; window.MosaicConsole?.attachBus(box, 'Restart the bus'); return; }
}
try {
const s = await api('/api/bus-status');
if (window.MosaicOrg) {
const org = (await window.MosaicOrg.readList()).find(row => row.id === window.MosaicOrg.id);
if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; return; }
}
if (!s.down) { box.hidden = true; return; }
const b = s.boot;
box.replaceChildren(node('b', '', 'Bus down: organization file invalid. '), node('span', '', `${b.reason} `), node('span', 'small muted', `File ${b.file}. Refused at ${b.at}. Last good load: ${b.lastGood ?? 'none'}. Fix the file, then restart the bus.`));
box.hidden = false;
window.MosaicConsole?.attachBus(box, 'Restart the bus');
} catch { /* no status to show: leave the banner as it was */ }
}
async function refresh() {
+59 -16
View File
@@ -319,7 +319,7 @@
});
let configRead = Promise.resolve();
let canSend = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
let canSend = false, canMessage = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
const REPLIES = 50; // the broker's cap
function repliesHtml() {
if (replies === null) return '<p class="small warn">The replies to it could not be read. Read again to retry; <code>mosaic inbox</code> in a terminal lists them.</p>';
@@ -344,7 +344,7 @@
<button class="btn primary" type="submit" data-send aria-disabled="${sending}">${sending ? 'Sending…' : 'Send'}</button></form>${said}</section>`;
}
async function composer(get) {
if (!canSend) return '<p class="small muted">Messaging a role needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.</p>';
if (!canMessage) return '<p class="small muted">Messaging a role needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.</p>';
try { sendRoles = (await get('agents')).map(c => c.role).filter(r => typeof r === 'string' && SAFE.test(r)); } catch { sendRoles = null; }
if (receipt?.kind === 'sent') {
const r = receipt;
@@ -612,8 +612,8 @@
let heldOrganizations = { empty: false, invalid: [], error: null };
function heldTable(claims) {
const cols = ['Role', 'State', 'Attention', 'Address', 'Holder', 'Held since'];
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${c.address ? txt(c.address) : '<span class="muted">no address</span>'}</td><td>${txt(c.role || ('a session '+short(c.holder_run)))}${c.harness ? `<span class="source">${txt(c.harness)}</span>` : ''}</td><td>${when(c.at)}</td>${canSend ? `<td>${typeof c.role === 'string' && SAFE.test(c.role) ? `<button type="button" class="btn" data-message-role="${esc(c.role)}" aria-label="Message ${esc(c.role)}">Message</button>` : ''}</td>` : ''}</tr>`).join('');
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles on the board, scroll for all columns"><table class="s1-table s1-agents s1-held"><thead><tr>${cols.map(h => `<th scope="col">${h}</th>`).join('')}${canSend ? '<th scope="col"><span class="sr-only">Message</span></th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${c.address ? txt(c.address) : '<span class="muted">no address</span>'}</td><td>${txt(c.role || ('a session '+short(c.holder_run)))}${c.harness ? `<span class="source">${txt(c.harness)}</span>` : ''}</td><td>${when(c.at)}</td>${canMessage ? `<td>${typeof c.role === 'string' && SAFE.test(c.role) ? `<button type="button" class="btn" data-message-role="${esc(c.role)}" aria-label="Message ${esc(c.role)}">Message</button>` : ''}</td>` : ''}</tr>`).join('');
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles on the board, scroll for all columns"><table class="s1-table s1-agents s1-held"><thead><tr>${cols.map(h => `<th scope="col">${h}</th>`).join('')}${canMessage ? '<th scope="col"><span class="sr-only">Message</span></th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
}
function paintHeld(err, noOrganizations, invalidOrganizations, organizationError) {
heldErr = err;
@@ -982,7 +982,8 @@
// offered: binding credentials stays a hand edit. When a change needs a bus restart the receipt
// says so; the Console never restarts the bus. Drafts survive polling and refusals; nothing retries.
let instanceView=null, instanceDrafts={add:null,change:null}, instanceBusyOps=new Set(), instanceReceipt='', instanceReceiptOp=null, instanceReceipts=Object.create(null);
const RESTART_TEXT='Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
// F3: an organization Console's owner restarts the bus from the receipt (console-restart.js).
const restartText=()=>window.MosaicConsole?.busRestart?'Restart needed: the running bus keeps the roles it loaded at start. Restart the bus to load the change.':'Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
const NO_RESTART_TEXT='No restart: the launcher reads the change at its next launch check.';
function instancesSection(view){
const title='<h2 id="instances-title">Role instances</h2>';
@@ -1024,7 +1025,7 @@
try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;instanceReceipts[op]=instanceReceipt;render(false);return;}
instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false);
try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?RESTART_TEXT:NO_RESTART_TEXT}`;}
if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?restartText():NO_RESTART_TEXT}`;}
else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}}
catch{instanceReceipt=unknownInstance;}finally{instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);}
});
@@ -1035,6 +1036,21 @@
// choosing none for all three. Save sends the changed rows through routes.set. Drafts survive
// polling and refusals; nothing retries.
let routesView=null, routesDraft=null, routesBusy=false, routesReceipt='', routesEditVersion=0;
// F3: after a save, how the change reaches each saved agent: a session keeps the route it started
// with, so a running one moves when it ends (a thread when it goes idle) or when the owner
// restarts it here (agents.js restartMarkup). Read from the instances after the save.
let routesApply=null;
async function routesApplyRead(names){
let view=null;
try{const res=await fetch('/api/bus/instances',{cache:'no-store',redirect:'manual'});if(res.ok&&res.type!=='opaqueredirect')view=(await res.json())?.rows?.[0]??null;}catch{/* said below */}
return names.map(name=>{const e=view?.instances?.[name];
if(!view||!e)return {name,text:'Could not read whether it is running. A running session keeps its old route until it ends.'};
const running=Array.isArray(e.running)?e.running.length:0, thread=!!window.MosaicAgents?.isThread(e.definition);
if(e.launcher)return {name,text:'The launcher. It takes the new route at its next start.'};
if(!running)return {name,text:'Not running. Its next start uses the new route.'};
return {name,thread,restart:true,text:thread?'Running. Its session keeps the old route until the thread goes idle; the next message after that starts it on the new route. Restart it to move it now.':'Running. Its session keeps the old route until it ends. Restart it to move it now.'};});
}
const routesApplyMarkup=()=>routesApply?.length?`<ul class="routes-apply" aria-label="How the change reaches each agent">${routesApply.map(a=>`<li><b>${txt(a.name)}</b>: ${txt(a.text)}${a.restart&&canSend&&!isTester?` ${window.MosaicAgents?.restartMarkup(a.name,a.thread)??''}`:''}</li>`).join('')}</ul>`:'';
let harnessSettings=null, harnessDraft=null, harnessLoading=false, harnessReceipt='', harnessBusy=false;
function readHarnesses(){
if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true;
@@ -1070,7 +1086,7 @@
const providerControl=canSend&&!isTester?`<button type="button" id="provider-switch" data-enabled="${v.catalogue?.status!=='disabled'}">${v.catalogue?.status!=='disabled'?'Disable':'Enable'} model proxy catalogue</button><p role="status" id="provider-switch-status"></p>`:'';
const harnessControl=canSend&&!isTester?`<fieldset class="harness-control"><legend>Harnesses</legend>${harnessSettings ? [...new Set([...harnessSettings.available.harness,...(harnessSettings.plugins.harness??[])])].map(name=>`<label><input type="checkbox" data-harness="${esc(name)}"${(harnessDraft??harnessSettings.plugins.harness??[]).includes(name)?' checked':''}${harnessBusy?' disabled':''}> ${txt(name)}</label>`).join('')+` <button type="button" id="harness-save"${harnessBusy?' disabled':''}>Save harnesses</button>` : '<p>Harnesses unavailable.</p><button type="button" id="harness-read">Read harnesses again</button>'}<p role="status" id="harness-status">${txt(harnessReceipt)}</p></fieldset>`:'';
const off=v.harnesses.length?'':'<p class="small warn">No harness is switched on in the business file\'s plugins.harness, so every launch refuses harness-off and a save that changes a harness refuses too. Switch one on under Harnesses.</p>';
return `<section class="panel routing-panel" aria-labelledby="routes-title"><span class="sr-only">${title}</span>${catalogueInfo}${providerControl}${harnessControl}${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col" rowspan="2">Agent</th><th scope="colgroup" colspan="3">Primary</th><th scope="colgroup" colspan="3">Fallback</th></tr><tr>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l.replace('Fallback ','')}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><div class="settings-actions route-actions${routesDraft?' has-draft':''}"><button type="submit"${disabled}>${routesBusy?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save routes'}</button> <button type="button" id="routes-reload"${routesDraft?'':' hidden'}${routesBusy||!routesDraft?' disabled':''}>Discard draft</button><p role="status" id="routes-receipt" class="${routesReceipt.startsWith('Outcome unknown')?'banner ref':routesReceipt.includes('Draft kept')?'banner err':routesReceipt.includes('saved')?'settings-success':''}">${txt(routesReceipt)}</p></div></form></section>`;
return `<section class="panel routing-panel" aria-labelledby="routes-title"><span class="sr-only">${title}</span>${catalogueInfo}${providerControl}${harnessControl}${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col" rowspan="2">Agent</th><th scope="colgroup" colspan="3">Primary</th><th scope="colgroup" colspan="3">Fallback</th></tr><tr>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l.replace('Fallback ','')}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><div class="settings-actions route-actions${routesDraft?' has-draft':''}"><button type="submit"${disabled}>${routesBusy?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save routes'}</button> <button type="button" id="routes-reload"${routesDraft?'':' hidden'}${routesBusy||!routesDraft?' disabled':''}>Discard draft</button><p role="status" id="routes-receipt" class="${routesReceipt.startsWith('Outcome unknown')?'banner ref':routesReceipt.includes('Draft kept')?'banner err':routesReceipt.includes('saved')?'settings-success':''}">${txt(routesReceipt)}</p></div>${routesApplyMarkup()}</form></section>`;
}
function routesFields(form){const draft=Object.fromEntries(Object.keys(routesView.instances).map(name=>[name,routeCells(routesView.instances[name])]));for(const el of form.querySelectorAll('[data-route]')){const [name,key]=JSON.parse(el.dataset.route);if(draft[name])draft[name][key]=el.value;}return draft;}
// The changed rows only. A fallback with all three cells empty is cleared; one with some set goes
@@ -1112,13 +1128,13 @@
}catch(error){status.textContent=error.message;button.disabled=false;}
});
document.addEventListener('click',e=>{if(e.target.matches?.('.fallback-toggle'))e.target.closest('tr')?.classList.add('fallback-open');});
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';last.delete('routes');render(true);});
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';routesApply=null;last.delete('routes');render(true);});
document.addEventListener('submit',async e=>{
if(e.target.id!=='routes-form')return;e.preventDefault();if(!(canSend || isTester)||out()||routesBusy||!routesView)return;
routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;}
routesBusy=true;routesReceipt='';render(false);
routesBusy=true;routesReceipt='';routesApply=null;render(false);
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;}
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;routesApply=await routesApplyRead(Object.keys(payload.routes));}
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'} Draft kept.`;}}
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
});
@@ -1148,8 +1164,8 @@
});
});
// Organizations is writable when the server allows org setup, which it does in organization mode where send is off.
const readonlyPage = page => !['appearance','business'].includes(page) && !(page === 'organizations' && orgSetup) && !canSend && !isTester;
const settingsPages = [['appearance','Appearance'],['business',window.MosaicOrg ? 'Organization' : 'Business'],['limits','Launch limits'],['organizations','Organizations'],['routing','Routing'],['agents','Agents'],['assistant','Assistant'],['tokens','Token use'],['model-proxy','Model proxy'],['about','About']];
const readonlyPage = page => !['appearance','business','console'].includes(page) && !(page === 'organizations' && orgSetup) && !canSend && !isTester;
const settingsPages = [['appearance','Appearance'],['business',window.MosaicOrg ? 'Organization' : 'Business'],['limits','Launch limits'],['organizations','Organizations'],['routing','Routing'],['agents','Agents'],['assistant','Assistant'],['tokens','Token use'],['model-proxy','Model proxy'],['console','Console'],['about','About']];
function markSettingsDraft(page,dirty){const link=document.querySelector(`#settings-nav a[href="#/settings/${page}"]`), dot='<span class="settings-dot"><span class="sr-only">unsaved changes</span></span>';if(link && dirty && !link.querySelector('.settings-dot'))link.insertAdjacentHTML('beforeend',dot);const button=$('settings-disclosure');if(dirty && link?.getAttribute('aria-current')==='page' && button && !button.querySelector('.settings-dot'))button.querySelector('strong').insertAdjacentHTML('beforeend',dot);}
function settingsShell(page, body, helper='') {
const pages=isTester ? [['appearance','Appearance'],['business','Business'],['routing','Routing'],['agents','Agents'],['names','Seat names']] : settingsPages;
@@ -1164,6 +1180,8 @@
if (page==='appearance') return settingsShell(page,appearance(),'These choices apply in this browser only.');
if (page==='organizations') { await configRead; return settingsShell(page,window.MosaicOrgCreate.markup,'Create an organization from the template, edit it, and finish its setup. Each step names the command it equals.'); }
if (page==='model-proxy') { await configRead; return settingsShell(page,window.MosaicProxy.markup,'Accounts used by this instance, their priority, and the client keys assigned to agents.'); }
// F3: Settings > Console (console-restart.js). The server checks the owner, Origin and trail.
if (page==='console') { await configRead; await window.MosaicConsole?.ready; return settingsShell(page,window.MosaicConsole?.settingsMarkup()??'<section class="panel"><p class="small warn">The Console controls did not load. Reload the page to try again.</p></section>','Restart this Console on the same address and configuration.'); }
const s = await get.api('settings'), n = s.notifier || {};
let usage, names = null, instances = null, routes = null, limitRows = [], limitsRefused = false;
if (page==='tokens') { try { [usage] = await get('usage'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; } }
@@ -1470,6 +1488,25 @@
if (path === '/business') { location.replace('#/settings/business'); return; }
if (path === '/settings/display') { location.replace('#/settings/appearance'); return; }
if (chatPath === '/chat' || chatPath.startsWith('/chat/') || /^\/projects\/[0-9a-f-]+\/(?:settings|conversations|tasks|requirements|files)$/i.test(chatPath)) {
// Re-read the scoped capability as the Console and bus recover. A failed read
// withdraws the control; the chat renderer retains the draft across repaint.
if (window.MosaicOrg && !isTester) {
try {
const response = await fetch('/api/config', { cache: 'no-store', redirect: 'manual' });
if (!current()) return;
if (response.type === 'opaqueredirect') { signOut(); return; }
if (response.status === 401) { signOutSso(); return; }
const config = response.ok ? await response.json() : null;
if (!current()) return;
if (response.ok) window.MosaicIcons.succeeded(); else window.MosaicIcons.failed();
chatSend = config?.chatSend === true;
window.MosaicOrgBusStopped = config?.devOwner === true && config?.orgSetup === true && config?.chatSend === false;
} catch {
if (!current()) return;
window.MosaicIcons.failed(); chatSend = false; window.MosaicOrgBusStopped = false;
}
if (!current()) return;
}
// Row 177 (#1675): the tester Console writes to its own threads.
const focusedHeading = document.activeElement === $('bus-view').querySelector('h1');
await window.MosaicChat.render({ path: chatPath, container: $('bus-view'), canSend: (window.MosaicOrg ? chatSend : canSend) || isTester, tester: isTester, navigated });
@@ -1696,10 +1733,14 @@
});
// Whether this server takes a send here. The form is drawn on the next Inbox render; the Board's
// Message buttons are drawn from the roles already read.
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => {
// F3: `settingsSend` lets the owner of an organization Console save Settings on the selected
// organization's bus; messaging a role still follows `send` (canMessage) and `chatSend`.
// A bus restart reads the config again and sends it here as a mosaic-config event.
const applyConfig = c => {
isTester = c?.tester === true;
canEdit = !isTester && c?.edit === true;
canSend = !isTester && c?.send === true;
canSend = !isTester && (c?.send === true || c?.settingsSend === true);
canMessage = !isTester && c?.send === true;
chatSend = !isTester && c?.chatSend === true;
orgSetup = !isTester && c?.orgSetup === true;
window.MosaicOrgBusStopped = !!window.MosaicOrg && c?.devOwner === true && orgSetup && c?.chatSend === false;
@@ -1707,7 +1748,7 @@
if (orgSetup && !$('org-new-link')) { const a = document.createElement('a'); a.id = 'org-new-link'; a.href = NEW_ORG; a.setAttribute('role', 'menuitem'); a.textContent = 'New organization'; document.querySelector('#avatar-menu > a[href="#/settings"]')?.after(a); }
ssoOn = c?.sso === true;
canDecide = !isTester && c?.decide === true;
if (isTester) {
if (isTester && !$('tester-banner')) {
const banner = document.createElement('div'); banner.id = 'tester-banner'; banner.className = 'banner ref';
banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.';
$('main').prepend(banner);
@@ -1715,7 +1756,9 @@
if (!canSend && !isTester && !ssoOn) return;
const path = routeOf().split('?')[0];
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
}).catch(() => {});
};
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {});
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') applyConfig(e.detail); });
// This context comes from the conversations renderer after its authorized read.
let projectContext = null;
document.addEventListener('mosaic-project-context', event => {
+3
View File
@@ -75,3 +75,6 @@
.chat-room-picker label { display: block; padding-block: .45rem; }
.chat-audience { margin: .25rem 0; }
.chat-projects .panel { border-radius: 4px; }
#chat-send-form textarea:disabled, #chat-room-send-form textarea:disabled { opacity: .55; cursor: not-allowed; }
#chat-send-form button:disabled, #chat-room-send-form button:disabled { opacity: .5; cursor: not-allowed; }
+3 -3
View File
@@ -319,7 +319,7 @@
}
const roomName = instance => current.members?.find(m => m.instance === instance)?.name || instance;
function roomOpenControl(project) {
if (window.MosaicOrg) return '<p>Room changes are unavailable.</p>';
if (window.MosaicOrg) return '<p>Room changes are unavailable on this Console.</p>';
if (!roomOwner()) return '';
if (current.projects.some(p => p.id === project && p.archived)) return `<p><a href="${roomPath(esc(project))}">Read room</a></p>`;
const f = form('room-open:' + project);
@@ -417,7 +417,7 @@
if (s.receiptIds?.every(id => s.rows.some(m => m.ids.includes(id)))) { s.receipt = 'Message shown in the room.'; s.receiptIds = null; }
} catch (err) {
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
current.error = roomError(err);
current.error = window.MosaicOrg && err.code === 'room-not-open' ? 'Room changes are unavailable on this Console.' : roomError(err);
current.readFailed = true;
if (err.code === 'channel-disabled') current.channelDisabled = true;
if (err.status === 403 || err.status === 404) { s.rows = []; current.members = []; current.projects = []; }
@@ -630,7 +630,7 @@
} else {
html += heading('Projects') + '<p>Projects and their conversations.</p><div class="chat-projects">' + c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
}
if (!c.canSend) html += window.MosaicOrgBusStopped ? '<p class="muted">The bus is stopped. Use Start the bus to send messages.</p>' : '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
if (!c.canSend) html += window.MosaicOrgBusStopped ? '<p class="muted">The bus is stopped. Use Start the bus on Settings → Organizations to send messages.</p>' : '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
html += '</div>';
// Drafts already live in the focused textarea; their changing text is not a
// reason for a background read to replace the control.
@@ -0,0 +1,172 @@
/* F3: restart the Console from Settings, restart an organization's bus, and bring an open page
back after any Console restart. The controls are the owner's: the server checks the human, the
exact Origin and Fetch Metadata, and writes the action trail. This file only asks and reports.
Reconnect: /api/config carries `boot`, an id the Console picks at each start. When a same-origin
request fails at the network level, or the owner asks for a restart, the page polls /api/config.
The same boot means the Console never went away, and the banner goes. A new boot means a new
Console process: the page reloads itself, so its scripts, icons and session match the new one.
A page with an unsaved draft does not reload; it says the Console is back and keeps the draft. */
(() => {
'use strict';
const nativeFetch = window.fetch.bind(window);
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const sleep = ms => new Promise(r => setTimeout(r, ms));
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
let config = null, boot = null, reconnecting = false, planned = false;
let restartState = 'idle', restartReceipt = '', busReceipt = '', busBusy = false;
const readConfig = () => nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })
.then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null));
const ready = readConfig().then(c => { config = c; boot = typeof c?.boot === 'string' ? c.boot : null; return c; }).catch(() => null);
const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.()
|| document.querySelector('.settings-dot')
|| [...document.querySelectorAll('#reply, #conv-reply, #doc-source, #send-body')].some(el => !!el.value?.trim()));
function banner(text, action = '') {
let box = $('console-reconnect');
if (!box) {
box = document.createElement('div'); box.id = 'console-reconnect'; box.className = 'banner ref console-reconnect';
box.setAttribute('role', 'status'); box.setAttribute('aria-live', 'polite');
($('main') ?? document.body).prepend(box);
}
box.innerHTML = `<span>${esc(text)}</span>${action}`;
box.hidden = false;
}
const hideBanner = () => { const box = $('console-reconnect'); if (box) box.hidden = true; };
// One poll loop at a time. It ends when the Console answers; it never sends anything but a read.
async function reconnect(reason) {
if (reconnecting) return;
reconnecting = true;
const started = Date.now();
banner(reason === 'restart' ? 'Console restarting. This page reconnects when it is back.' : 'The Console is not answering. Reconnecting…');
for (;;) {
const waited = Date.now() - started;
await sleep(waited < 120000 ? 1000 : 5000);
if (waited >= 120000) banner('The Console has not come back after two minutes. Check the terminal or service that started it. This page keeps trying.');
let c;
try {
const r = await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' });
// A sign-in redirect: the new Console wants a sign-in, which the reload shows.
if (r.type === 'opaqueredirect') { location.reload(); return; }
if (!r.ok) continue;
c = await r.json();
} catch { continue; }
// The process that took the restart can still answer before it closes.
if (boot !== null && c?.boot === boot) { if (planned) continue; reconnecting = false; hideBanner(); return; }
reconnecting = false; planned = false;
if (dirty()) {
boot = typeof c?.boot === 'string' ? c.boot : boot; config = c;
banner('The Console is back. Your unsaved draft is kept on this page.', ' <button type="button" class="btn" id="console-reconnect-reload">Reload now</button>');
return;
}
location.reload();
return;
}
}
document.addEventListener('click', e => { if (e.target.id === 'console-reconnect-reload') location.reload(); });
// A same-origin request that fails at the network level (the Console went away) starts the poll.
// An aborted request is an AbortError, not a TypeError, and starts nothing.
const sameOrigin = input => { try { return new URL(typeof input === 'string' ? input : input.url, location.href).origin === location.origin; } catch { return false; } };
window.fetch = (input, init) => nativeFetch(input, init).catch(err => {
if (err?.name === 'TypeError' && sameOrigin(input) && boot !== null) reconnect('lost');
throw err;
});
// Settings > Console.
function settingsMarkup() {
if (!config) return '<section class="panel"><p class="small warn">The Console settings could not be read. Reload the page to try again.</p></section>';
if (config.tester === true || config.user === true) return '<section class="panel"><p>Restarting the Console is the owner\'s.</p></section>';
const signOut = config.sso === true && config.devOwner !== true ? ' Sign-in sessions are held in memory, so everyone signs in again after it.' : '';
let body;
if (config.consoleRestart === true) {
const busy = restartState !== 'idle' ? ' disabled' : '';
const confirm = restartState === 'confirm'
? '<div class="settings-actions" id="console-restart-confirm"><p>Restart the Console now?</p><button type="button" id="console-restart-go">Restart now</button> <button type="button" id="console-restart-cancel">Cancel</button></div>' : '';
body = `<p>The Console stops, and <code>mosaic console start</code> starts it again on the same address, arguments and configuration. Organization buses are separate processes and keep running. Open pages show the restart and reconnect when it is back.${signOut}</p><p class="small muted">Each restart is recorded in the Console's action trail with your identity.</p><div class="settings-actions"><button type="button" id="console-restart"${busy}${restartState === 'confirm' ? ' hidden' : ''}>Restart Console</button></div>${confirm}`;
} else if (config.orgSetup !== true && config.send !== true) {
body = '<p>This Console is read-only, so it does not restart itself. Restart it where it was started.</p>';
} else {
body = '<p>This Console was not started in the foreground by <code>mosaic console start</code>, so nothing would start it again. Restart it where it was started: in its terminal, or with <code>systemctl --user restart</code> and its unit.</p>';
}
return `<section class="panel" aria-labelledby="console-restart-title"><h3 id="console-restart-title">Restart the Console</h3>${body}<p role="status" id="console-restart-receipt">${esc(restartReceipt)}</p></section>`;
}
const repaint = () => { const panel = $('console-restart-title')?.closest('section'); if (panel) panel.outerHTML = settingsMarkup(); };
async function restartConsole() {
restartState = 'sending'; restartReceipt = 'Asking the Console to restart…'; repaint();
let response, answer = null;
try { response = await nativeFetch('/api/console/restart', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' }); }
catch { restartState = 'idle'; restartReceipt = 'Outcome unknown. If the Console restarted, this page reconnects by itself.'; repaint(); reconnect('lost'); return; }
try { answer = await response.json(); } catch { /* an unreadable answer is refused below */ }
if (response.status === 202 && answer?.outcome === 'restarting') {
planned = true; if (typeof answer.boot === 'string') boot = answer.boot;
restartState = 'idle'; restartReceipt = `Restart requested at ${clock()}.`; repaint();
reconnect('restart');
return;
}
restartState = 'idle';
restartReceipt = response.type === 'opaqueredirect' ? 'Sign in again, then retry.' : (answer?.message || 'The restart was refused. Nothing was retried.');
repaint();
}
document.addEventListener('click', e => {
if (e.target.id === 'console-restart' && config?.consoleRestart === true) { restartState = 'confirm'; restartReceipt = ''; repaint(); $('console-restart-go')?.focus(); }
else if (e.target.id === 'console-restart-cancel') { restartState = 'idle'; repaint(); $('console-restart')?.focus(); }
else if (e.target.id === 'console-restart-go' && restartState === 'confirm') restartConsole();
});
// The org bus restart control, offered on the organization's own pages to its owner.
const busAllowed = () => !!window.MosaicOrg && config?.orgSetup === true;
function busButton(label = 'Restart bus') {
if (!busAllowed()) return '';
return `<span class="bus-restart"><button type="button" class="btn" data-bus-restart data-label="${esc(label)}"${busBusy ? ' disabled' : ''}>${esc(busBusy ? 'Restarting…' : label)}</button> <span role="status" data-bus-restart-receipt>${esc(busReceipt)}</span></span>`;
}
async function attachBus(box, label) {
await ready;
// The bus is unavailable again: an earlier receipt no longer describes it.
if (box && !box.hidden) $('bus-restart-done')?.remove();
if (!box || box.querySelector('[data-bus-restart]') || !busAllowed()) return;
box.insertAdjacentHTML('beforeend', ` ${busButton(label)}`);
}
const paintBus = () => document.querySelectorAll('.bus-restart').forEach(el => {
const label = el.querySelector('[data-bus-restart]')?.dataset.label;
el.outerHTML = busButton(label);
});
document.addEventListener('click', async e => {
const button = e.target.closest?.('[data-bus-restart]');
if (!button || button.disabled || busBusy || !busAllowed()) return;
const id = window.MosaicOrg.id;
busBusy = true; busReceipt = ''; paintBus();
try {
const response = await nativeFetch(`/api/orgs/${encodeURIComponent(id)}/restart/bus`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' });
if (response.type === 'opaqueredirect') throw new Error('Sign in again, then retry.');
const body = await response.json();
if (!response.ok || body.outcome !== 'done' || body.step !== 'bus-restart') throw new Error(body.message || 'The restart was refused. Nothing was retried.');
busReceipt = `${body.restarted ? 'Bus restarted' : 'Bus started'} at ${clock()}. ${body.healthy ? 'It answers.' : `It has not answered yet. Check it with ${body.verb.replace('restart', 'status')}.`}`;
// The page's send flags were read while the bus was in its old state; read them again. This
// read goes through the page's fetch, which display.js scopes to the selected organization.
// The organization list goes first, so the view the config event redraws shows the bus as it is now.
await Promise.resolve(window.MosaicOrg.refreshList?.()).catch(() => {});
const fresh = await window.fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => (r.ok ? r.json() : null)).catch(() => null);
if (fresh) { config = fresh; document.dispatchEvent(new CustomEvent('mosaic-config', { detail: fresh })); }
// A healthy bus ends the "Bus unavailable" banner. Its receipt stays on the page in its place.
const down = $('bus-down');
if (down && body.healthy) {
if (!down.hidden) {
$('bus-restart-done')?.remove();
const done = document.createElement('p'); done.id = 'bus-restart-done'; done.className = 'banner'; done.setAttribute('role', 'status');
done.textContent = busReceipt; down.after(done);
}
down.hidden = true;
}
} catch (error) {
busReceipt = error instanceof SyntaxError || error?.name === 'TypeError' ? 'Outcome unknown. Check the bus with mosaic bus status before retrying.' : error.message;
} finally { busBusy = false; paintBus(); }
});
window.MosaicConsole = Object.freeze({ ready, settingsMarkup, busButton, attachBus, get busRestart() { return busAllowed(); }, get config() { return config; } });
})();
+20 -4
View File
@@ -41,7 +41,13 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
// A failed read keeps its reason, so the header can say why the list is empty.
let listError = null;
const readList = () => nativeFetch('/api/orgs', { cache: 'no-store', redirect: 'manual' }).then(async r => {
if (r.ok) { const rows = await r.json(); listError = null; return Array.isArray(rows) ? rows : []; }
if (r.ok) {
const body = await r.json(); listError = null;
const rows = Array.isArray(body) ? body : [];
// E2E5 review P2: an open panel (Settings > Organizations) follows every fresh list read, not only its own.
document.dispatchEvent(new CustomEvent('mosaic-org-list', { detail: { rows } }));
return rows;
}
const body = await r.json().catch(() => ({}));
listError = typeof body.message === 'string' && body.message ? body.message : 'Organization registry could not be read';
return [];
@@ -59,7 +65,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
const org = rows.find(row => row.id === decodeURIComponent(id) && !row.invalid);
const mark = document.querySelector('.wordmark');
if (mark && org) { const name = document.createElement('span'); name.id = 'current-organization'; name.textContent = ` · ${org.label || org.id}`; mark.append(name); }
if (org?.busHealthy === false) { const banner = document.getElementById('bus-down'); if (banner) { banner.textContent = 'Bus unavailable'; banner.hidden = false; } }
if (org?.busHealthy === false) { const banner = document.getElementById('bus-down'); if (banner) { banner.textContent = 'Bus unavailable'; banner.hidden = false; window.MosaicConsole?.attachBus(banner, 'Restart the bus'); } }
return rows;
};
let list = readList().then(paintHeader);
@@ -234,13 +240,23 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
});
const cleanup = () => { clearTimeout(poll); login = null; const redirect=$('proxy-login-submit')?.elements.redirect_url, key=$('proxy-apikey')?.elements.key; if(redirect)redirect.value='';if(key)key.value=''; };
window.addEventListener('pagehide', cleanup, {once:true});
let devOwner = false;
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(config => {
canWrite = config.send === true && config.tester !== true && config.user !== true;
// F3: an organization's settings writes go with settingsSend (the org Console has no global send).
canWrite = (config.send === true || config.settingsSend === true) && config.tester !== true && config.user !== true;
devOwner = config.devOwner === true;
document.querySelectorAll('[data-write]').forEach(fieldset => { fieldset.disabled = !canWrite; });
$('proxy-readonly').hidden = canWrite;
if (!canWrite) document.querySelectorAll('[aria-labelledby=proxy-signin-title],[aria-labelledby=proxy-apikey-title]').forEach(el=>el.hidden=true);
return refresh();
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message); if(error.message==='The model proxy is not configured for this instance.') { $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel, #proxy-refresh').forEach(el=>el.hidden=true); } });
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message); if(error.message==='The model proxy is not configured for this instance.') { $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel, #proxy-refresh').forEach(el=>el.hidden=true);
// F3: on a dev instance, say plainly why this page cannot set the proxy up and what does.
// Setup takes a management key typed twice at a terminal and writes the proxy's credentials;
// this page never handles that key. Account sign-in (a provider's own OAuth) then runs here.
if (devOwner) { const org = window.MosaicOrg?.id, note = document.createElement('div'); note.id = 'proxy-dev-setup'; note.className = 'panel';
note.innerHTML = '<h2>Set up the model proxy</h2><p>Nothing else on this dev Console asks for a sign-in. The proxy is the exception: its setup asks for a management key, typed twice, and writes the proxy\'s credentials. This page never handles that key, so setup runs once in a terminal on this machine:</p><pre><code></code></pre><p>Then refresh this page. Signing in to a Claude or Codex account is the provider\'s own sign-in, which cannot be skipped. It runs here, under Sign in to an account, with one button.</p>';
note.querySelector('code').textContent = `mosaic proxy fetch\nmosaic proxy init${org ? ` --org ${org}` : ''}`;
$('proxy-message').after(note); } } });
return cleanup;
}
window.MosaicProxy={markup,mount};
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script>
<script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<body>
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
<a class="skip" href="#main">Skip to content</a>
+2 -1
View File
@@ -4,7 +4,8 @@
.org-admin h4{font-size:14px;font-weight:700;margin:0 0 8px;color:var(--strong)}
.org-help{display:block;font-size:13px;color:var(--muted);margin:4px 0 0}
.org-field-err{display:block;font-size:13px;color:var(--danger);margin:4px 0 0}
.org-field-err code,.org-errors code,.org-verb code{overflow-wrap:anywhere}
.org-field-err code,.org-errors code{overflow-wrap:anywhere}
.org-verb code{overflow-wrap:normal;word-break:normal}
.org-start-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:24px;align-items:start}
.org-inline{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 8px;align-items:center}
.org-inline label{grid-column:1/-1;font-size:14px;font-weight:600;color:var(--strong)}
+27 -5
View File
@@ -75,6 +75,7 @@
<section id="org-archived" class="panel" tabindex="-1" hidden aria-labelledby="org-archived-title">
<h3 id="org-archived-title">Organization archived</h3>
<p>Editing and setup are unavailable while archived. Files remain in place; restoring does not start the bus.</p>
<p id="org-restore-verb" class="org-verb"></p>
<button type="button" id="org-restore" data-write>Restore organization</button>
</section>
<p id="org-archive-error" class="org-field-err" role="alert"></p>
@@ -247,6 +248,7 @@
editorDirty = false; setupDirty.clear(); $('org-new-id').value = '';
$('org-editor').hidden = false; $('org-delete').hidden = mode !== 'edit'; resetDelete(); resetArchive(); $('org-delete-success').hidden = true;
$('org-archive').hidden = mode !== 'edit' || state !== 'active'; $('org-archived').hidden = mode !== 'edit' || state !== 'archived';
$('org-restore-verb').textContent = state === 'archived' ? `Same as mosaic org restore ${id} --if-revision ${revision}` : '';
$('org-editor-title').textContent = mode === 'create' ? `New organization: ${id}` : `Organization: ${id}`;
$('org-editor-verb').innerHTML = verb ? `Same as <code>${txt(verb)}</code>` : '';
$('org-save').textContent = mode === 'create' ? 'Save organization' : 'Save changes';
@@ -416,6 +418,9 @@
if (!alive) return;
if (r.status !== 200) { st.setup = null; $('org-setup').hidden = false; $('org-steps').innerHTML = `<li class="panel org-step"><p class="org-field-err">Setup could not be read back (${txt(r.body.error || r.status)}). ${txt(r.body.message || '')}</p><button type="button" class="org-secondary" data-readback>Read back again</button></li>`; return; }
st.setup = r.body;
const orgs = window.MosaicOrg ? await window.MosaicOrg.readList() : [];
if (!alive) return;
st.busDown = orgs.find(org => org.id === id)?.busHealthy === false;
for (const key of [...st.unknown]) if (stepState(key)?.state !== 'unknown') st.unknown.delete(key);
renderSetup();
}
@@ -431,7 +436,7 @@
const keys = stepKeys(); let blocked = false, firstOpen = null;
$('org-setup').hidden = false;
$('org-steps').innerHTML = keys.map((key, i) => {
const s = { ...stepState(key) }; if (st.unknown.has(key) && s.state !== 'done') s.state = 'unknown';
const s = { ...stepState(key) }; if (st.unknown.has(key)) s.state = 'unknown';
if (s.lastError && s.state !== 'done') s.state = s.state === 'unknown' ? 'unknown' : 'failed';
const [stateLabel, cls] = STATE[s.state] || STATE.unknown;
const ready = !blocked && s.state !== 'done' && s.state !== 'unavailable';
@@ -455,7 +460,7 @@
<label class="org-risk"${remote ? ' hidden' : ''}><input type="checkbox" data-risk${c.risk ? ' checked' : ''}${!remote && s.lastError && !c.risk ? ' aria-invalid="true"' : ''}> I accept that a local-only history has no copy anywhere else and is lost with this disk.</label>
<div class="settings-actions"><button type="button" data-run="${txt(key)}">${busy ? 'Working… <span class="settings-spinner" aria-hidden="true"></span>' : key === 'queue' ? 'Set up checkout' : 'Set up repository'}</button></div>
</fieldset>` : ''}
${s.state !== 'done' && s.state !== 'unknown' && key === 'bus' ? `<div class="settings-actions"><button type="button" data-run="bus" ${ready && canWrite && !busy ? '' : 'disabled'}>${busy ? 'Starting… <span class="settings-spinner" aria-hidden="true"></span>' : 'Start the bus'}</button>${ready ? '' : '<span class="org-help">Finish the steps above first.</span>'}</div>` : ''}
${key === 'bus' && canWrite && !st.unknown.has(key) && (st.busDown || (s.state !== 'done' && s.state !== 'unknown')) ? `<div class="settings-actions"><button type="button" data-run="bus" ${(ready || st.busDown) && canWrite && !busy ? '' : 'disabled'}>${busy ? 'Starting… <span class="settings-spinner" aria-hidden="true"></span>' : 'Start the bus'}</button>${ready || st.busDown ? '' : '<span class="org-help">Finish the steps above first.</span>'}</div>` : ''}
</li>`;
}).join('');
for (const card of root.querySelectorAll('.org-step')) if (st.choice.has(card.dataset.step) && card.querySelector('.org-git')) showVerb(card);
@@ -491,6 +496,7 @@
else if (r.body.error === 'readback-failed') { st.unknown.add(key); s.lastError = r.body.message; }
else if (r.body.errors?.length) s.lastError = r.body.errors.map(e => `${e.field}: ${e.message}`).join(' ');
else s.lastError = r.body.message || `Refused (${r.body.error || r.status}).`;
if (key === 'bus' && r.status >= 400 && r.status < 500) s.lastError += ` If the host lock is stale, run mosaic bus recover ${id}.`;
status(`Step incomplete: ${s.lastError}`);
renderSetup(); root.querySelector(`[data-step="${CSS.escape(key)}"] h4`)?.setAttribute('tabindex', '-1'); root.querySelector(`[data-step="${CSS.escape(key)}"] h4`)?.focus();
}
@@ -531,7 +537,7 @@
function resetArchive() {
$('org-archive-confirm').hidden = true; $('org-archive-open').hidden = false;
$('org-archive-error').textContent = ''; $('org-archive-success').hidden = true;
$('org-archive-final').disabled = !canWrite; $('org-restore').disabled = !canWrite;
$('org-archive-final').disabled = !canWrite; $('org-archive-final').textContent = 'Archive'; $('org-restore').disabled = !canWrite;
}
async function refreshOrganizations() {
const rows = window.MosaicOrg ? await window.MosaicOrg.refreshList() : [];
@@ -545,7 +551,7 @@
}
async function runLifecycle(step) {
const id = st.id, revision = st.revision, button = $(step === 'archive' ? 'org-archive-final' : 'org-restore');
button.disabled = true; $('org-archive-error').textContent = '';
button.disabled = true; if (step === 'archive') button.textContent = 'Archiving…'; $('org-archive-error').textContent = '';
try {
const reply = await api('POST', `/api/orgs/${encodeURIComponent(id)}/${step}`, { revision });
if (!alive) return;
@@ -559,6 +565,7 @@
} else $('org-archived').focus();
status(`Organization ${step === 'archive' ? 'archived' : 'restored'}.`);
} catch {
if (step === 'archive') button.textContent = 'Archive';
$('org-archive-error').textContent = `The outcome is unknown. Inspect the organization before retrying: mosaic org ${step} ${id} --if-revision ${revision}.`;
}
}
@@ -587,8 +594,23 @@
} catch { $('org-delete-error').textContent = 'The outcome is unknown. Inspect the receipt and resume with the CLI.'; $('org-delete-progress').innerHTML = `<p>Resume with <code>${txt(`mosaic org delete ${id} --confirm ${id}`)}</code></p>`; }
}
// E2E5 review P2: a list read anywhere on the page (Refresh, the header, the bus banner) updates the
// bus Start affordance of the open organization. Only a change re-renders, and the typed setup choices
// are kept first, so a draft survives.
function onOrgList(e) {
if (!alive || !st.setup || !st.id || !Array.isArray(e.detail?.rows)) return;
const down = e.detail.rows.find(org => org.id === st.id)?.busHealthy === false;
if (down === Boolean(st.busDown)) return;
for (const card of root.querySelectorAll('.org-step')) {
if (!card.querySelector('.org-git')) continue;
const mode = card.querySelector('input[type=radio]:checked')?.value;
st.choice.set(card.dataset.step, { mode, remote: card.querySelector('[data-remote]')?.value ?? '', risk: !!card.querySelector('[data-risk]')?.checked });
}
st.busDown = down; renderSetup();
}
// ---- wiring -----------------------------------------------------------
const handlers = [
[document, 'mosaic-org-list', onOrgList],
[$('org-archive-open'), 'click', () => { $('org-archive-open').hidden = true; $('org-archive-confirm').hidden = false; $('org-archive-confirm').focus(); }],
[$('org-archive-cancel'), 'click', () => { resetArchive(); $('org-archive-open').focus(); }],
[$('org-archive-final'), 'click', () => runLifecycle('archive')],
@@ -621,7 +643,7 @@
[$('org-steps'), 'input', e => { const card = e.target.closest('.org-step'); if (card && e.target.matches('[data-remote]')) { keepChoice(card); showVerb(card); } }],
];
for (const [el, ev, fn] of handlers) el.addEventListener(ev, fn);
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(async config => {
fetch(new URL('/api/config', location.href), { cache: 'no-store' }).then(r => r.json()).then(async config => {
if (!alive) return;
canWrite = (config.orgSetup === true || config.send === true) && config.tester !== true && config.user !== true;
root.querySelectorAll('[data-write]').forEach(el => { el.disabled = !canWrite; });
+1 -1
View File
@@ -100,7 +100,7 @@
});
window.addEventListener('pagehide', () => { endLogin(); $('proxy-apikey').elements.key.value = ''; });
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(config => {
canWrite = config.send === true && config.tester !== true && config.user !== true;
canWrite = (config.send === true || config.settingsSend === true) && config.tester !== true && config.user !== true;
document.querySelectorAll('[data-write]').forEach(fieldset => { fieldset.disabled = !canWrite; });
$('proxy-readonly').hidden = canWrite;
return refresh();
+10
View File
@@ -93,6 +93,16 @@ table.s1-table td code{font-size:12.5px;white-space:nowrap}
.banner.ref{border-color:color-mix(in srgb,var(--warning) 50%,var(--line));background:color-mix(in srgb,var(--warning) 9%,var(--surface))}
.banner b,.banner strong{color:var(--strong)}
.banner code{overflow-wrap:anywhere}
/* F3: the Console restart banner stays in view while the page waits for the Console, and the bus restart control sits inline in the receipt or banner that asks for it. */
.console-reconnect{position:sticky;top:0;z-index:30;align-items:center}
.console-reconnect>span{flex:1 1 220px;min-width:0;overflow-wrap:anywhere}
.bus-restart{display:inline-flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0}
.bus-restart [role=status]:empty{display:none}
.routes-apply{margin:12px 0 0;padding-left:18px;display:grid;gap:8px}
.routes-apply li{overflow-wrap:anywhere}
.agent-restart{display:inline-flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0}
.agent-restart [role=status]:empty{display:none}
#proxy-dev-setup pre{overflow-x:auto;max-width:100%}
/* Stream S4: which organization this is, in words. Colour (the theme) is never the only cue; the strip appears whenever the theme is not the built-in one. */
.org-cue{margin:0 0 12px;padding:6px 10px;border:1px solid var(--line);border-left:4px solid var(--action);border-radius:var(--r-table);background:var(--raised);color:var(--text);font-size:13px;overflow-wrap:anywhere}.skel-rows{display:grid;gap:10px;padding:14px 12px;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface)}
.skel{display:block;height:12px;border-radius:4px;background:var(--raised);animation:skel 1.4s ease-in-out infinite}
+39 -18
View File
@@ -2,7 +2,7 @@ import { proxyHTTP } from './proxy-http.mjs';
import { createServer } from 'node:http';
import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs';
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
import { timingSafeEqual } from 'node:crypto';
import { timingSafeEqual, randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import { isIP } from 'node:net';
import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from './bus.mjs';
@@ -19,6 +19,9 @@ import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } fro
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
import { consoleRestartHTTP } from './console-restart-http.mjs';
import { workspaceHTTP } from './workspace-read.mjs';
import { actor } from './action-trail.mjs';
import { devHostName } from '../../cli/src/dev-local-owner.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
@@ -55,7 +58,7 @@ const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
@@ -443,10 +446,11 @@ const reasonFor = (code, done) => {
return `A skill this role needs isn't turned on: scripts/skill.sh activate ${done.skill}`;
return Object.hasOwn(INSTANCE_REASONS, code) ? INSTANCE_REASONS[code] : Object.hasOwn(AGENT_REASONS, code) ? AGENT_REASONS[code] : typeof code === 'string' && code.startsWith('proxy-') ? 'Check the model proxy configuration and account status' : String(code ?? 'unknown');
};
async function instanceSend(req, res, { instancesWrite, preview, log }) {
let instance = null;
async function instanceSend(req, res, { instancesWrite, preview, log, note = null }) {
let instance = null, op = null;
// The instance is quoted and cut short so a crafted value can't forge a log line. Vars are never logged.
const line = outcome => log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
// F3 review P3: the owner's instance changes also go to the action trail, like routes and agents.
const line = outcome => { log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`); note?.({ action: 'instances.set', op, instance: typeof instance === 'string' ? instance.slice(0, 200) : null, outcome }); };
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
@@ -462,7 +466,7 @@ async function instanceSend(req, res, { instancesWrite, preview, log }) {
(has('vars') && (!value.vars || typeof value.vars !== 'object' || Array.isArray(value.vars))) || (has('launch') && typeof value.launch !== 'boolean')) {
return refuse(400, 'invalid-request', 'this takes a revision, an instance, op add or change and a definition, vars or launch, or op delete alone, nothing else');
}
({ instance } = value);
({ instance, op } = value);
// D4.1: the tester never deletes. The bus refuses it too (broker tester-refused); this makes no call.
if (deleting && preview.tester) return refuse(403, 'tester-refused', 'the tester listener doesn\'t delete. Nothing changed.');
let result;
@@ -499,10 +503,13 @@ const AGENT_ROUTES = {
'/api/veto': { kind: 'veto', verb: 'veto.set', keys: ['revision', 'op', 'name'], tester: false,
done: r => /^[a-f0-9]{64}$/.test(r?.revision ?? '') && typeof r.restart === 'boolean' && r.instances && typeof r.instances === 'object' && !Array.isArray(r.instances) },
};
async function agentSend(req, res, { route, agentWrite, preview, log }) {
// F3: the writes the owner makes on a selected organization's bus from Settings (dev org Console).
const ORG_OWNER_WRITE = path => path === '/api/settings' || path === '/api/instances' || path === '/api/routes'
|| Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/');
async function agentSend(req, res, { route, agentWrite, preview, log, note = null }) {
let target = null;
// The instance or name is quoted and cut short so a crafted value can't forge a log line. A reason is never logged.
const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(target)} ${outcome}`);
const line = outcome => { log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(target)} ${outcome}`); note?.({ action: route.verb, instance: typeof target === 'string' ? target.slice(0, 200) : null, outcome }); };
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
@@ -562,10 +569,11 @@ const ROUTE_REASONS = {
};
const routeObject = o => o && typeof o === 'object' && !Array.isArray(o);
const routesAnswer = v => /^[a-f0-9]{64}$/.test(v?.revision ?? '') && Array.isArray(v.harnesses) && Array.isArray(v.models) && routeObject(v.levels) && routeObject(v.instances);
async function routesSend(req, res, { routesWrite, preview, log }) {
// `note` (F3) adds the owner's change to the Console's action trail; best effort, after the log line.
async function routesSend(req, res, { routesWrite, preview, log, note = null }) {
// Instance names only; routes are never logged.
let names = [];
const line = outcome => log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`);
const line = outcome => { log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`); note?.({ action: 'routes.set', instances: names, outcome }); };
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
@@ -763,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
// 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.
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 } = {}) {
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
// 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.
@@ -790,11 +798,16 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const registered = routeTable();
for (const registerRoutes of routes) registerRoutes(registered);
registered.add('GET', '/api/themes', (req, res) => themesHTTP(req, res, { log }), { publicRead: true });
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log });
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log, trail });
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore|restart)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
for (const method of ['GET', 'POST']) registered.add(method, path, adminRoute);
}
registered.add('POST', '/api/orgs', adminRoute);
// 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.
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);
// `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.
@@ -942,7 +955,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null,
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, upstream = null } = selected);
}
if (!hook && !['GET', 'HEAD'].includes(req.method) && !decisionPath && path !== '/api/north-star/approve' && !(/^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f-]+\/(?:messages|open))$/i.test(path)))
// F3: the owner's settings writes reach the selected organization's bus too (limits, routing,
// agents, assistant, instances, model proxy). The routes below keep their own checks.
if (!hook && !['GET', 'HEAD'].includes(req.method) && !decisionPath && path !== '/api/north-star/approve' && !(/^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f-]+\/(?:messages|open))$/i.test(path))
&& !(orgId !== null && !tester && !granted && named && ORG_OWNER_WRITE(path)))
return json(res, 403, { error: 'unavailable', sent: false });
}
if (hook) {
@@ -953,6 +969,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return hook.handler(req, res, { path: originalPath, search, session: ssoSession, orgId, origin, owner: !tester && !granted && named && sso !== null, send: owner.send !== null });
}
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer;
// F3: the owner's routing and agent changes go to the action trail too (who, when, what).
const ownerNote = !tester && !granted && ssoSession && trail ? entry => {
try { trail({ by: actor(ssoSession), ...(orgId !== null ? { org: orgId } : {}), ...entry }); }
catch { log(`${new Date().toISOString()} trail write failed for ${entry.action}`); }
} : null;
// P4 page and verbs are owner-only, including reads and static page assets.
// This runs after owner sign-in and the granted-user fence, before generic
// tester writes or static-file serving. No localhost preview grants access.
@@ -1008,13 +1029,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (path === '/api/instances') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if ((!tester && (!send || !named)) || !instancesWrite) return json(res, 403, { error: 'read-only', sent: false });
return instanceSend(req, res, { instancesWrite, preview: writer, log });
return instanceSend(req, res, { instancesWrite, preview: writer, log, note: ownerNote });
}
// Row 194 (#1692, D7): the Agents section. No route gets a --send exception.
if (Object.hasOwn(AGENT_ROUTES, path)) {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if ((!tester && (!send || !named)) || (tester && !AGENT_ROUTES[path].tester) || !agentWrite) return json(res, 403, { error: 'read-only', sent: false });
return agentSend(req, res, { route: AGENT_ROUTES[path], agentWrite, preview: writer, log });
return agentSend(req, res, { route: AGENT_ROUTES[path], agentWrite, preview: writer, log, note: ownerNote });
}
if (path === '/api/agent/request') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
@@ -1024,7 +1045,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (path === '/api/routes') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if ((!tester && (!send || !named)) || !routesWrite) return json(res, 403, { error: 'read-only', sent: false });
return routesSend(req, res, { routesWrite, preview: writer, log });
return routesSend(req, res, { routesWrite, preview: writer, log, note: ownerNote });
}
if (path === '/api/library/revert') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
@@ -1138,7 +1159,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy } : {}), decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && !granted && named && sso !== null && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
+344
View File
@@ -0,0 +1,344 @@
// 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) {
const controller = new AbortController(), until = Date.now() + ms;
let timer;
const promise = new Promise(resolve => { timer = setTimeout(() => { controller.abort(); resolve(TIMED_OUT); }, ms); timer.unref?.(); });
return { promise, signal: controller.signal, deadline: until, abort: () => controller.abort(), clear: () => clearTimeout(timer) };
}
const code = e => (typeof e?.code === 'string' && /^[a-z-]{1,64}$/.test(e.code) ? e.code : 'read-failed');
// Runs `names` reads `limit` at a time and stops starting new ones once `stopped()` says so.
async function pooled(names, limit, run, stopped) {
const out = {}, queue = [...names];
const worker = async () => {
while (queue.length && !stopped()) {
const name = queue.shift();
try { out[name] = { ok: true, value: await run(name) }; } catch (e) { out[name] = { ok: false, error: code(e) }; }
}
};
await Promise.all(Array.from({ length: Math.min(limit, names.length) }, worker));
return out;
}
const iso = ms => new Date(ms).toISOString();
const latest = values => values.filter(v => typeof v === 'string' && !Number.isNaN(Date.parse(v))).sort().at(-1) ?? null;
function median(values) {
if (!values.length) return null;
const s = [...values].sort((a, b) => a - b), mid = s.length >> 1;
return s.length % 2 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
}
// One org's bus state and reads. Never throws: every failure becomes the org's state.
async function readOrg(member, needs, { timeoutMs, now, ctx }) {
const started = now();
const head = { org: member.id, label: member.id, theme: member.doc?.appearance?.theme ?? null, tracker: member.doc?.tracker ? 'vikunja' : 'built-in', roles: Object.keys(member.doc?.roles ?? {}).sort() };
const finish = (state, extra = {}) => ({ ...head, state, at: iso(now()), readMs: now() - started, ...extra });
if (member.invalid) return { org: { ...head, roles: [], state: 'invalid', at: iso(now()), readMs: 0, reason: member.reason } };
const limit = deadline(timeoutMs);
const bound = { signal: limit.signal, deadline: limit.deadline };
const invalid = () => ({ org: finish('invalid', { error: 'invalid-response' }) });
try {
let status;
try {
status = await Promise.race([member.status(bound), limit.promise]);
} catch (e) {
if (limit.signal.aborted || Date.now() >= limit.deadline) return { org: finish('timeout', { reason: `the bus state was not read within ${timeoutMs} ms` }) };
// A stale or mismatched host state (org-bus.mjs checkedState) is a stopped bus to the reader.
// members() has already made host paths relative; any absolute path left is withheld.
return { org: finish('down', { reason: plainMessage(ctx, typeof e?.message === 'string' ? e.message : 'bus state could not be read').slice(0, 300) }) };
}
if (status === TIMED_OUT || limit.signal.aborted || Date.now() >= limit.deadline) return { org: finish('timeout', { reason: `the bus state was not read within ${timeoutMs} ms` }) };
if (status?.healthy !== true) {
const boot = status?.boot && typeof status.boot === 'object' ? { ok: status.boot.ok === true, at: status.boot.at ?? null, lastGood: status.boot.lastGood ?? null } : null;
if (Number.isSafeInteger(status?.pid)) return { org: finish('starting', { reason: 'the bus host is running but does not answer yet', boot }) };
const reason = boot && !boot.ok && typeof status.boot.reason === 'string'
? `the bus refused to start: ${plainMessage(ctx, status.boot.reason).slice(0, 300)}`
: 'the bus is not running';
return { org: finish('down', { reason, boot }) };
}
const options = member.options(bound);
const reads = pooled(needs, PER_ORG_CONCURRENCY, name => READS[name](options), () => limit.signal.aborted || Date.now() >= limit.deadline);
const results = await Promise.race([reads, limit.promise]);
if (results === TIMED_OUT || limit.signal.aborted || Date.now() >= limit.deadline) return { org: finish('timeout', { reason: `the bus did not answer within ${timeoutMs} ms` }) };
const errors = Object.fromEntries(Object.entries(results).filter(([, r]) => !r.ok).map(([name, r]) => [name, r.error]));
const data = Object.fromEntries(Object.entries(results).filter(([, r]) => r.ok).map(([name, r]) => [name, r.value]));
if (Object.values(errors).includes('invalid-response') || !Object.entries(data).every(([name, value]) => validRead(name, value))) return invalid();
const org = finish('ok', { errors });
return { org, rows: orgRows(org, data, member.doc, now()) };
} catch { return invalid(); } finally { if (Date.now() >= limit.deadline) limit.abort(); limit.clear(); }
}
// Validate the fields consumed by projection and by the cross-org filters and sorts. Unknown
// fields are retained by the bus contract; optional fields can be absent or null.
const record = v => v !== null && typeof v === 'object' && !Array.isArray(v);
const optional = (v, check) => v == null || check(v);
const string = v => typeof v === 'string';
const strings = (v, keys) => keys.every(k => optional(v[k], string));
const records = v => Array.isArray(v) && v.every(record);
function validRead(name, value) {
if (name === 'usage' || name === 'limits') {
if (!record(value)) return false;
return name === 'limits' ? optional(value.tokens, records) : optional(value.total, record);
}
if (!records(value)) return false;
return value.every(v => {
if (name === 'projects') return string(v.id) && strings(v, ['repo', 'name', 'at']);
if (name === 'threads') return strings(v, ['id', 'project']);
if (name === 'claims') return strings(v, ['thread', 'instance', 'holder_run', 'harness', 'at']);
if (name === 'inbox') return string(v.id) && strings(v, ['question', 'class', 'raised_by_role', 'task_ref', 'at']) && optional(v.options, a => records(a) && a.every(o => strings(o, ['key', 'text'])));
if (name === 'agents') return strings(v, ['role', 'holder_run', 'harness', 'state', 'at']) && optional(v.working, w => record(w) && strings(w, ['since'])) && ['inputNeeded', 'stranded'].every(k => optional(v[k], records)) && (v.inputNeeded ?? []).every(d => strings(d, ['task_ref', 'at']));
if (name === 'tasks') return strings(v, ['task_ref', 'project_key', 'at']) && optional(v.fields, f => record(f) && strings(f, ['title', 'bucket']) && ['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);
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 = 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;
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);
}
+2 -1
View File
@@ -218,7 +218,8 @@ test('row 194 N5: agents.js is served as JavaScript and index.html loads it once
const s = await setup(t);
const js = await get(s, '/agents.js');
assert.equal(js.status, 200); assert.match(js.type, /^text\/javascript/);
assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section \}\)/);
// F3 adds the agent restart receipt and the thread test that Settings > Routing uses.
assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section, restartMarkup, isThread: definition => threadDefinitions\.has\(definition\) \}\)/);
assert.match(js.text, /Agent actions are unavailable here\. Use mosaic org instance from the instance CLI\./);
const html = await get(s, '/');
assert.equal(html.text.match(/<script src="\/agents\.js" defer><\/script>/g)?.length, 1);
+2 -2
View File
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false);
@@ -0,0 +1,101 @@
// F3: the owner restarts the Console from Settings > Console. The real supervisor in
// `mosaic console start` relaunches the Console on the same address, and the open page shows the
// restart and comes back by itself, with no manual reload. A page holding an unsaved draft keeps
// it and offers the reload instead of taking it.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
import { AGENT_MARKERS } from '../../cli/src/transport.mjs';
import { consoleStart, consoleStop } from '../../cli/src/org-console.mjs';
import { browser } from './browser.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
const sleep = ms => new Promise(r => setTimeout(r, ms));
// The page reloads on its own, so a check can land between two documents; it is retried.
async function until(b, expression, ms = 30000) {
const deadline = Date.now() + ms;
for (;;) {
try { if (await b.evaluate(`!!(${expression})`)) return; } catch { /* between documents */ }
if (Date.now() > deadline) throw new Error(`timed out: ${expression}`);
await sleep(50);
}
}
// Every banner the page shows is kept in sessionStorage, which outlives the page's own reload.
const WATCH = `(() => {
const seen = JSON.parse(sessionStorage.getItem('f3-seen') || '[]');
new MutationObserver(() => {
const box = document.getElementById('console-reconnect');
if (box && !box.hidden && seen.at(-1) !== box.textContent) { seen.push(box.textContent); sessionStorage.setItem('f3-seen', JSON.stringify(seen)); }
}).observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
return true;
})()`;
test('Settings > Console restarts the Console under its supervisor and the open page reconnects by itself', { timeout: 120000 }, async t => {
const home = mkdtempSync(join(tmpdir(), 'f3-'));
const env = { ...process.env, HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-f3test/config.json') };
for (const k of AGENT_MARKERS) delete env[k];
delete env.NODE_TEST_CONTEXT;
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'f3test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-f3test'), dataRoot: join(home, '.mosaic-f3test'), instance: 'f3test', env };
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
const warnings = [];
const run = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) });
t.after(async () => { run.stop(); await run.done; try { await consoleStop(ctx, { warn: () => {} }); } catch {} rmSync(home, { recursive: true, force: true }); });
const stateFile = join(ctx.dataRoot, 'console/console.json');
const pidNow = () => JSON.parse(readFileSync(stateFile, 'utf8')).pid;
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`${run.url}#/settings/console`);
await until(b, 'document.getElementById("console-restart")');
const boot1 = await b.evaluate('window.MosaicConsole.config.boot');
assert.equal(typeof boot1, 'string');
assert.match(await b.evaluate('document.getElementById("console-restart-title").closest("section").textContent'), /Organization buses are separate processes and keep running/);
await b.evaluate(`window.__f3 = 'first'`); await b.evaluate(WATCH);
// The button asks first; Cancel restarts nothing.
await b.evaluate('document.getElementById("console-restart").click()');
await until(b, 'document.getElementById("console-restart-cancel")');
await b.evaluate('document.getElementById("console-restart-cancel").click()');
await until(b, '!document.getElementById("console-restart-go") && !document.getElementById("console-restart").hidden');
assert.equal(pidNow(), run.pid);
await b.evaluate('document.getElementById("console-restart").click()');
await until(b, 'document.getElementById("console-restart-go")');
await b.evaluate('document.getElementById("console-restart-go").click()');
// The page reloads itself once the new Console answers: the marker is gone, the boot is new.
await until(b, `window.__f3 === undefined && window.MosaicConsole?.config?.boot && window.MosaicConsole.config.boot !== ${JSON.stringify(boot1)}`);
const seen1 = JSON.parse(await b.evaluate(`sessionStorage.getItem('f3-seen')`));
assert.equal(seen1[0], 'Console restarting. This page reconnects when it is back.');
assert.equal(await b.evaluate('location.hash'), '#/settings/console');
await until(b, 'document.getElementById("console-restart")');
const pid2 = pidNow();
assert.notEqual(pid2, run.pid);
assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).url, run.url);
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
const trail = readFileSync(join(ctx.dataRoot, 'console/actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
assert.deepEqual(trail.map(e => [e.action, e.outcome, e.by.provider, e.boot]), [['console.restart', 'restarting', 'local-dev', boot1]]);
// An unsaved draft (a reply being written) is not thrown away: the page says the Console is
// back and offers the reload. The textarea stands in for the chat reply box.
const boot2 = await b.evaluate('window.MosaicConsole.config.boot');
await b.evaluate(`(() => { window.__f3 = 'second'; sessionStorage.removeItem('f3-seen'); const t = document.createElement('textarea'); t.id = 'reply'; t.value = 'half a reply'; document.body.append(t); return true; })()`);
await b.evaluate(WATCH);
await b.evaluate('document.getElementById("console-restart").click()');
await until(b, 'document.getElementById("console-restart-go")');
await b.evaluate('document.getElementById("console-restart-go").click()');
await until(b, 'document.getElementById("console-reconnect-reload")');
assert.equal(await b.evaluate('window.__f3'), 'second');
assert.equal(await b.evaluate('document.getElementById("reply").value'), 'half a reply');
assert.deepEqual(JSON.parse(await b.evaluate(`sessionStorage.getItem('f3-seen')`)), ['Console restarting. This page reconnects when it is back.', 'The Console is back. Your unsaved draft is kept on this page. Reload now']);
assert.notEqual(await b.evaluate('window.MosaicConsole.config.boot'), boot2);
assert.notEqual(pidNow(), pid2);
await b.evaluate('document.getElementById("console-reconnect-reload").click()');
await until(b, `window.__f3 === undefined && document.getElementById("console-restart")`);
assert.equal(readFileSync(join(ctx.dataRoot, 'console/actions.jsonl'), 'utf8').split('\n').filter(Boolean).length, 2);
});
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more