Compare commits

..
Author SHA1 Message Date
jason.woltje edec4e1d04 docs: PRD 1.20 draft 2, chief of staff hires at every level (#1873) 2026-11-09 12:14:00 -06:00
jason.woltje 00ef0bd301 docs: record unified workspace direction and PRD 1.20 draft (#1873) 2026-11-09 11:55:44 -06:00
jason.woltje 3c2d8631e5 docs: record owner rulings for organizations 2026-11-09 11:55:31 -06:00
jason.woltje d6ab5047af Merge rename stage A (org-rename-stage-a) into refactor 2026-11-09 11:15:59 -06:00
jason.woltje 311ae46e3d Merge test-health fixes (org-test-health-fix) into refactor 2026-11-09 11:14:55 -06:00
jason.woltje e08f0fa1f4 test(webui): match the E2E round 4 Queue heading and record-init write
The refused Queue read keeps Queue as its heading, and the owner's
Initialize project records button is a new POST in bus.js. Both older
tests predated those intended changes.
2026-11-09 11:14:08 -06:00
jason.woltje 3581f7548a Merge E2E round 4 fixes (org-e2e4-fix) into refactor
# Conflicts:
#	packages/webui/src/public/chat.js
#	packages/webui/src/public/projects.js
2026-11-08 23:25:02 -06:00
jason.woltje 1b8e7976a6 bus: PRD reads refuse when org project records exist at both roots
readCandidate called readPrdMaterial with the org data root directly, so
prd.submit and the Console PRD draft kept working while project.files refused
the same conflict. It now resolves through recordArgs like the file adapters.
Fixes the P2 from the E2E4 review.
2026-11-08 23:24:19 -06:00
jason.woltje c27ff53649 webui: reread request panels, files and summary on every refresh
The refresh shortcuts (unchanged view, transcript-only change) skipped the request
summary and file rereads, so a request's phase change showed only after a full
repaint. One refreshRequestData helper now runs on every paint path; a quiet reread
keeps the present content until a changed result arrives.

s6b's error matrix waited for the 'Reading' placeholder to go, which a quiet
reread no longer shows, so it could read the previous scenario. It now waits for
the expected text.
2026-11-08 23:22:43 -06:00
jason.woltje d070ed6d4a dev fixture: take a stale record atomically so a concurrent init keeps a live record
A stale-record init now renames the record aside, checks it is still the one it
observed, and puts it back otherwise. Two inits racing over the same stale record
can no longer delete the record of a live endpoint the other one just started.
Fixes the P2 from the test-health review.
2026-11-08 23:07:48 -06:00
jason.woltje 87b7a34f1c Merge rename part A2: --org selectors with --business aliases
# Conflicts:
#	packages/cli/tests/org-console.test.mjs
#	packages/webui/src/cli.mjs
2026-11-08 23:04:19 -06:00
jason.woltje a36d45af10 Merge rename part A1: pin v1 organization contracts 2026-11-08 23:04:19 -06:00
jason.woltje 61e4e76520 Fix organization setup records and console regressions 2026-11-08 22:55:55 -06:00
jason.woltje 20d798cd57 Merge live project updates (org-live-updates) into refactor 2026-11-08 22:43:23 -06:00
jason.woltje a8bcf94b8c Scope requirement view state to its request and keep closed panels closed
A refresh that drops the displayed request no longer hands its open
requirement and focus to another request's requirement with the same
local id: the restore key now carries the request id. Keyed disclosures
also remember a closed state, so desktop provenance the user closed
stays closed through a repaint. Adds Sol's two R3 probes as tests.
2026-11-08 22:43:16 -06:00
jason.woltje 9a52eecffd selector: org-console test asserts the Console child's exact argv
The detached Console is a selector producer: the test reads the child's
/proc cmdline and asserts serve --port 0 --business alpha --dev-local-owner.
2026-11-08 22:35:24 -06:00
jason.woltje 4fa6995c13 selector: --org and --business in land-work
land-work takes --org as --business in one scope. A repeated, conflicting,
missing, empty or flag-shaped value exits 4 before the config is read. The
id keeps its old grammar (none): any other text names businesses/<ID>.json
and a missing file exits 3. A value of --subject or --attribution is never
read as a selector.
2026-11-08 22:35:24 -06:00
jason.woltje 381039fe5f selector: --org and --business in stack-copy check
check takes --org as --business in one scope: each spelling once, both only
with the same value. A repeated, conflicting, missing, empty or flag-shaped
value exits 4 before any unit is read; an id outside check's own grammar
still exits 1. copy and wrapper refuse either spelling with usage, as
before. The sourced-library path used by stack-release.sh does not read
selector.sh; the command path refuses if it cannot.
2026-11-08 22:35:24 -06:00
jason.woltje 1105de280b selector: stack-release rehearsal fixture copies org-selector.mjs
The rehearsal builds fake copies from an explicit file list. f198cfd47 made
packages/cli/src/access.mjs import org-selector.mjs, which the list lacked,
so every rehearsal that ran the copied CLI failed with "Cannot find module"
(17 of 226 tests) from f198cfd47 until this commit. Real copies were not
affected: stack-copy.sh COPY_PATHS takes packages/cli/src whole.
2026-11-08 22:35:24 -06:00
jason.woltje af29832a05 Keep request selection and requirement state by identity across refreshes
Sol's re-check of c0b2f7d86 found two failures. A refresh that changed only an
unselected request kept the page and so kept the request selector's old closure;
selecting that request then drew its old PRD. Retained handlers now draw from the
newest accepted read for their container.

Requirement disclosures and focus were saved by position, so a requirement
inserted above the focused one handed its open state and focus to a neighbour.
Requirement and provenance details carry a restore key; open state and summary
focus are restored by that key and a key that no longer exists is dropped. The
outer chat capture uses the same helper.

org-live-fix-r3-browser.test.mjs holds Sol's two probes: 0/2 on c0b2f7d86, 2/0
here. Nineteen live-update, chat, project, request and s10-s15 files: 231/0.
2026-11-08 22:23:59 -06:00
jason.woltje 9e458878df selector: --org and --business in stack-upgrade's args-file scopes
read_args takes --org as --business in the unit flags before -- and in the
serve flags after it, with per-scope duplicate and conflict refusal, and
refuses a file whose serve flags name no selector or another business, as
case2-unit.sh would at the render after A2 stopped the units. The serve
flags are walked with case2-unit.sh's grammar so a value is never read as a
selector. The file is not rewritten; only --copy's value changes, as before.
A checkout without scripts/selector.sh refuses checkout-unreadable.
2026-11-08 22:05:44 -06:00
jason.woltje c0b2f7d86a Preserve live project views through overlapping refreshes 2026-11-08 21:57:48 -06:00
jason.woltje ac63a187e8 Merge walkthrough fixes and LAN dev bind (org-stage) into refactor
org-walkthrough-fix 60f3b3d78 (Sol APPROVE 0/0/0) and org-lan-bind 95a5f7072 (Sol APPROVE 0/0/1) via org-stage 265f1767c.
2026-11-08 21:56:43 -06:00
jason.woltje 226be2e935 selector: --org and --business in case1-accept's three scopes 2026-11-08 21:56:02 -06:00
jason.woltje dbb6909dcd selector: case2-precheck and case2-install refuse when selector.sh cannot be read
Neither script runs under set -e, so a failed source left selector_take
undefined and parsing carried on without the duplicate and conflict rules.
2026-11-08 21:56:02 -06:00
jason.woltje 4b7ddbcb43 selector: --org and --business in case2-unit's outer and serve scopes 2026-11-08 21:39:20 -06:00
jason.woltje 7f882a905d selector: --org and --org-file in case2-install
Rename design r2, "Selector scopes and parser matrix", case2-install row. --org/--business
and --org-file/--business-file are two families, each its own scope: each spelling once, and
a second spelling must carry the same text (file paths compared as given). Missing values
are usage; a flag-shaped or invalid id keeps the business-id refusal; a flag-shaped file, a
repeated spelling and a conflict say why. All exit 4 before anything is read or written.
The persona-import call it spawns still passes --business.
2026-11-08 21:27:37 -06:00
jason.woltje 265f1767c0 Merge org-lan-bind into org-stage 2026-11-08 21:27:33 -06:00
jason.woltje 3baf6f2297 selector: --org and --business in case2 persona-import and precheck
Rename design r2, "Selector scopes and parser matrix". case2-persona-import.mjs takes --org
through selectorScope and case2-precheck.sh through scripts/selector.sh, one scope each. Both
keep their exit codes and their old business-id wording; a repeated or conflicting selector
says why. persona-import now checks the id while parsing, so stage refuses an invalid
selector before it reads anything.

The case2-install fake stack copy carries packages/business/src/selector.mjs, which the real
copy already does (COPY_PATHS takes packages/business/src).
2026-11-08 21:25:49 -06:00
jason.woltje 298081dbf3 Keep console views stable across refreshes 2026-11-08 21:24:12 -06:00
jason.woltje 259302a368 selector: --org and --business in the Console CLI and service parsers
cli.mjs serve and webui-service.sh each take --org as --business in their own
scope through the shared selector rules; the service unit still carries the
canonical --business. A flag-shaped or invalid value keeps each script's old
wording and exit code; a repeated or conflicting selector is a usage error.
2026-11-08 21:20:09 -06:00
jason.woltje f198cfd47b selector: --org and --business in each CLI parser's own scope
Rename design r2, "Selector scopes and parser matrix". packages/business/src/selector.mjs
and scripts/selector.sh carry one rule set: in a parser's scope each spelling appears once,
and a second spelling must carry exactly the same value; a repeated spelling, a missing or
flag-shaped value, an invalid id and unequal values refuse. The file family
(--business-file/--org-file) compares paths as given.

Every CLI parser that took --business now takes --org through orgSelector, and the old
global --org rewrite in main() is gone, so a value another option consumes is never read as
a selector. The id is checked while parsing (OrgError org-invalid, exit 3), so grants admin
set no longer reads the token and proxy init no longer prompts for the key before an
invalid id refuses. access gains the same local id check.
2026-11-08 21:14:11 -06:00
jason.woltje 95a5f7072d Document the console LAN flags and console status 2026-11-08 21:13:56 -06:00
jason.woltje 665a5148d4 Open the dev owner Console to allowed LAN names
mosaic console start --dev-local-owner --dev-insecure-bind ADDR
--dev-allow-host NAME[,NAME...] binds 0.0.0.0 or an IPv4 address and
answers only NAME:<bound port> plus the loopback names. Both flags are
refused without the dev owner, the bind without an allowed name, and a
specific ADDR that is not itself a name. Start and console status print
the no-sign-in warning; the state file records bind and allowHosts.

serve.mjs takes devAllowHosts only with the dev owner; SSO, preview and
tester listeners stay loopback. Writes need Origin http://<same Host>.
Chromium sends no Fetch Metadata from a LAN http origin, so for an
allowed name a write with that exact Origin and no Sec-Fetch-Site counts
as same-origin; an explicit header is still judged by the routes.
chat.js builds its operation keys without crypto.randomUUID, which an
insecure context lacks.
2026-11-08 21:13:53 -06:00
jason.woltje 60f3b3d78f Offer New organization in the header menu, picker and empty state
Once organizations existed, Quick Setup was reachable only through
Settings > Organizations > type an ID > New organization. This tree
has no header organization switcher, so the owner of a Console with
organization setup now gets "New organization" in the header avatar
menu, under the Organization picker on Settings > Organization, and
next to the Settings link in the no-organization empty state. Each
opens #/settings/organizations?new=1, which focuses the ID field and
then drops the flag. The form itself is unchanged.

The new browser test covers the menu at 1400 and 390px, the picker
and the empty state. It fails on 6ca7f3b65 and passes here.
2026-11-08 20:58:46 -06:00
jason.woltje 4b40c7f57b Keep project crumbs evenly spaced for short project names
"Projects / work /  / Conversations / init" had no missing label.
Every project crumb link had min-width:44px, so a short name such as
"work" was padded with blank space after its text, and the separators
around it read as an empty crumb. The 44px target width now comes
from an ::after overlay centred on the link, so the layout box fits
the text on every project route.

The new browser test measures text-to-separator gaps and hit width
on the overview, conversations and conversation routes at 1440 and
390px. It fails on 6ca7f3b65 and passes here.
2026-11-08 20:58:40 -06:00
jason.woltje a1491a0163 Give organization Consoles a request read so request status resolves
The organization options in cli.mjs carried no requestRead, so
/api/orgs/<id>/config never offered requests and the project and
conversation pages counted "requests not offered" as a failed read:
"Request status unavailable." on a conversation whose message went
through. The owner now reads request status over the organization's
own bus socket, as the root Console does. Request writes stay off,
per the README Console actions inventory.

The new browser test runs the real cli.mjs serve --dev-local-owner
against a real organization record with only the bus transports
shimmed. It fails on 6ca7f3b65 and passes here.
2026-11-08 20:58:35 -06:00
jason.woltje 500418e710 test: pin remaining v1 organization contracts 2026-11-08 20:53:16 -06:00
jason.woltje c2a6fad2a8 dev fixture: --ttl for the endpoint, and init replaces a stale record (P19 #882)
The endpoint ended after MOSAIC_FIXTURE_TTL_S, but init passed it only PATH, so
the lifetime was always the 14400 s default. Its record stayed after it ended,
and init refused fixture-exists until `dev fixture stop`.

init now takes --ttl SECONDS (0 = no expiry; otherwise 60 to 2147483, the
longest delay a Node timer holds; the default stays 14400), passes it to the
endpoint, and records ttlSeconds and expiresAt. /health and status report them.
A record whose pid is no longer the endpoint is stale: init removes it and says
which pid it replaced. A live endpoint still refuses fixture-exists.

A running bus keeps the endpoint it read at start, so after a replacement
the bus needs a restart; --ttl 0 avoids the expiry in the first place.
2026-11-08 20:53:03 -06:00
jason.woltje 7d1ab7cd96 test: pin v1 resolver and command contracts 2026-11-08 20:45:50 -06:00
jason.woltje 041e163d3c tester-cli: load the bus client only in the nonce child (P19 #876)
The tester-cli test makes about 20 Console reads and chat calls in a
row. Each one spawns TESTER_CLI, which re-executes itself so the nonce
is in its initial environment; both processes statically imported the
client and the broker module graph behind it, though the parent only
spawns the child. The child now imports Client and readProcess after
the re-exec branch, so the parent is a bare node start. Interleaved
over 20 calls at load 19: 258 ms per call before, 192 ms after. The
proof, nonce and argv are unchanged.

What remains is legitimate serial work: about 20 round trips of two
node processes each, about 3 s idle and 10 s at load 20 on 16 cores
(before this change). 30 s left under 3x room on a busy machine, so the
test's hang guard is now 60 s.
2026-11-08 20:45:23 -06:00
jason.woltje 588253e524 test: stop dev-local-owner buses before removing their root (P19 #875)
The Console-and-bus test in packages/webui/tests/dev-local-owner.test.mjs
registered rmSync(root) as its first t.after hook. Hooks run in
registration order, so root went first, both `bus stop` calls then failed,
and the SIGTERM fallback reached only the outer `bus start`. Each run left
the beta and acme --internal-host processes and their brokers running.

One hook now kills the Console, stops both buses while root exists, waits
for every recorded process (outer CLIs, hosts from their receipts, and the
hosts' children, each matched by start time) and fails if any survives,
SIGKILLing the survivors first so the failure does not leak. Root is
removed last. A mutant that skips the stops fails with six survivors.
packages/bus/tests/dev-local-owner.test.mjs already stopped its bus and
leaves nothing behind.
2026-11-08 20:39:31 -06:00
jason.woltje 93a73a7375 test: leave a linked node_modules out of the copy fixtures
On a deploy tree node_modules is a symlink. .gitignore's node_modules/
matches only a directory, so git ls-files -co --exclude-standard lists
the link as an untracked file and the fixtures' cpSync throws EISDIR
on it (8 failures in pinned-copy.test.mjs). stack-copy.sh never takes
node_modules from git (it archives COPY_PATHS and runs npm ci), so the
fixtures now exclude **/node_modules from the listing. The copy code is
unchanged.
2026-11-08 20:34:53 -06:00
108 changed files with 4722 additions and 260 deletions

No files matched your search

+2
View File
@@ -6337,3 +6337,5 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
2026-11-07 UTC | Organizations S1 — implementation phase | Before: ui-dev c8d6d12fe used the instance root for one bus and the Console had no scoped lifecycle commands. The S1 stream charter and boundaries are in /mnt/storage/scratch/mosaic-lead/uidev/org-release/PLAN.md. The queue refused this linked worktree; canonical checkout access was excluded. Verification and unresolved integration boundaries follow in the stream report.
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.
+10 -2
View File
@@ -302,6 +302,8 @@ scripts/mosaic inbox --read <id>... | --read-all [--business <id>]
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 talk <role> <text> [--wait SECONDS] [--business <id>]
scripts/mosaic project create <name> [--repo <repo>] | list [--json]
scripts/mosaic threads [--project <project>] [--json] [--tester]
@@ -320,6 +322,8 @@ scripts/bus-service.sh [--instance NAME] uninstall | status <business>
For local development on a named instance only, `mosaic bus start BUSINESS --dev-local-owner` admits the fixed local-dev Console owner and refuses the default instance. Launches are enabled unless `--no-launch` is supplied; `--pm` can start the business's PM. Launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only. Start the matching Console with `--dev-local-owner`; the usual Console wrapper, process proof and nonce checks still apply. This mode has no sign-in and is for local development only.
`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.
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
@@ -1046,7 +1050,7 @@ including `console.sso` (exit 2 if missing). Without `--send` the unit is
read-only. The template is
`packages/webui/systemd/mosaic-webui.service.in`.
For direct local development (not the service renderer), `node packages/webui/src/cli.mjs serve --dev-local-owner --send` uses the owner without sign-in. It requires `MOSAIC_CONFIG` naming a non-default `~/.config/mosaic-NAME/config.json`, listens only on 127.0.0.1, and requires an exact local Host and same-origin writes. It refuses SSO, preview, proof, and tester flags. Use an absolute path to the Console CLI so the bus can verify its process path. Start the named instance bus with `--dev-local-owner` as above for decision and request operations. Anyone able to reach the local listener acts as the owner.
For direct local development (not the service renderer), `node packages/webui/src/cli.mjs serve --dev-local-owner --send` uses the owner without sign-in. It requires `MOSAIC_CONFIG` naming a non-default `~/.config/mosaic-NAME/config.json`, listens on 127.0.0.1 unless `--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens it to the LAN as described for `mosaic console start` above, and requires an exact local or allowed Host and same-origin writes. It refuses SSO, preview, proof, and tester flags. Use an absolute path to the Console CLI so the bus can verify its process path. Start the named instance bus with `--dev-local-owner` as above for decision and request operations. Anyone able to reach the local listener acts as the owner.
Under `--send` the Console's Docs page (row 148) can also save an existing
spec/north-star.md or docs/NAME.md of a project: one local commit authored by the
@@ -1316,7 +1320,11 @@ refuses `project-no-commit` if the layout has no readable HEAD commit. It
exits 0 when valid, 2 for project/record problems (including an undeclared
project), 3 for configuration loading problems and 4 for usage. The optional business project `repo` is a normalized absolute
record-repository path; the default is
`<dataRoot>/projects/<business>/<project>/`. `root` remains the code checkout.
`<dataRoot>/projects/<business>/<project>/`. For a registered organization,
`<dataRoot>` is that organization's data root for both the CLI and its bus.
If an instance-root records repository already exists, initialization and reads
refuse and name both locations for inspection; they never move or copy it.
`root` remains the code checkout.
The package README describes schemas and the shared agent record writer; agent
writes never enter `spec/`, never absorb pending edits and refuse remotes.
@@ -0,0 +1,47 @@
# Organizations: owner rulings, 2026-11-07
Recorded by Mos (lead) from Jason's messages of 2026-11-07, around 16:39Z and
16:55Z. These bind the multi-organization design (working files under
`/mnt/storage/scratch/mosaic-lead/uidev/business-design-r*.md`). Corrections
go in a new dated file, not edits here.
## Rulings
1. **Name.** "Business" becomes "Organization" in labels, routes, help and new
CLI verbs now. The internal rename (package `packages/business`, the
`businesses/` config directory, `--business` flags, schema fields) is not
optional. It may wait, but it is a priority follow-up, because mixed names
cause confusion.
2. **Isolation.** Each organization runs its own bus. One Console switches
between them from Settings.
3. **No migration.** No existing business must be migrated. The full setup is
tested from scratch on a fresh instance.
4. **Test order.** An agent runs the full creation end to end with the CLI,
then with the WebUI, before Jason walks through it in the WebUI.
5. **Dev instance authority.** The dragon-lin instance is throwaway and for
development only. Agents may use it, and act as its human for these tests,
without asking. That is why it needs no sign-in. A deployed system keeps
human sign-in and the agent restrictions on actions taken for the user.
6. **WebUI creation.** The WebUI creates an organization with a WYSIWYG editor
of its JSON, starting from a template. Every WebUI action is tied to a CLI
command.
7. **Theme.** Each organization can have its own theme, stored in its file.
Themes are plugins: the choice lists whatever themes are installed.
8. **Proxy.** One model proxy per user by default; that user's organizations
share it, and an organization may opt into its own. On multi-user instances,
sharing between users is by explicit, revocable grant (time-limited,
unlimited, per key). Grants come later.
9. **Organization git.** Each organization has its own canonical git checkout
for its high-level information and queue. A remote is optional when the user
chooses "local only" and accepts the risk.
10. **Project git.** Every project uses git. The user chooses per project
whether it has a remote or is **local only** (a local repository that is
never pushed), for throwaway or temporary work that needs no durable
remote. Jason first said this on 2026-11-06; it was not recorded then.
11. **Access.** JSON access control is acceptable for the MVP. Database-backed
RBAC with tighter security comes later.
12. **Modules.** The system is modules connected through documented API calls.
Each function (for example the model proxy, the OpenMontage plugin, the
creative writing system) is a module with a declared set of calls.
Swapping or disabling a module must not break the core. Adding a module
must never require rewriting the core.
+144
View File
@@ -0,0 +1,144 @@
# 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.
- Source: `docs/plans/2026-11-09_unified-workspace-owner-direction.md`,
Jason's direction and answers of 2026-11-09.
- Drafted by Mos (lead).
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.
## Amended requirements
Each entry gives the 1.19 text that changes and its 1.20 replacement.
### REQ-ASSIST-1
1.19: "Each user has one assistant role per organization."
1.20: 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. (The rest
of REQ-ASSIST-1 stands.)
### REQ-USER-1
1.19: "An agent belongs to one organization and reaches only its allowed
projects and threads."
1.20: 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-TASK-1
1.19: "Tasks live in Vikunja..."
1.20: 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.
### REQ-WEB-3
1.19: "The Console has controls to restart the bus and to reload the
organization file."
1.20: 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.
## New requirements
### Workspace
- **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.
### Agent levels
- **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.
### System types
- **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.
### Accounts
- **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).
### Setup
- **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.
## Open questions for the approval
1. The final name for the Mosaic Montage module. A council of three agents
is producing three suggestions for Jason to choose from.
2. Answered 2026-11-09: "yes, the COS should be able to hire and fire
agents to fulfill the user's needs". REQ-AGENT-3 now lets the chief hire
and fire at every level. Draft 2 keeps one approval step at the root
level because a root change reaches every organization; Jason can drop
that step when he approves the text.
@@ -0,0 +1,253 @@
# Unified workspace: owner direction, 2026-11-09
Recorded by Mos (lead) from Jason's T3 message of 2026-11-09, around 17:50Z,
and his answers to four clarifying questions at about 18:00Z. It binds the
next phase of the organizations work. Corrections go in a new dated file, not
edits here.
## What prompted it
Jason ran Quick Setup on the dragon-lin dev instance (uiorg) to create the
`test3` organization. Two things went wrong:
- The new project showed "This project has no records yet" with an
"Initialize project records" button. Quick Setup should have done that.
The setup receipts show the org, checkout, project repository and bus
steps at 17:19:20Z. The records repository first appears at 17:21:13Z,
from the button press. The fix round reproduces it.
- After he pressed the button, the overview showed the north star template
as raw text: `# North star Describe the outcome this project should
achieve...`. The overview prints the markdown source instead of rendering
it.
He also named a standing problem with how the dev instance is operated.
## Owner text, verbatim
> The Quick Setup should auto-initialize since this didn't ask any
> questions. An onboarding interview to determine the north star and PRD
> would be beneficial here.
>
> I've noticed that we have a perennial issue with needing to restart the
> console, or requiring a sign-in (model proxy), and other limiting behaviors
> that don't seem to make sense for the operating method. We are running
> this as a test instance on my dev workstation (dragon-lin), auth is not
> required for this local testing env., and we need to be capable of rapid
> iteration without undue manual intervention by the user. I also don't see
> where we can trigger any type of restart or relaunch with the available
> config from within the webui. The user should be able to change the
> running model and restart the service from within the system.
>
> Here is what I want:
> - Mosaic Stack system runs
> - Allows user to log in
> - User can create orgs, and be invited to join another user's org
> - User belongs to N organizations
> - All orgs are visible in the interface for the user to see the org,
> project, sessions within each
> - Tasks are overarching for all projects; a central store of tasks
> - Agents can be configured at the root, org, and project level
> - The user is able to see all happenings in the system from one pane
> - Projects are sortable and filterable by org, status, name, activity
> - Inbox contains all items needing the user; all orgs/projects/sessions
> with needs surface here
> - System allows for side-by-side operation of orgs and projects all
> visible in single pane
> - Rather than selecting an organization with a switch or in the settings
> menu, the user sees all orgs at all times.
> - Sidebar collapsible item for Organizations; user clicks to see all info
> about the organzation
> - The Docs are available on the org dashboard
> - All configurables are visible on the org dashboard
> - Org dashboard features KPIs/Metrics for the org
> - Active agent sessions visible on the org dashboard
> - Projects sidebar list indicates the organization owning the project via
> iconography, color, and "avatar" for org
> - Main dashboard for user surfaces sum and cumulative data for all
> orgs/projects, not just one
> - The system is configured to use plugins/modules to allow for various
> system types like coding, creative writing, Mosaic Montage (fork of
> OpenMontage) for social media production, business management
> - Each Project within an org is capable of using multiple system types
> - Example: Org. Distinctly Digital
> - enabled plugins: coding, creative writing, marketing, Mosaic
> Montage/Mosaic Studio (whatever we name this...), business management,
> - Each project within "Distinctly Digital" has access to the plugins,
> but is not required to use all of them
> - Agents configured for the user's root config are available
> - Org agents are available
> - Project agents are available
> - Agents use the toolsets for the plugin/module appropriately
> - Configuration for the agents, orgs, projects, sessions are all
> available for the user to tweak as needed
> - Chief of Staff can hire and fire agents to meet the user's system
> needs
> - User can override settings at any time via the configuration
>
> I'm thinking this information needs to be recorded for durability and we
> need to work toward this implementation.
## Answers to the clarifying questions
1. **Task store.** Verbatim: "We need to keep the tasks separated at an org
level. This allows for archive, instant removal of org tasks without
violating the other orgs. I'd like to see a basic config use per-org,
aggregated store with the option to use Vikunja. Not all users will use
Vikunja. Either way, we need a way to store, retrieve, archive, maintain,
organize, and view all the tasks. If we use Vikunja or a custom-rolled
solution within Mosaic Stack, they all need to surface with Kanban, etc.
for the user to see properly."
2. **Agent scope across orgs.** Verbatim: "The user's main interaction agent
needs to be aware of all orgs, projects, etc. That one interaction agent
uses a shared memory. The other agents are isolated per org."
3. **Multi-user.** Design sign-in, membership and invites now. Build them
in the second phase, after the single-user unified pane.
4. **Order.** Remove the operating friction first, then build the unified
pane.
## Rulings that follow
1. **All orgs at once.** The WebUI shows every organization the user
belongs to at all times. A collapsible Organizations item in the sidebar
lists them, and selecting one opens its dashboard. There is no
organization switcher, in Settings or elsewhere.
2. **Per-org isolation stays.** Each organization keeps its own bus as the
tenant boundary (REQ-ORG-1). The Console reads across the buses of the
user's organizations to build the shared views. Removing or archiving an
organization removes its data, tasks included, without touching another.
3. **Tasks.** Each organization stores its own tasks. The default store is
built into Mosaic Stack. Vikunja is an optional alternative, selected per
organization through the task tracker plug-in kind (REQ-PLUG-1). Both
support store, retrieve, archive, maintain and organize. The WebUI
aggregates every organization's tasks into one view with Kanban and list
layouts, filterable by organization and project.
4. **Agents.** Agents are configured at three levels: the user's root,
the organization and the project. A project's agents are the union of
all three. The user's main interaction agent (the assistant) is the one
exception to isolation. It knows all of the user's organizations,
projects and sessions, and it keeps one shared memory. Every other agent
is isolated to the organization it works in: its memory, files and
credentials are scoped to that organization, even when its definition
comes from the root.
5. **One pane.** One screen shows activity across every organization and
project. Organizations and projects can be open side by side.
6. **Main dashboard.** It sums across all organizations and projects, not
only one.
7. **Org dashboard.** It shows the organization's docs, every configurable
setting, KPIs and metrics, and its active agent sessions.
8. **Projects list.** It sorts and filters by organization, status, name
and activity. Each project shows its owning organization by icon, color
and avatar.
9. **Inbox.** One inbox holds everything that needs the user, from every
organization, project and session.
10. **System types are plug-ins.** Coding, creative writing, marketing,
Mosaic Montage (the OpenMontage fork; final name open) and business
management are modules. An organization enables a set of them. Each of
its projects may use any of the enabled set, and none is required.
Agents get the toolsets of the modules their project uses.
11. **Configuration.** The user can see and change the configuration of
agents, organizations, projects and sessions, and can override any
setting at any time. The chief of staff hires and fires agents to meet
the user's needs, within the existing PRD rules (REQ-ROLE, version
1.7).
12. **Accounts.** The deployed system has user sign-in. Users create
organizations, belong to any number of them, and can be invited into
another user's organization. The dev instance keeps running with no
sign-in (ruling 5 of 2026-11-07).
13. **Operating the dev instance.** Routine operation needs no manual
intervention from the user. From the WebUI the user can restart the
Console and an organization's bus, and change the model an agent runs
on. Dev mode needs no sign-in, including for the model proxy. Agents
still cannot restart the Console; the user can, from the WebUI.
14. **Quick Setup.** Quick Setup leaves a project with its records
initialized. An onboarding interview then helps the user write the
north star and a first PRD.
## What this supersedes or amends
- **2026-11-07 ruling 2.** "One Console switches between them from
Settings" is superseded by ruling 1 above. The per-org bus in the same
ruling stands.
- **2026-11-07 ruling 9.** Each organization's queue lived only in its own
checkout. Ruling 3 above keeps tasks per organization, adds the Vikunja
option through the task tracker kind, and adds the aggregated view.
- **PRD 1.19, REQ-USER-1.** "An agent belongs to one organization" gains
the assistant exception in ruling 4. This needs a PRD version. The draft
is `docs/plans/2026-11-09_prd-1-20-draft.md`, and Jason approves the
exact text before it enters `docs/prd/mosaic-stack.md`.
## Delivery plan
Each phase follows the MVP rule: build, one review round, one fix round, an
agent E2E on a scratch instance, then a deploy to uiorg for Jason.
### Phase F: operating friction (now)
- **F1.** Quick Setup records: reproduce the `test3` gap on a scratch
instance and fix it, so a Quick Setup project opens with live Docs, Files
and north star. Render the north star as markdown on the project
overview.
- **F2.** Onboarding interview: after Quick Setup, the assistant opens a
conversation that asks for the outcome and limits, then drafts
`spec/north-star.md` and a first PRD. The user approves each through the
existing approval path.
- **F3.** Self-service operation: restart the Console and an org bus from
the WebUI, change an agent's model from Settings and apply it, and list
and remove every manual step the dev instance still needs, model proxy
sign-in included.
### Phase U: unified pane
- **U0.** Design samples first. Sample screens for the sidebar with all
orgs, the main dashboard, an org dashboard, the projects list, the inbox,
the task Kanban and side-by-side panes. Jason approves them before the
build (design samples are the acceptance target).
- **U1.** Cross-org read model in the Console: one API that reads every
organization's bus the user belongs to, for projects, sessions, inbox
items, tasks and metrics.
- **U2.** Sidebar and org dashboards. **U3.** Main dashboard, projects list
and inbox. **U4.** Task store: built-in per-org store behind the task
tracker kind, the Vikunja module, and the aggregated Kanban. **U5.**
Side-by-side panes.
### Phase A: agent levels
Root, organization and project agent configuration. The assistant spans
organizations with shared memory, and every other agent is isolated per
organization. The user can override settings, and the chief of staff hires
and fires at each level.
### Phase P: system type plug-ins
A plug-in kind for system types. Organizations enable modules, projects opt
in, and agents receive the modules' toolsets. First modules: coding (the
current behavior), creative writing, then Mosaic Montage, marketing and
business management.
### Phase M: accounts (design now, build second)
OIDC sign-in (OIDC-only per Q203), membership in N organizations, invites
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.
## Open items
- The final name for the Mosaic Montage module.
- Which "sign-in (model proxy)" step Jason hit. F3 inventories every manual
step on the dev instance and reports it.
## Addendum, 2026-11-09 18:13 UTC
Jason's answers to the two open questions in PRD 1.20 draft 1, verbatim:
> 1. I would like you to use a council of agents to decide on 3 name
> suggestions for this
> 2. yes, the COS should be able to hire and fire agents to fulfill the
> user's needs
Effect: the chief of staff hires and fires agents at the root,
organization and project levels (PRD 1.20 draft 2, REQ-AGENT-3). A council
of three agents proposes three names for the Mosaic Montage module, and
Jason picks one.
+12 -9
View File
@@ -491,6 +491,7 @@ export async function startBroker({
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
// Row 210 (#1707, design D7 step 6) and row 234 (#1740, r6 E2): the run dir and the host enrollment half.
const runRoot = organization ? join(organization.dir, 'secrets', 'run') : null;
const recordArgs = context => ({ ...context, ...projectModule.recordContext({ ...context, dataRoot: instanceDataRoot ?? dataRoot, orgRoot: org ? dataRoot : null }) });
let broker;
processing = createProcessing({ dataRoot, ...discordProcessingHooks(dataRoot), containerStop: createContainerStopper({ dataRoot }),
onTransition: () => broker.reconcileProcessing(),
@@ -650,15 +651,17 @@ export async function startBroker({
workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }),
requirementsHost,
requestLifecycle: services => {
const readCandidate = (candidate, business) => {
// The PRD reader goes through the same record resolver as the file adapters, so both roots present refuses here too.
const readCandidate = async (candidate, business) => {
const { request, project, prd_version, records_commit, prd_path, mode_revision } = candidate;
return readPrdMaterial({ business: services.prdBusiness(business), dataRoot, request, project, prd_version, records_commit, prd_path, mode_revision,
const records = recordArgs({ business: services.prdBusiness(business), project });
return readPrdMaterial({ business: records.business, dataRoot: records.dataRoot, request, project, prd_version, records_commit, prd_path, mode_revision,
requirement_ids: candidate.requirement_ids ?? candidate.requirements.map(r => r.id) });
};
const catalog = createRequirementCatalog({ journal: { ...requestJournal, readCandidate, authority: services.prdAuthority, approval: services.prdApproval },
readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }),
readSpec: ({ business, project }) => projectModule.readSpec(recordArgs({ business: services.prdBusiness(business), project })),
builtinBinding: services.builtinBinding, projectArchived: services.projectArchived, threadProject: services.threadProject });
return (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { catalog, readCandidate, readReviewedArtifact: (state, commit, path) => readReviewedArtifact({ repo: workingRepo(dataRoot, state.business, state.project), commit, path }), readVerification: (state, record) => projectModule.readProjectFile({ business: services.prdBusiness(state.business), project: state.project, dataRoot, path: record.path, commit: record.commit }), requestAdmission: state => admission({ business: state.business, project: state.project }), ...(writer ? hostPreparation({ dataRoot, organization: writer, trackers, journal: requestJournal, catalog, admission, work: fn => processing.work(fn), readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }) }) : {}), ...requestServices, ...services,
return (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { catalog, readCandidate, readReviewedArtifact: (state, commit, path) => readReviewedArtifact({ repo: workingRepo(dataRoot, state.business, state.project), commit, path }), readVerification: (state, record) => projectModule.readProjectFile(recordArgs({ business: services.prdBusiness(state.business), project: state.project, path: record.path, commit: record.commit })), requestAdmission: state => admission({ business: state.business, project: state.project }), ...(writer ? hostPreparation({ dataRoot, organization: writer, trackers, journal: requestJournal, catalog, admission, work: fn => processing.work(fn), readSpec: ({ business, project }) => projectModule.readSpec(recordArgs({ business: services.prdBusiness(business), project })) }) : {}), ...requestServices, ...services,
// The new-project executor needs the organization writer; without one a confirmed selection stays at its setup wait.
...(writer ? { bootstrap: ({ binding, state }) => processing.work(() => executeBootstrap({ binding, owner: state.owner, journal: requestJournal, services: {
dataRoot, organization: writer, activate: activateManagedProject, barrier: requestServices.barrier, projectArchived: services.projectArchived, threadProject: services.threadProject,
@@ -717,11 +720,11 @@ export async function startBroker({
GuardError: libraryModule.GuardError,
},
project: {
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
writeRecord: context => processing.work(() => projectModule.writeRecord({ ...context, dataRoot })),
listFiles: context => projectModule.listProjectFiles({ ...context, dataRoot }),
readFile: context => projectModule.readProjectFile({ ...context, dataRoot }),
writeFile: context => projectModule.writeHumanFile({ ...context, dataRoot }),
readSpec: context => projectModule.readSpec(recordArgs(context)),
writeRecord: context => processing.work(() => projectModule.writeRecord(recordArgs(context))),
listFiles: context => projectModule.listProjectFiles(recordArgs(context)),
readFile: context => projectModule.readProjectFile(recordArgs(context)),
writeFile: context => projectModule.writeHumanFile(recordArgs(context)),
ProjectError: projectModule.ProjectError,
},
// limits and limits.set (organization.set is limits.set) carry each token limit's count; a
+5 -2
View File
@@ -4,8 +4,6 @@ import { randomBytes } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { Client } from './client.mjs';
import { readProcess } from './human.mjs';
const file = fileURLToPath(import.meta.url);
if (!process.env.MOSAIC_BUS_TESTER_NONCE) {
const child = spawnSync(process.execPath, [file, ...process.argv.slice(2)], {
@@ -14,6 +12,11 @@ if (!process.env.MOSAIC_BUS_TESTER_NONCE) {
});
process.exit(child.status ?? 2);
}
// P19 #876: only the re-executed child, whose initial environment holds the nonce, talks to the
// bus. Loading the client (and the broker module graph behind it) here instead of at the top
// keeps the re-exec parent to a bare node start.
const { Client } = await import('./client.mjs');
const { readProcess } = await import('./human.mjs');
try {
const request = JSON.parse(readFileSync(0, 'utf8'));
if (
@@ -0,0 +1,70 @@
import test from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import assert from 'node:assert/strict';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { prdFixture, git } from '../../project/tests/prd-fixture.mjs';
import { createRequestStore } from '../src/request-store.mjs';
async function setup(t, mode = 'autonomous') {
const root = tmp(t, 'activation-'), f = fixture(root, 'demo');
const instanceRoot=join(root,'instance');mkdirSync(instanceRoot);
const source = join(root, 'project'); mkdirSync(join(source, '.mosaic'), { recursive: true });
writeFileSync(join(source, '.mosaic/project.json'), JSON.stringify({ projectVersion: 1, id: 'stack', vars: { 'tracker.project': 1 } }));
git(source, 'init', '-qb', 'main'); git(source, 'add', '-A'); git(source, 'commit', '-qm', 'Factory');
const system = loadSystem({ env: f.env });
const boot = async () => {
const config = bootConfig({ system, businessId: 'demo', env: f.env, warn: () => {} });
const bus = await startBroker({...config,org:{id:'demo'},instanceDataRoot:instanceRoot,instanceConfigDir:join(root,'config')}); t.after(() => bus.close().catch(() => {})); return bus;
};
const bus = await boot(), caps = {};
const db = new DatabaseSync(join(f.dataRoot, 'bus/bus.sqlite'), { readOnly: true }); t.after(() => db.close());
const store = { get: (sql, ...a) => db.prepare(sql).get(...a), all: (sql, ...a) => db.prepare(sql).all(...a) };
const bind = b => {
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
caps.console = b.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
caps.reader = b.bindReader({ business: 'demo' });
for (const role of ['pm','cto','coder']) { caps[role] = b.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi' }); b.request(caps[role], { verb: 'role.claim' }); }
};
bind(bus.broker);
const call = (who, verb, args = {}) => bus.broker.request(caps[who], { verb, args });
const sent = call('human', 'message.send', { to: 'pm', body: 'Build a timer.\n' });
let state = await call('pm', 'request.open', { request: sent.request });
state = await call('pm', 'request.propose', { request: sent.request, revision: state.revision, op: 'plan', source_project: 'stack', project: 'timer', name: 'Timer', tracker_project: 2 });
call('human', 'decision.resolve', { id: state.selection.decision, choice: mode });
state = await bus.requestLifecycle.resume('demo', sent.request);
const context = { business: { id: 'demo', human: 'jason', projects: { timer: { root: join(f.dataRoot, 'products/demo/timer/code') } } }, project: 'timer', dataRoot: f.dataRoot };
const p = await prdFixture(t, context);
const args = { request: sent.request, revision: state.revision, op: 'prd1', prd_version: '1.0', records_commit: p.args.records_commit, prd_path: p.args.prd_path, requirement_ids: p.args.requirement_ids };
const submit = () => call('pm', 'prd.submit', args);
const approve = (submission, who = mode === 'guided' ? 'human' : 'cto') => call(who, 'decision.resolve', { id: submission.decision.id, choice: 'yes' });
const activate = submission => bus.requirementCatalog.activate({ request: sent.request, decision: submission.decision.id });
return { ...p, f, bus, caps, call, state, instanceRoot, request: sent.request, args, submit, approve, activate, boot, bind, config: bootConfig({ system, businessId: 'demo', env: f.env, warn: () => {} }), store, journal: createRequestStore(store) };
}
// E2E4 review P2: in an organization runtime the PRD reader resolves records through the same
// conflict guard as project.files, so records at both roots refuse and neither root changes.
test('org PRD submit refuses when records exist at both roots', async t => {
const f = await setup(t, 'guided');
const legacy = join(f.instanceRoot, 'projects/demo/timer');
mkdirSync(legacy, { recursive: true }); writeFileSync(join(legacy, 'keep'), 'legacy record evidence');
await assert.rejects(f.call('human', 'project.files', { project: 'timer' }), { code: 'project-records-conflict' });
const before = git(f.repo, 'rev-parse', 'HEAD');
const decisions = f.store.get("SELECT count(*) n FROM decisions").n;
await assert.rejects(f.submit(), { code: 'project-records-conflict' });
assert.equal(f.store.get("SELECT count(*) n FROM decisions").n, decisions, 'no approval decision is raised');
assert.equal(git(f.repo, 'rev-parse', 'HEAD'), before);
assert.equal(readFileSync(join(legacy, 'keep'), 'utf8'), 'legacy record evidence');
});
test('org Console PRD draft refuses when records exist at both roots', async t => {
const f = await setup(t, 'guided'); await f.submit();
const legacy = join(f.instanceRoot, 'projects/demo/timer');
mkdirSync(legacy, { recursive: true }); writeFileSync(join(legacy, 'keep'), 'legacy');
await assert.rejects(f.call('console', 'request.draft.console', { request: f.request }), { code: 'project-records-conflict' });
assert.equal(readFileSync(join(legacy, 'keep'), 'utf8'), 'legacy');
});
+54
View File
@@ -0,0 +1,54 @@
// Organization selector options (rename design r2, "Selector scopes and parser matrix").
// scripts/selector.sh is the shell half and keeps the same rules; packages/business/tests/
// selector.test.mjs runs one table against both.
//
// --org and --business are one option family, and --org-file and --business-file are another.
// A scope is one parser's own options: the parser that knows which tokens its other options
// consume calls take() for a selector token it meets, so a value or positional text that happens
// to read "--org" never reaches this file. Within one scope each spelling may appear once, and a
// second spelling must carry exactly the same value. A missing value, an empty one, one that
// starts with "-", an invalid one (by the parser's own predicate) and differing values refuse
// through the parser's own refuse function, so each entry point keeps its exit code and wording.
// The file family compares the strings given; its paths are checked later under existing rules.
export const SELECTOR_FAMILIES = Object.freeze({
id: Object.freeze({ canonical: '--business', spellings: Object.freeze(['--business', '--org']) }),
file: Object.freeze({ canonical: '--business-file', spellings: Object.freeze(['--business-file', '--org-file']) }),
});
// Each refusal names one of these kinds, so a parser can map it to its own usage error.
export const SELECTOR_REFUSALS = Object.freeze(['missing', 'flag', 'repeated', 'invalid', 'differ']);
export function isSelector(token, family = 'id') {
return SELECTOR_FAMILIES[family]?.spellings.includes(token) ?? false;
}
// refuse(kind, message) must throw; take() throws its own error if it returns.
export function selectorScope({ family = 'id', valid = null, refuse }) {
const spec = SELECTOR_FAMILIES[family];
if (!spec) throw new TypeError(`unknown selector family ${family}`);
if (typeof refuse !== 'function') throw new TypeError('selectorScope needs a refuse function');
const seen = [];
let value;
const fail = (kind, message) => {
refuse(kind, message);
throw new Error(`selector refusal returned: ${message}`);
};
return Object.freeze({
family,
canonical: spec.canonical,
owns: token => spec.spellings.includes(token),
take(flag, given) {
if (!spec.spellings.includes(flag)) throw new TypeError(`${flag} is not a ${family} selector`);
if (seen.includes(flag)) fail('repeated', `${flag} given twice`);
if (typeof given !== 'string' || given === '') fail('missing', `${flag} needs one value`);
if (given.startsWith('-')) fail('flag', `${flag} needs one value, not ${given}`);
if (valid && !valid(given)) fail('invalid', `${flag} ${given} is not valid`);
if (seen.length && given !== value) fail('differ', `${seen[0]} ${value} and ${flag} ${given} conflict`);
seen.push(flag);
value = given;
},
get value() { return value; },
get given() { return seen.length > 0; },
});
}
@@ -0,0 +1 @@
{"business":"acme","contract":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"<ROOT>/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
@@ -0,0 +1,95 @@
{
"business": "acme",
"project": "stack",
"instance": "coder",
"definition": "coder",
"holder": null,
"contract": "<ROOT>/roles/coder.md",
"vars": {
"tracker.kind": "vikunja",
"tracker.reconcileMinutes": 60,
"tracker.pollSeconds": 60,
"bus.tester": false,
"tracker.descendants": false,
"environment": "development",
"dataRoot": "<ROOT>/data",
"execution.backend": "docker",
"execution.provider": "zai",
"execution.model": "glm-5.3-flash",
"execution.adapter": "mock",
"tracker.baseUrl": "http://127.0.0.1:3456",
"gitea.baseUrl": "https://git.example",
"tracker.project": 3,
"harness": "pi",
"limits.network": "none"
},
"provenance": {
"tracker.kind": "default",
"tracker.reconcileMinutes": "default",
"tracker.pollSeconds": "business:acme",
"bus.tester": "default",
"tracker.descendants": "default",
"environment": "system",
"dataRoot": "system",
"execution.backend": "system",
"execution.provider": "system",
"execution.model": "system",
"execution.adapter": "system",
"tracker.baseUrl": "business:acme",
"gitea.baseUrl": "business:acme",
"tracker.project": "project:stack",
"harness": "business:acme:roles.coder",
"limits.network": [
"business:acme:roles.coder"
]
},
"limits": {
"tools": [
"read",
"write",
"edit",
"bash",
"grep",
"find",
"ls"
],
"network": "none",
"authority": {
"withinRole": [
"task.update.assigned",
"git.push.working",
"review.request",
"message.send",
"record.write"
],
"crossRole": [
"task.scope.change"
]
}
},
"credentials": {
"gitea": {
"service": "gitea",
"file": "<ROOT>/secrets/coder-gitea.token",
"rotateBy": "2099-01-01"
},
"vikunja": {
"service": "vikunja",
"file": "<ROOT>/secrets/coder-vikunja.token",
"expires": "2099-01-01"
}
},
"tracker": {
"bot": "bot-acme-coder",
"botId": 5
},
"launch": null,
"webSource": null,
"skills": [
"ms-unslop",
"ms-sdlc",
"ms-file-read",
"ms-file-write"
],
"digest": "<DIGEST>"
}
@@ -0,0 +1,14 @@
{
"business": "acme",
"file": "<ROOT>/config/businesses/acme.json",
"projects": {
"stack": "valid"
},
"instances": {
"pm": "<DIGEST>",
"cto": "<DIGEST>",
"coder": "<DIGEST>",
"reviewer": "<DIGEST>",
"chief": "<DIGEST>"
}
}
@@ -0,0 +1 @@
{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
+63
View File
@@ -0,0 +1,63 @@
{
"business": "acme",
"project": "stack",
"instance": "coder",
"definition": "coder",
"holder": "example",
"contract": "/synthetic/roles/coder.md",
"vars": {
"tracker.kind": "vikunja",
"tracker.reconcileMinutes": 60,
"tracker.pollSeconds": 15,
"bus.tester": false,
"tracker.descendants": false,
"environment": "development",
"dataRoot": "/synthetic/data",
"execution.backend": "docker",
"execution.provider": "zai",
"execution.model": "system-model",
"execution.adapter": "mock",
"tracker.baseUrl": "http://127.0.0.1:3456",
"tracker.project": 3,
"model": "agent-model",
"limits.network": "none"
},
"provenance": {
"tracker.kind": "default",
"tracker.reconcileMinutes": "default",
"tracker.pollSeconds": "project:stack:roles.coder",
"bus.tester": "default",
"tracker.descendants": "default",
"environment": "system",
"dataRoot": "system",
"execution.backend": "system",
"execution.provider": "system",
"execution.model": "system",
"execution.adapter": "system",
"tracker.baseUrl": "business:acme",
"tracker.project": "project:stack",
"model": "business:acme:roles.coder",
"limits.network": [
"business:acme:roles.coder"
]
},
"limits": {
"tools": [
"read",
"write"
],
"network": "none",
"authority": {
"withinRole": [
"message.send"
],
"crossRole": []
}
},
"credentials": {},
"tracker": null,
"launch": null,
"webSource": null,
"skills": [],
"digest": "975144f5fcd6989c3393175b1713908c632c04f378b5f28a95cb2f4e552fbefa"
}
@@ -0,0 +1,12 @@
// Synthetic, path-stable resolver input; no file is read by resolveInstance.
export const input = {
system: { environment: 'development', dataRoot: '/synthetic/data', 'execution.backend': 'docker', 'execution.provider': 'zai', 'execution.model': 'system-model', 'execution.adapter': 'mock' },
business: {
id: 'acme', vars: { 'tracker.baseUrl': 'http://127.0.0.1:3456', 'tracker.pollSeconds': 60 },
projects: { stack: { root: '/synthetic/project' } },
roles: { coder: { definition: 'coder', holder: 'example', vars: { model: 'agent-model', 'limits.network': 'none' }, credentials: {}, tracker: null } },
definitions: { coder: { name: 'coder', tools: ['read', 'write'], network: 'api-only', authority: { withinRole: ['message.send'], crossRole: [] }, contractPath: '/synthetic/roles/coder.md', skills: [] } },
},
project: { id: 'stack', file: '/synthetic/project/.mosaic/project.json', vars: { 'tracker.project': 3, 'tracker.pollSeconds': 20 }, roles: { coder: { vars: { 'tracker.pollSeconds': 15 } } } },
instance: 'coder',
};
@@ -0,0 +1,124 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import * as publicApi from '../src/index.mjs';
import * as direct from '../src/business.mjs';
import { BusinessError, refuse } from '../src/errors.mjs';
import { canonicalJson, sha256 } from '../src/util.mjs';
import { validateRecord } from '../../project/src/validate.mjs';
import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs';
import { input } from './fixtures/rename-a/resolver-input.mjs';
const fixture = name => readFileSync(new URL(`./fixtures/rename-a/${name}`, import.meta.url), 'utf8');
test('v1 resolver serialization, canonical preimage and digest are pinned', () => {
const result = publicApi.resolveInstance(input);
assert.equal(`${JSON.stringify(result, null, 2)}\n`, fixture('resolved.json'));
const { digest, ...preimage } = result;
const canonical = fixture('preimage.json');
assert.equal(`${canonicalJson(preimage)}\n`, canonical);
assert.equal(sha256(canonical.slice(0, -1)), digest);
assert.equal(result.provenance['tracker.pollSeconds'], 'project:stack:roles.coder');
assert.equal(result.provenance.model, 'business:acme:roles.coder');
});
test('v1 business public and direct exports share identity and refusal constructor', () => {
for (const name of ['configDir', 'businessFilePath', 'validateBusinessDocument', 'loadBusiness']) {
assert.equal(publicApi[name], direct[name], name);
}
assert.equal(publicApi.BusinessError, BusinessError);
assert.throws(() => refuse('fixture', 4), error => error instanceof publicApi.BusinessError && error.exitCode === 4 && error.message === 'fixture');
});
test('v1 business document rejects missing, mixed and unknown version fields', t => {
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const doc = businessDoc(root);
const check = value => publicApi.validateBusinessDocument(value, join(root, 'businesses', 'acme.json'), { rolesDir: REPO_ROLES });
assert.equal(check(doc).id, 'acme');
for (const change of [d => delete d.businessVersion, d => { d.businessVersion = 2; }, d => { d.businessVersion = '1'; }, d => { d.organizationVersion = 2; }, d => { d.unexpected = true; }]) {
const candidate = structuredClone(doc); change(candidate);
assert.throws(() => check(candidate), BusinessError);
}
});
test('v1 project layout requires its version and business identity exclusively', () => {
const doc = { layoutVersion: 1, business: 'acme', project: 'stack', name: 'Stack', createdAt: '2026-01-01T00:00:00.000Z' };
assert.deepEqual(validateRecord('project', doc), []);
for (const change of [d => delete d.layoutVersion, d => { d.layoutVersion = 2; }, d => { d.layoutVersion = '1'; }, d => delete d.business, d => { d.organization = 'acme'; }, d => { d.unknown = true; }]) {
const candidate = structuredClone(doc); change(candidate);
assert.notDeepEqual(validateRecord('project', candidate), []);
}
});
test('installed business validate and resolve have identical wrapper and direct responses', async t => {
const { spawnSync } = await import('node:child_process');
const { businessDoc, REPO, rolesCopy, systemConfig, tmp, writeJson } = await import('./helpers.mjs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const config = systemConfig(root), doc = businessDoc(root);
writeJson(join(root, 'config', 'businesses', 'acme.json'), doc);
writeJson(publicApi.projectFilePath(doc.projects.stack.root), { projectVersion: 1, id: 'stack', vars: { 'tracker.project': 3 } });
const env = { PATH: process.env.PATH, HOME: join(root, 'home'), TMPDIR: process.env.TMPDIR, MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesCopy(root) };
const directCli = join(REPO, 'packages/business/src/cli.mjs');
const wrapper = join(REPO, 'scripts/mosaic');
const invoke = (command, args) => {
const p = spawnSync(command, args, { env, cwd: root, encoding: 'utf8' });
assert.equal(p.error, undefined);
return { status: p.status, stdout: p.stdout, stderr: p.stderr };
};
for (const args of [['validate', 'acme'], ['resolve', 'acme', 'coder', '--project', 'stack'], ['validate', 'missing'], ['resolve', 'acme', 'unknown']]) {
const expected = invoke(process.execPath, [directCli, ...args]);
const wrapped = invoke(wrapper, ['business', ...args]);
assert.deepEqual(wrapped, expected, args.join(' '));
assert.equal(expected.status, args[1] === 'missing' ? 4 : args[2] === 'unknown' ? 2 : 0);
assert.doesNotMatch(expected.stdout + expected.stderr, /placeholder-not-a-token/);
}
});
test('org validate interprets an installed-ID-shaped argument as a draft filename', async t => {
const { spawnSync } = await import('node:child_process');
const { templateRender } = await import('../src/org-service.mjs');
const { REPO, writeJson } = await import('./helpers.mjs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
writeJson(join(root, 'acme'), await templateRender('acme', { fixture: 'local' }));
const env = { PATH: process.env.PATH, HOME: root, TMPDIR: process.env.TMPDIR };
const result = spawnSync(join(REPO, 'scripts/mosaic'), ['org', 'validate', 'acme'], { cwd: root, env, encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, 'valid\n');
assert.equal(result.stderr, '');
});
test('installed wrapper pins v1 validate and resolve output, preimage and digest', async t => {
const { spawnSync } = await import('node:child_process');
const { REPO, rolesCopy, systemConfig, writeJson } = await import('./helpers.mjs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const config = systemConfig(root), doc = businessDoc(root);
writeJson(join(root, 'config', 'businesses', 'acme.json'), doc);
writeJson(publicApi.projectFilePath(doc.projects.stack.root), { projectVersion: 1, id: 'stack', vars: { 'tracker.project': 3 } });
const env = { PATH: process.env.PATH, HOME: join(root, 'home'), TMPDIR: process.env.TMPDIR, MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesCopy(root) };
const invoke = args => {
const result = spawnSync(join(REPO, 'scripts/mosaic'), ['business', ...args], { env, cwd: root, encoding: 'utf8' });
assert.deepEqual([result.status, result.stderr], [0, '']);
return result.stdout;
};
const validate = invoke(['validate', 'acme']);
const parsed = JSON.parse(validate);
const resolved = invoke(['resolve', 'acme', 'coder', '--project', 'stack']);
const { digest, ...preimage } = JSON.parse(resolved);
assert.equal(sha256(canonicalJson(preimage)), digest);
for (const [id, expected] of Object.entries(parsed.instances)) {
const { digest: actual, ...record } = JSON.parse(invoke(['resolve', 'acme', id]));
assert.equal(sha256(canonicalJson(record)), actual);
assert.equal(actual, expected);
}
const normalized = text => text.split(root).join('<ROOT>').replace(/[a-f0-9]{64}/g, '<DIGEST>');
const snapshots = {
'installed-validate.json': normalized(validate),
'installed-resolve.json': normalized(resolved),
'installed-preimage.json': normalized(canonicalJson(preimage)) + '\n',
};
assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9');
for (const [name, actual] of Object.entries(snapshots)) {
assert.equal(actual, fixture(name), name);
}
});
+138
View File
@@ -0,0 +1,138 @@
// The organization selector helpers: one table, run against packages/business/src/selector.mjs and
// scripts/selector.sh through the same small parser, so the two halves keep one set of rules.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { REPO } from './helpers.mjs';
import { ID_PATTERN } from '../src/vocabulary.mjs';
import { SELECTOR_FAMILIES, isSelector, selectorScope } from '../src/selector.mjs';
// A parser with one other value option (--note), positional text and a `--` separator.
function viaJs(family, args) {
const scope = selectorScope({ family, valid: family === 'id' ? id => ID_PATTERN.test(id) : null, refuse: kind => { throw Object.assign(new Error(kind), { kind }); } });
const out = { value: null, note: null, positional: [] };
try {
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (scope.owns(a)) { scope.take(a, args[i + 1]); i++; }
else if (a === '--note') out.note = args[++i] ?? null;
else if (a === '--') { out.positional.push(...args.slice(i + 1)); break; }
else out.positional.push(a);
}
} catch (e) { if (e.kind) return { refused: e.kind }; throw e; }
out.value = scope.value ?? null;
return out;
}
const DRIVER = `
set -euo pipefail
. "$1"; family="$2"; shift 2
refuse() { printf 'refused %s\\n' "$1"; exit 3; }
valid() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; }
v=-; [ "$family" = id ] && v=valid
note=""; has_note=0; pos=()
while [ $# -gt 0 ]; do
case "$family:$1" in
id:--business|id:--org|file:--business-file|file:--org-file) selector_take s "$family" refuse "$v" "$1" "\${@:2:1}"; shift 2 ;;
*:--note) has_note=1; note="\${2-}"; shift; [ $# -gt 0 ] && shift ;;
*:--) shift; pos+=("$@"); break ;;
*) pos+=("$1"); shift ;;
esac
done
printf 'value %s\\n' "$(selector_value s)"
[ "$has_note" = 1 ] && printf 'note %s\\n' "$note"
for p in "\${pos[@]+"\${pos[@]}"}"; do printf 'pos %s\\n' "$p"; done
`;
function viaShell(family, args) {
const r = spawnSync('bash', ['-c', DRIVER, 'driver', join(REPO, 'scripts/selector.sh'), family, ...args], { encoding: 'utf8' });
if (r.status === 3) return { refused: /^refused (\S+)$/.exec(r.stdout.trim())[1] };
assert.equal(r.status, 0, r.stderr);
const out = { value: null, note: null, positional: [] };
for (const line of r.stdout.split('\n').filter(Boolean)) {
const [, key, rest] = /^(\S+) ?(.*)$/.exec(line);
if (key === 'value') out.value = rest || null;
else if (key === 'note') out.note = rest;
else out.positional.push(rest);
}
return out;
}
const ok = (value, more = {}) => ({ value, note: null, positional: [], ...more });
const ID_TABLE = [
['old alone', ['--business', 'acme'], ok('acme')],
['new alone', ['--org', 'acme'], ok('acme')],
['matching, old first', ['--business', 'acme', '--org', 'acme'], ok('acme')],
['matching, new first', ['--org', 'acme', '--business', 'acme'], ok('acme')],
['unequal, old first', ['--business', 'acme', '--org', 'beta'], { refused: 'differ' }],
['unequal, new first', ['--org', 'acme', '--business', 'beta'], { refused: 'differ' }],
['old repeated', ['--business', 'acme', '--business', 'acme'], { refused: 'repeated' }],
['new repeated', ['--org', 'acme', '--org', 'acme'], { refused: 'repeated' }],
['new repeated after a match', ['--org', 'acme', '--business', 'acme', '--org', 'acme'], { refused: 'repeated' }],
['old missing', ['--business'], { refused: 'missing' }],
['new missing', ['--org'], { refused: 'missing' }],
['new empty', ['--org', ''], { refused: 'missing' }],
['old flag-shaped', ['--business', '--org', 'acme'], { refused: 'flag' }],
['new flag-shaped', ['--org', '-x'], { refused: 'flag' }],
['old invalid', ['--business', 'Acme'], { refused: 'invalid' }],
['new invalid', ['--org', 'acme/../x'], { refused: 'invalid' }],
['equal but invalid', ['--org', 'A', '--business', 'A'], { refused: 'invalid' }],
['option-like value of another option', ['--note', '--org', '--business', 'acme'], ok('acme', { note: '--org' })],
['option-like value, new spelling', ['--note', '--business', '--org', 'acme'], ok('acme', { note: '--business' })],
['text after the separator', ['--org', 'acme', '--', '--business', 'beta', '--org'], ok('acme', { positional: ['--business', 'beta', '--org'] })],
['neither', ['word'], ok(null, { positional: ['word'] })],
];
const FILE_TABLE = [
['old alone', ['--business-file', 'a.json'], ok('a.json')],
['new alone', ['--org-file', 'a.json'], ok('a.json')],
['identical, old first', ['--business-file', 'a.json', '--org-file', 'a.json'], ok('a.json')],
['identical, new first', ['--org-file', 'a.json', '--business-file', 'a.json'], ok('a.json')],
['different paths, old first', ['--business-file', 'a.json', '--org-file', 'b.json'], { refused: 'differ' }],
['different paths, new first', ['--org-file', 'a.json', '--business-file', 'b.json'], { refused: 'differ' }],
// Strings are compared as given, so two spellings of one path differ.
['same file, other spelling', ['--org-file', 'a.json', '--business-file', './a.json'], { refused: 'differ' }],
['new repeated', ['--org-file', 'a.json', '--org-file', 'a.json'], { refused: 'repeated' }],
['old repeated', ['--business-file', 'a.json', '--business-file', 'a.json'], { refused: 'repeated' }],
['new missing', ['--org-file'], { refused: 'missing' }],
['new flag-shaped', ['--org-file', '--business-file', 'a.json'], { refused: 'flag' }],
['an id spelling is data here', ['--org', 'x', '--org-file', 'a.json'], ok('a.json', { positional: ['--org', 'x'] })],
];
for (const [family, table] of [['id', ID_TABLE], ['file', FILE_TABLE]]) {
for (const [name, args, expected] of table) {
test(`${family} selector: ${name}`, () => {
assert.deepEqual(viaJs(family, args), expected, 'selector.mjs');
assert.deepEqual(viaShell(family, args), expected, 'selector.sh');
});
}
}
test('selector families and spellings are fixed', () => {
assert.deepEqual(SELECTOR_FAMILIES.id.spellings, ['--business', '--org']);
assert.deepEqual(SELECTOR_FAMILIES.file.spellings, ['--business-file', '--org-file']);
assert.equal(isSelector('--org'), true);
assert.equal(isSelector('--org-file'), false);
assert.equal(isSelector('--org-file', 'file'), true);
assert.equal(isSelector('--organization'), false);
});
test('a refuse function that returns still refuses, and scopes are separate', () => {
const quiet = selectorScope({ refuse: () => {} });
assert.throws(() => quiet.take('--org', ''), /selector refusal returned/);
assert.throws(() => quiet.take('--org-file', 'x'), TypeError);
const refuse = kind => { throw new Error(kind); };
const outer = selectorScope({ refuse }), child = selectorScope({ refuse });
outer.take('--org', 'acme'); child.take('--org', 'beta');
assert.deepEqual([outer.value, child.value, outer.given], ['acme', 'beta', true]);
assert.equal(selectorScope({ refuse }).given, false);
});
test('the shell helper refuses a misuse and keeps scopes separate', () => {
const run = body => spawnSync('bash', ['-c', `set -u; . "$1"; refuse() { echo "refused $1"; exit 3; }; quiet() { :; }; ${body}`, 'x', join(REPO, 'scripts/selector.sh')], { encoding: 'utf8' });
assert.equal(run('selector_take s id refuse - --org-file a').status, 70);
assert.equal(run('selector_take bad-name id refuse - --org a').status, 70);
assert.equal(run('selector_take s id quiet - --org ""').status, 70);
const two = run('selector_take outer id refuse - --org acme; selector_take child id refuse - --business beta; echo "$(selector_value outer) $(selector_value child) [$(selector_value none)]"');
assert.equal(two.status, 0, two.stderr);
assert.equal(two.stdout, 'acme beta []\n');
});
+5
View File
@@ -13,6 +13,7 @@ import { join, dirname } from "node:path";
import { BusinessError, accessFilePath, checkAccess, configDir, loadAccess, loadBusiness, LEVELS } from "../../business/src/index.mjs";
import { rolesDir } from "./config.mjs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
import { refuseInsideAgent } from "./transport.mjs";
export const ACCESS_USAGE = [
@@ -70,14 +71,18 @@ export function accessContextFor(system, env) {
// --tester is refused outright on every verb, before any bus call.
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(why => new CliError(`${why}\n${ACCESS_USAGE}`, 4));
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === "--json" || a === "--tester") out.flags.add(a);
else if (selector.owns(a)) { selector.take(a, args[i + 1]); i++; }
else if (a.startsWith("--")) {
if (i + 1 >= args.length || args[i + 1].startsWith("--")) throw new CliError(`${a} needs one value\n${ACCESS_USAGE}`, 4);
out.values[a] = args[++i];
} else out.positional.push(a);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+24 -14
View File
@@ -28,7 +28,7 @@ import { PluginError } from "../../plugins/src/kinds.mjs";
import { CliError } from "./errors.mjs";
import { readNotifyConfig } from "./notify-config.mjs";
export { readNotifyConfig } from "./notify-config.mjs";
import { DEV_OWNER, devOwnerInstance } from "./dev-local-owner.mjs";
import { DEV_OWNER, devOwnerInstance, devLanOptions } from "./dev-local-owner.mjs";
import { bootConfig, loadSystem, orgPaths, socketPath } from "./config.mjs";
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
import { businessFilePath, configDir } from "../../business/src/index.mjs";
@@ -40,8 +40,9 @@ import { quitLine, runApp } from "./app.mjs";
import { readSessions, stopSession } from "../../seat/src/session.mjs";
import { SeatError } from "../../seat/src/seat.mjs";
import { OrgError, resolveOrg, withOrgLifecycle } from "../../business/src/org-registry.mjs";
import { orgSelector } from "./org-selector.mjs";
import { busStart, busStatus, busStop, busRestart, busRecover } from "./org-bus.mjs";
import { consoleStart, consoleStop } from "./org-console.mjs";
import { consoleStart, consoleStatus, consoleStop } from "./org-console.mjs";
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
import { orgCommand, ORG_USAGE } from "./org.mjs";
@@ -83,8 +84,8 @@ export const USAGE = `usage:
mosaic bus start <id> [--pm | --no-launch] [--dev-local-owner] [--detach]
mosaic bus stop|restart|recover <id>
mosaic bus status <id> [--json]
mosaic console start [--dev-local-owner] [--detach] [--port auto|N]
mosaic console stop
mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
mosaic console stop|status
${DEV_USAGE}
${RUN_USAGE}`;
@@ -97,17 +98,22 @@ export const SEAT_USAGE = [
const usage = (why) => new CliError(why ? `${why}\n${USAGE}` : USAGE, 4);
// A verb that takes --business also takes --org, in this parser's scope only, so a value another
// option consumes or positional text is never read as a selector (rename design r2).
function parse(args, { flags = [], values = [] }) {
const out = { positional: [], flags: new Set(), values: {} };
const selector = values.includes("--business") ? orgSelector(usage) : null;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (flags.includes(a)) out.flags.add(a);
if (selector?.owns(a)) { selector.take(a, args[i + 1]); i++; }
else if (flags.includes(a)) out.flags.add(a);
else if (values.includes(a)) {
if (i + 1 >= args.length || Object.hasOwn(out.values, a)) throw usage(`${a} needs one value`);
out.values[a] = args[++i];
} else if (a.startsWith("--")) throw usage(`unknown option ${a}`);
else out.positional.push(a);
}
if (selector?.given) out.values["--business"] = selector.value;
return out;
}
@@ -555,10 +561,21 @@ async function consoleCommand(rest, io, deps) {
io.stdout.write(`${JSON.stringify(await consoleStop(organizationContext(io, deps), { warn: s => io.stderr.write(s) }))}\n`);
return;
}
if (sub === "status") {
if (args.length) throw usage("console status takes no arguments");
io.stdout.write(`${JSON.stringify(consoleStatus(organizationContext(io, deps), { warn: s => io.stderr.write(s) }))}\n`);
return;
}
if (sub !== "start") throw usage();
const parsed = parse(args, { flags: ["--dev-local-owner", "--detach"], values: ["--port"] });
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");
const receipt = await consoleStart(organizationContext(io, deps), { devLocalOwner: parsed.flags.has("--dev-local-owner"), detach: parsed.flags.has("--detach"), port: parsed.values["--port"] ?? "auto", warn: s => io.stderr.write(s) });
// org-lan-bind: refused here, before any config is read, unless both flags come with
// --dev-local-owner. ADDR is 0.0.0.0 or an IPv4 address; NAME[,NAME...] has no ports.
const devOwner = parsed.flags.has("--dev-local-owner"), bind = parsed.values["--dev-insecure-bind"] ?? null;
const names = Object.hasOwn(parsed.values, "--dev-allow-host") ? [parsed.values["--dev-allow-host"]] : [];
let lan;
try { lan = devLanOptions({ devOwner, bind, names }); } catch (err) { throw usage(err.message); }
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;
@@ -671,13 +688,6 @@ async function app(io, deps) {
export async function main(argv, io = { env: process.env, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, deps = {}) {
if (argv.length === 0) return app(io, deps);
if (argv[0] !== 'org') {
const org = argv.indexOf('--org'), business = argv.indexOf('--business');
if (org !== -1 && business !== -1) {
if (!argv[org + 1] || !argv[business + 1] || argv[org + 1] !== argv[business + 1]) throw usage('--org and --business conflict');
argv = argv.filter((_, i) => i !== org && i !== org + 1);
} else if (org !== -1) argv = argv.map((arg, i) => i === org ? '--business' : arg);
}
const [verb, ...rest] = argv;
if (verb === 'org') return orgCommand(rest, io, deps);
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
+7 -2
View File
@@ -3,6 +3,7 @@
// is the human's; with --tester (Q173) only status and revoke. issue and rotate read the token on
// stdin, never argv, and nothing here prints it: the broker's answer carries the kind, date and state.
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const CREDENTIALS_USAGE = "mosaic credentials [status [AGENT]] [--json] [--tester] | issue|rotate AGENT gitea --rotate-by DATE | " +
"issue|rotate AGENT vikunja --expires DATE | issue|rotate AGENT host.ID --rotate-by DATE | " +
@@ -15,15 +16,19 @@ const MAX_TOKEN = 16384;
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(CREDENTIALS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--business", "--rotate-by", "--expires", "--reason"].includes(arg)) {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--rotate-by", "--expires", "--reason"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(CREDENTIALS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(CREDENTIALS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+49 -13
View File
@@ -7,9 +7,10 @@
// so a launch runs the real launcher, sandbox, runner and pi against it.
// Refuses: the default dev instance, an instance with a proxy configured, a
// provider credential in the environment, a non-loopback endpoint and a second
// fixture. Exit codes as errors.mjs: 2 invalid, 3 refused, 4 usage.
// fixture. A record whose endpoint is gone (its lifetime ended, or it crashed)
// is stale: init replaces it. Exit codes as errors.mjs: 2 invalid, 3 refused, 4 usage.
import { spawn } from 'node:child_process';
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { closeSync, constants, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { get } from 'node:http';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -19,9 +20,10 @@ import { loadSystem } from './config.mjs';
import { parseArgs } from './thread.mjs';
import { isProviderVariable } from '../../seat/src/session.mjs';
import { FIXTURE_ENTRY, FIXTURE_KEY, FIXTURE_KEY_NAME, FIXTURE_MODEL } from '../../plugins/model-provider/fixture/index.mjs';
import { TTL_DEFAULT_S, TTL_MAX_S, TTL_MIN_S } from '../../plugins/model-provider/fixture/endpoint.mjs';
export const ENDPOINT = resolve(dirname(fileURLToPath(import.meta.url)), '../../plugins/model-provider/fixture/endpoint.mjs');
export const DEV_USAGE = `mosaic dev fixture init --model deterministic [--instance NAME] [--bind 127.0.0.1] [--json]
export const DEV_USAGE = `mosaic dev fixture init --model deterministic [--instance NAME] [--bind 127.0.0.1] [--ttl SECONDS] [--json]
mosaic dev fixture status [--json]
mosaic dev fixture stop [--json]`;
export const fixtureDir = dataRoot => join(dataRoot, 'dev-fixture');
@@ -93,11 +95,21 @@ function instanceOf(io, parsed) {
return instance;
}
function startEndpoint(logFile, { timeoutMs = 10000 } = {}) {
// --ttl: the endpoint's lifetime in whole seconds, TTL_DEFAULT_S when absent. 0 is no expiry,
// for a long-running dev instance; it is safe only because this command runs on a named dev
// instance and nothing else starts the endpoint with 0.
function ttlOf(raw) {
if (raw === undefined) return TTL_DEFAULT_S;
const n = /^(0|[1-9][0-9]{0,6})$/.test(raw) ? Number(raw) : NaN;
if (n === 0 || (n >= TTL_MIN_S && n <= TTL_MAX_S)) return n;
throw invalid(`--ttl takes 0 (no expiry) or whole seconds from ${TTL_MIN_S} to ${TTL_MAX_S}`);
}
function startEndpoint(logFile, ttlSeconds, { timeoutMs = 10000 } = {}) {
const log = openSync(logFile, 'a', 0o600);
let child;
try {
child = spawn(process.execPath, [ENDPOINT], { detached: true, stdio: ['ignore', 'pipe', log], env: { PATH: process.env.PATH ?? '/usr/bin:/bin' } });
child = spawn(process.execPath, [ENDPOINT], { detached: true, stdio: ['ignore', 'pipe', log], env: { PATH: process.env.PATH ?? '/usr/bin:/bin', MOSAIC_FIXTURE_TTL_S: String(ttlSeconds) } });
} finally { closeSync(log); }
return new Promise((resolve, reject) => {
let out = '';
@@ -114,32 +126,56 @@ function startEndpoint(logFile, { timeoutMs = 10000 } = {}) {
});
}
// Remove the stale record this init inspected, and only that one. The record is taken by an
// atomic rename, then checked: if a concurrent init already replaced it with a live endpoint's
// record, that record goes back (link refuses to overwrite) and this init refuses fixture-exists.
// An unlink by path after the check would delete the other init's new record instead.
export function takeStale(dataRoot, existing, instance) {
const file = fixtureFile(dataRoot), aside = `${file}.stale-${process.pid}`;
try { renameSync(file, aside); } catch (e) { if (e.code === 'ENOENT') return; throw e; }
let taken = null;
try { taken = JSON.parse(readFileSync(aside, 'utf8')); } catch {}
if (taken?.pid === existing.pid && taken?.startedAt === existing.startedAt) { rmSync(aside, { force: true }); return; }
// EEXIST means a third init published meanwhile; that record stays and this init still refuses.
try { linkSync(aside, file); } catch {} finally { rmSync(aside, { force: true }); }
throw refuse('fixture-exists', `instance ${instance} already has a fixture; mosaic dev fixture stop removes it`);
}
const print = (io, json, value, line) => io.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : `${line}\n`);
async function init(args, io, deps) {
const parsed = parseArgs(args, `usage: ${DEV_USAGE}`, { flags: ['--json'], values: ['--model', '--instance', '--bind'] });
const parsed = parseArgs(args, `usage: ${DEV_USAGE}`, { flags: ['--json'], values: ['--model', '--instance', '--bind', '--ttl'] });
if (parsed.positional.length) throw invalid('fixture init takes no positional arguments');
if (parsed.values['--model'] !== 'deterministic') throw invalid('fixture init needs --model deterministic');
const ttlSeconds = ttlOf(parsed.values['--ttl']);
const bind = parsed.values['--bind'] ?? '127.0.0.1';
if (bind !== '127.0.0.1') throw refuse('fixture-endpoint-not-loopback', `the fixture binds 127.0.0.1 only, not ${bind}`);
const instance = instanceOf(io, parsed);
noCredentials(io.env);
const system = deps.system ?? loadSystem({ env: io.env, instance });
if (system.proxy) throw refuse('fixture-proxy-configured', `instance ${instance} has a model proxy configured; the fixture is for an instance without one`);
if (readFixture(system.dataRoot)) throw refuse('fixture-exists', `instance ${instance} already has a fixture; mosaic dev fixture stop removes it`);
const existing = readFixture(system.dataRoot);
if (existing && isEndpoint(existing.pid)) throw refuse('fixture-exists', `instance ${instance} already has a fixture; mosaic dev fixture stop removes it`);
// P19 #882: an endpoint whose lifetime ended left its record, and init refused fixture-exists
// until `dev fixture stop`. A record whose pid is no longer the endpoint is stale; it goes.
// A second init racing this one still loses at the exclusive write of the new record.
if (existing) takeStale(system.dataRoot, existing, instance);
const replaced = existing ? { pid: existing.pid, startedAt: existing.startedAt ?? null } : null;
try { mkdirSync(fixtureDir(system.dataRoot), { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw e; }
ownedPrivate(fixtureDir(system.dataRoot), true);
const { child, port } = await startEndpoint(join(fixtureDir(system.dataRoot), 'endpoint.log'));
const { child, port } = await startEndpoint(join(fixtureDir(system.dataRoot), 'endpoint.log'), ttlSeconds);
const endpoint = `http://127.0.0.1:${port}`;
const started = new Date();
const record = { fixtureVersion: 1, fixture: 'fixture', model: 'deterministic', modelKey: FIXTURE_KEY_NAME, entry: FIXTURE_ENTRY,
instance, endpoint, port, pid: child.pid, principal: PRINCIPAL, upstream: 'none', startedAt: new Date().toISOString() };
instance, endpoint, port, pid: child.pid, principal: PRINCIPAL, upstream: 'none', startedAt: started.toISOString(),
ttlSeconds, expiresAt: ttlSeconds ? new Date(started.getTime() + ttlSeconds * 1000).toISOString() : null };
try {
if (!(await health(endpoint))?.ok) throw refuse('fixture-start-failed', 'the endpoint did not answer its health check');
writeFileSync(fixtureFile(system.dataRoot), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
} catch (e) { try { process.kill(child.pid, 'SIGTERM'); } catch {} throw e; }
const receipt = { receipt: 'dev-fixture-init', ...record, loopback: true, external: false, state: fixtureFile(system.dataRoot) };
const receipt = { receipt: 'dev-fixture-init', ...record, replaced, loopback: true, external: false, state: fixtureFile(system.dataRoot) };
print(io, parsed.flags.has('--json'), receipt,
`fixture ready: instance ${instance}, model ${FIXTURE_MODEL} (launch.models key ${FIXTURE_KEY_NAME}), endpoint ${endpoint} (loopback, no upstream), principal ${PRINCIPAL}, pid ${child.pid}`);
`fixture ready: instance ${instance}, model ${FIXTURE_MODEL} (launch.models key ${FIXTURE_KEY_NAME}), endpoint ${endpoint} (loopback, no upstream), principal ${PRINCIPAL}, pid ${child.pid}, ${ttlSeconds ? `expires ${record.expiresAt}` : 'no expiry'}${replaced ? `; replaced the stale record of pid ${replaced.pid}` : ''}`);
}
async function status(args, io, deps) {
@@ -153,8 +189,8 @@ async function status(args, io, deps) {
const answer = alive ? await health(record.endpoint) : null;
const report = { instance, ready: Boolean(alive && answer?.ok && !system.proxy), endpoint: record.endpoint, loopback: true, upstream: 'none', external: false,
model: record.model, modelKey: record.modelKey, entry: record.entry, principal: record.principal, pid: record.pid, alive,
proxyConfigured: Boolean(system.proxy), health: answer, startedAt: record.startedAt };
print(io, parsed.flags.has('--json'), report, `fixture ${report.ready ? 'ready' : 'not ready'}: ${record.endpoint}, pid ${record.pid}${alive ? '' : ' (not running)'}`);
proxyConfigured: Boolean(system.proxy), health: answer, startedAt: record.startedAt, ttlSeconds: record.ttlSeconds ?? null, expiresAt: record.expiresAt ?? null };
print(io, parsed.flags.has('--json'), report, `fixture ${report.ready ? 'ready' : 'not ready'}: ${record.endpoint}, pid ${record.pid}${alive ? '' : ' (not running; init replaces the record)'}`);
}
async function stop(args, io, deps) {
+30
View File
@@ -1,4 +1,5 @@
import { realpathSync } from 'node:fs';
import { isIP } from 'node:net';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { configDir } from '../../business/src/business.mjs';
@@ -17,3 +18,32 @@ export function devOwnerInstance(env = process.env) {
catch (err) { if (err.message === refusal) throw err; if (err.code !== 'ENOENT') throw new Error(refusal); }
return match[1];
}
// org-lan-bind: a name for --dev-allow-host is lowercase DNS labels or an IPv4 address, with no
// port; the Console then accepts the Host NAME:<bound port>.
const HOST_LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
export function devHostName(name) {
if (typeof name !== 'string' || !name || name.length > 253) return false;
if (isIP(name) === 4) return true;
return !/^[0-9.]+$/.test(name) && name.split('.').every(label => HOST_LABEL.test(label));
}
// --dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]: the dev owner Console on the LAN with
// no sign-in. `names` holds the raw --dev-allow-host values. Returns null when neither flag is
// given, else { bind, allowHosts }; throws the refusal otherwise.
export function devLanOptions({ devOwner = false, bind = null, names = [] } = {}) {
if (bind === null || bind === undefined) {
if (names.length) throw new Error('--dev-allow-host needs --dev-insecure-bind');
return null;
}
if (!devOwner) throw new Error('--dev-insecure-bind needs --dev-local-owner');
if (typeof bind !== 'string' || isIP(bind) !== 4) throw new Error('--dev-insecure-bind must be 0.0.0.0 or an IPv4 address');
if (!names.length) throw new Error('--dev-insecure-bind needs at least one --dev-allow-host');
const allowHosts = [];
for (const name of names.flatMap(value => typeof value === 'string' ? value.split(',') : [value])) {
if (!devHostName(name)) throw new Error('--dev-allow-host takes host names or IPv4 addresses without a port, comma-separated');
if (!allowHosts.includes(name)) allowHosts.push(name);
}
// The printed URL must pass the Host check, so a specific address is also an allowed name.
if (bind !== '0.0.0.0' && bind !== '127.0.0.1' && !allowHosts.includes(bind)) throw new Error('--dev-insecure-bind ADDR other than 0.0.0.0 must also be a --dev-allow-host name');
return { bind, allowHosts };
}
+7 -2
View File
@@ -6,6 +6,7 @@
// scope reads the chief of staff's grant scope and scope set writes it from JSON on stdin; both are
// the human's, and the set is the human's Approve of the change.
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const GRANTS_USAGE =
"mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " +
@@ -15,13 +16,16 @@ export const GRANTS_USAGE =
"scope [--business ID] | scope set --revision REV [--business ID] (the scope JSON on stdin)";
const MAX_TOKEN = 16384;
const MAX_SCOPE = 65536;
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by", "--revision"];
const VALUES = ["--reason", "--url", "--account", "--rotate-by", "--revision"];
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(GRANTS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester" || arg === "--finish") {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester" || arg === "--finish") {
if (out.flags.has(arg)) throw new CliError(GRANTS_USAGE, 4);
out.flags.add(arg);
} else if (VALUES.includes(arg)) {
@@ -30,6 +34,7 @@ function parse(args) {
} else if (arg.startsWith("--")) throw new CliError(GRANTS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+7 -2
View File
@@ -4,21 +4,26 @@
// reads a key: the answers carry fingerprints only. Row 235 (#1741, r6 section 6, X3) adds revoke, which
// takes an agent's grant on a host away at once, and sweep, which removes the lines a failure left.
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const HOSTS_USAGE =
"mosaic hosts [status] [--json] [--tester] [--business ID] | check|remove|sweep HOST [--tester] [--business ID] | revoke AGENT HOST --reason R [--tester] [--business ID]";
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(HOSTS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (arg === "--business" || arg === "--reason") {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (arg === "--reason") {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(HOSTS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(HOSTS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+7 -2
View File
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import { loadSystem } from "./config.mjs";
import { systemVars } from "../../business/src/index.mjs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
import { migrateLaunch } from "../../business/src/migrate-launch.mjs";
// Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition.
@@ -34,15 +35,19 @@ export function formatTokens(tokens) {
export function parseLimits(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(LIMITS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--file", "--models", "--roles-dir", "--business"].includes(arg)) {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--file", "--models", "--roles-dir"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(LIMITS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(LIMITS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+6 -1
View File
@@ -3,6 +3,7 @@ import { configDir } from '../../business/src/business.mjs';
import { createProxyLock } from '../../bus/src/proxy-lock.mjs';
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
import { CliError } from './errors.mjs';
import { orgSelector } from './org-selector.mjs';
import { refuseInsideAgent } from './transport.mjs';
import { fetchPinned } from '../../model-proxy/src/fetch.mjs';
import { validManagementKey, proxyTiming } from '../../model-proxy/src/common.mjs';
@@ -60,11 +61,15 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
}
if (!['fetch', 'init'].includes(verb)) throw failure('proxy-usage', 4);
const parsed = { flags: new Set(), values: {} }; let port;
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => failure('proxy-usage', 4));
for (let i = 0; i < rest.length; i++) {
const flag = rest[i];
if (verb !== 'init' || !['--business', '--port', '--rotate-grace-minutes', '--first-output-seconds'].includes(flag) || parsed.values[flag] !== undefined || !rest[i + 1]) throw failure('proxy-usage', 4);
if (verb === 'init' && selector.owns(flag)) { selector.take(flag, rest[i + 1]); i++; continue; }
if (verb !== 'init' || !['--port', '--rotate-grace-minutes', '--first-output-seconds'].includes(flag) || parsed.values[flag] !== undefined || !rest[i + 1]) throw failure('proxy-usage', 4);
parsed.values[flag] = rest[++i];
}
if (selector.given) parsed.values['--business'] = selector.value;
if (parsed.values['--port'] !== undefined) {
if (!/^[0-9]+$/.test(parsed.values['--port'])) throw failure('proxy-config-invalid');
port = Number(parsed.values['--port']);
+34 -6
View File
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
import { listOrgs, resolveOrg } from '../../business/src/org-registry.mjs';
import { cmdlineOf, startTimeOf } from './host.mjs';
import { CliError } from './errors.mjs';
import { devLanOptions } from './dev-local-owner.mjs';
const WEBUI = fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url));
const forbidden = new Set([7332, 7340, 7341, 33223, 17450, 17443, 44307]);
@@ -25,8 +26,14 @@ function state(ctx) {
try { data = JSON.parse(readFileSync(fd, 'utf8')); } catch { fail('console state is unreadable'); }
} finally { closeSync(fd); }
if (!Number.isSafeInteger(data?.pid) || data.pid <= 0 || typeof data.startTime !== 'string' || !data.startTime || typeof data.url !== 'string') fail('console state is invalid');
// org-lan-bind: a LAN-exposed Console records where it listens and the names it answers.
if (data.bind !== undefined || data.allowHosts !== undefined) {
try { if (!Array.isArray(data.allowHosts) || data.allowHosts.some(name => typeof name !== 'string' || name.includes(',')) || !devLanOptions({ devOwner: true, bind: data.bind, names: data.allowHosts })) throw new Error(); }
catch { fail('console state is invalid'); }
}
return data;
}
const lanWarning = (bind, port) => `WARNING: dev owner Console listens on ${bind}:${port} with no sign-in; anyone who can reach it acts as the owner.\n`;
function checkDir(ctx) {
mkdirSync(directory(ctx), { recursive: true, mode: 0o700 });
const st = lstatSync(directory(ctx));
@@ -57,9 +64,13 @@ function takeLock(ctx) {
let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); }
return () => { closeSync(fd); rmSync(path, { force: true }); };
}
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', warn = s => process.stderr.write(s) } = {}) {
// `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;
try {
@@ -68,7 +79,8 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false,
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'] : [])];
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] }); }
@@ -78,14 +90,18 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false,
const deadline = Date.now() + 30000;
while (Date.now() < deadline && !exited) {
const text = readFileSync(log, 'utf8');
const match = /Mosaic Console: (http:\/\/127\.0\.0\.1:(\d+)\/)/.exec(text);
// The URL is loopback, or the specific address a LAN Console binds.
const match = /Mosaic Console: (http:\/\/([0-9.]+):(\d+)\/)/.exec(text);
if (match) {
if (forbidden.has(Number(match[2]))) fail('console selected a forbidden port');
if (match[2] !== '127.0.0.1' && match[2] !== lan?.bind) fail('console printed an unexpected address');
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] }) + '\n', { mode: 0o600, flag: 'wx' });
writeFileSync(stateFile(ctx), JSON.stringify({ pid: child.pid, startTime, url: match[1], ...(lan ?? {}) }) + '\n', { mode: 0o600, flag: 'wx' });
if (detach) child.unref();
const result = { url: match[1], pid: child.pid };
// 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;
}
@@ -121,3 +137,15 @@ export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } =
fail('console did not stop');
} finally { release(); }
}
// Read-only: what the state file says and whether that process still runs. It never moves a
// stale state file (start and stop do that under the start lock).
export function consoleStatus(ctx, { warn = s => process.stderr.write(s) } = {}) {
const s = state(ctx);
if (!s) return { running: false };
const running = liveConsole(s);
const bind = s.bind ?? '127.0.0.1';
const port = /:(\d+)\/$/.exec(s.url)?.[1] ?? 'unknown';
// The same warning start printed, for as long as a Console started with the LAN flags runs.
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 } : {}) };
}
+17
View File
@@ -0,0 +1,17 @@
// The organization selector for the CLI's own parsers (rename design r2, "Selector scopes and
// parser matrix"): --org and --business in one parser's scope, under packages/business/src/
// selector.mjs. An id outside the organization grammar refuses as resolveOrg does, OrgError
// org-invalid with exit 3, but while parsing, so no verb reads stdin, prompts for a key or reads a
// business file first. Every other refusal is the parser's own usage error.
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { OrgError } from '../../business/src/org-registry.mjs';
import { selectorScope } from '../../business/src/selector.mjs';
// usageError(message) returns the parser's usage error for a missing, flag-shaped, repeated or
// conflicting selector.
export function orgSelector(usageError) {
return selectorScope({
valid: id => ID_PATTERN.test(id),
refuse: (kind, why) => { throw kind === 'invalid' ? new OrgError('org-invalid', 'invalid organization id') : usageError(why); },
});
}
+7 -2
View File
@@ -5,20 +5,25 @@
// `tester`) in the organization's changes.jsonl.
import { readFileSync } from "node:fs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester]";
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(PLUGINS_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--file", "--business"].includes(arg)) {
if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (arg === "--file") {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(PLUGINS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(PLUGINS_USAGE, 4);
else out.positional.push(arg);
}
if (selector.given) out.values["--business"] = selector.value;
return out;
}
+18 -2
View File
@@ -1,9 +1,12 @@
import { configDir, loadBusiness } from '../../business/src/index.mjs';
import { checkProject, initProject, ProjectError, testerProjectArea } from '../../project/src/index.mjs';
import { checkProject, initProject, ProjectError, testerProjectArea, recordContext } from '../../project/src/index.mjs';
import { loadSystem, rolesDir } from './config.mjs';
import { CliError } from './errors.mjs';
import { shortId } from './format.mjs';
import { parseArgs } from './thread.mjs';
import { basename, join } from 'node:path';
import { lstatSync } from 'node:fs';
import { readRegistry, resolveOrg } from '../../business/src/org-registry.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const PROJECT_USAGE = 'mosaic project init <business> <project> [--tester] | check <business> <project>';
@@ -77,7 +80,20 @@ export async function projectCommand(args, io, deps = {}, callFor) {
try {
const system = deps.system ?? loadSystem({ env: io.env });
const business = loadBusiness(id, { dir: configDir(io.env), rolesDir: rolesDir(io.env) });
const context = { business, project, dataRoot: system.dataRoot };
const dir = configDir(io.env), named = /^mosaic-([a-z][a-z0-9]{1,15})$/.exec(basename(dir));
let orgRoot = null;
let registryPresent = false;
if (named) { try { lstatSync(join(dir, 'org-runtime.json')); registryPresent = true; } catch (e) { if (e.code !== 'ENOENT') throw e; } }
if (named && registryPresent) {
const registry = await readRegistry({ configDir: dir });
if (registry?.orgs?.[id]) {
const org = await resolveOrg({ configDir: dir, dataRoot: system.dataRoot, instance: named[1] }, id);
if (org.state !== 'active') throw new CliError('organization is not active', 3);
orgRoot = org.root;
}
}
if (tester && orgRoot) throw new CliError('organization project init --tester is unavailable', 3);
const context = recordContext({ business, project, dataRoot: system.dataRoot, orgRoot });
if (tester) {
testerProjectArea(context);
await callFor({ positional: [], flags: new Set(['--tester']), values: { '--business': id } })('agents', {});
+6 -1
View File
@@ -7,6 +7,7 @@
// on the tester's own threads (owner `tester`, row 177). The broker's ownership check is the only one.
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
import { shortId } from "./format.mjs";
import { configDir, loadBusiness } from "../../business/src/index.mjs";
import { rolesDir } from "./config.mjs";
@@ -26,15 +27,19 @@ export const THREAD_TALK_USAGE = [
export function parseArgs(args, usage, { flags = [], values = [] } = {}) {
const out = { positional: [], flags: new Set(), values: {} };
const refuse = (why) => new CliError(`${why}\n${usage}`, 4);
// A command that takes --business also takes --org, in this parser's scope (rename design r2).
const selector = values.includes("--business") ? orgSelector(refuse) : null;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (flags.includes(a)) out.flags.add(a);
if (selector?.owns(a)) { selector.take(a, args[i + 1]); i++; }
else if (flags.includes(a)) out.flags.add(a);
else if (values.includes(a)) {
if (i + 1 >= args.length || Object.hasOwn(out.values, a)) throw refuse(`${a} needs one value`);
out.values[a] = args[++i];
} else if (a.startsWith("--")) throw refuse(`unknown option ${a}`);
else out.positional.push(a);
}
if (selector?.given) out.values["--business"] = selector.value;
return out;
}
+5 -1
View File
@@ -1,13 +1,17 @@
import { CliError } from './errors.mjs';
import { orgSelector } from './org-selector.mjs';
export const USAGE_USAGE = 'mosaic usage [--business ID] [--json] [--tester]';
export async function usageCommand(args, io, callFor) {
const parsed = { flags: new Set(), values: {} };
// --org and --business are one selector in this parser's scope (rename design r2).
const selector = orgSelector(() => new CliError(USAGE_USAGE, 4));
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if ((arg === '--json' || arg === '--tester') && !parsed.flags.has(arg)) parsed.flags.add(arg);
else if (arg === '--business' && args[i + 1] && !args[i + 1].startsWith('--') && !parsed.values[arg]) parsed.values[arg] = args[++i];
else if (selector.owns(arg)) { selector.take(arg, args[i + 1]); i++; }
else throw new CliError(USAGE_USAGE, 4);
}
if (selector.given) parsed.values['--business'] = selector.value;
const result = await callFor(parsed)('usage', {});
if (parsed.flags.has('--json')) { io.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; }
const counts = row => `input=${row.input} output=${row.output} cacheWrite=${row.cacheWrite} cacheRead=${row.cacheRead} unknown=${row.unknownTurns}`;
+11 -1
View File
@@ -251,7 +251,8 @@ test("an agent caller is refused before the bus is asked", async (t) => {
test("--tester is refused on every verb before the bus is asked", async (t) => {
const s = setup(t);
for (const verb of [["list"], [...GRANT, "--revision", "1"], ["revoke", "--user", "alice", "--revision", "1"]]) {
const args = [...verb, "--business", "acme", "--tester"];
// GRANT already names the business; a repeated --business refuses (rename design r2).
const args = verb.includes("--business") ? [...verb, "--tester"] : [...verb, "--business", "acme", "--tester"];
const err = await refusal(run(args, s.deps), /not --tester/);
assert.equal(err.exitCode, 3);
}
@@ -508,3 +509,12 @@ test("R4 the help keeps the provider-removal order and says what a refused reloa
assert.match(text, /grant and revoke ask the bus to re-read the file after the write\. If the bus refuses, the write still stands \(exit 0\), stderr names the refusal, and the bus's timer applies the change within 30 s\./);
assert.ok(text.indexOf("so do that second") < text.indexOf("re-read the file"), "next to the provider-removal order");
});
test('v1 grant receipt preserves business identity and exact JSON line shape', async t => {
const s = setup(t);
await run([...GRANT, '--revision', '1'], s.deps);
const raw = s.receipts();
const grant = s.read().grants[0].id;
assert.match(grant, /^g-[0-9a-f]{8}$/);
assert.equal(raw, JSON.stringify({ at: '2026-10-20T20:00:00.000Z', verb: 'grant', business: 'acme', user: 'alice', actor: 'human', revisionBefore: 1, revisionAfter: 2, grants: [grant] }) + '\n');
});
+96
View File
@@ -437,6 +437,7 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
if(fault==='inner'||fault==='outer'){
observedScript=join(h.dir,'case1-p10-observed.sh');timingFile=join(h.hc,'probe-uptime');
put(timingFile,'',0o600);put(observedScript,instrumentP10Probe(read(SCRIPT)),0o700);
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
}
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
@@ -783,6 +784,101 @@ test("row 195 accept: usage errors and a run without --root as a user exit 2", (
assert.ok(!existsSync(join(h.hc, "calls.log")));
});
// The selector (rename design r2): --org and --business are one option in each of three scopes, the
// script's own arguments and the args file's unit and serve arguments either side of --. A spelling
// may appear once per scope, and the other spelling only with the same value; never counted across --.
const SELECT = { old: ["--business", "mosaic-stack"], new: ["--org", "mosaic-stack"],
both: ["--business", "mosaic-stack", "--org", "mosaic-stack"], swapped: ["--org", "mosaic-stack", "--business", "mosaic-stack"] };
// The args file with outer unit selector tokens and serve selector tokens in ARGS's places.
const argsWith = (outer, serve) => [...ARGS.slice(0, 6), ...outer, ...ARGS.slice(8, 16), "--port", "732", ...serve, "--tester-socket"];
const invocation = (h, sel) => [...sel, "--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", NODE_PIN, "--root", h.root];
test("row 195 accept: --org or --business names the business in each of the three scopes, and talk and precheck still get --business", (t) => {
const runs = [["old", "old", "old"], ["new", "new", "new"], ["both", "swapped", "both"], ["swapped", "both", "swapped"],
["new", "old", "new"], ["old", "new", "old"], ["both", "old", "swapped"]];
const seen = [];
for (const [inv, outer, serve] of runs) {
const h = host(t), name = `${inv} ${outer} -- ${serve}`;
put(h.args, argsWith(SELECT[outer], SELECT[serve]).join("\n") + "\n", 0o644);
const r = run(h, {}, invocation(h, SELECT[inv]));
assert.equal(r.status, 0, `${name}: ${r.out}${r.err}`);
assert.deepEqual(failed(r), [], name);
const talk = calls(h, "mosaic").filter((a) => a.startsWith("talk "));
assert.equal(talk.length, 1, name);
assert.match(talk[0], /^talk chief case1-accept-[0-9a-f]{12}: .* --business mosaic-stack --tester --wait 300$/, name);
assert.deepEqual(calls(h, "precheck"), ["--business mosaic-stack --profile factory"], name);
seen.push(r.lines.filter((l) => /^[FP][0-9]+ /.test(l)).map((l) => l.split(" ").slice(0, 3).join(" ")));
}
for (const marks of seen) assert.deepEqual(marks, seen[0]);
});
test("row 195 accept: a bad selector in any scope, or scopes that name different businesses, refuse with exit 2 before any check", (t) => {
const usage = "usage: case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N\n";
const refusing = (why) => `case1-accept: refusing: ${why}\n`;
const before = (why) => refusing(`/etc/mosaic-stack/dev.args before --: ${why}`);
const after = (why) => refusing(`/etc/mosaic-stack/dev.args after --: ${why}`);
const cases = [
// The script's own arguments.
["invocation unequal", ["--business", "mosaic-stack", "--org", "beta"], null, refusing("--business mosaic-stack and --org beta conflict")],
["invocation unequal, new first", ["--org", "mosaic-stack", "--business", "beta"], null, refusing("--org mosaic-stack and --business beta conflict")],
["invocation old twice", ["--business", "mosaic-stack", "--business", "mosaic-stack"], null, refusing("--business given twice")],
["invocation new twice", ["--org", "mosaic-stack", "--org", "mosaic-stack"], null, refusing("--org given twice")],
["invocation new empty", ["--org", ""], null, usage],
["invocation new flag-shaped", ["--org", "--args"], null, usage],
["invocation new invalid", ["--org", "Mosaic"], null, usage],
["invocation old invalid", ["--business", "a/../b"], null, usage],
// The args file's unit arguments.
["unit unequal", ["--org", "mosaic-stack"], argsWith(["--business", "mosaic-stack", "--org", "beta"], SELECT.old), before("--business mosaic-stack and --org beta conflict")],
["unit unequal, new first", ["--org", "mosaic-stack"], argsWith(["--org", "mosaic-stack", "--business", "beta"], SELECT.old), before("--org mosaic-stack and --business beta conflict")],
["unit old twice", ["--org", "mosaic-stack"], argsWith(["--business", "mosaic-stack", "--business", "mosaic-stack"], SELECT.old), before("--business given twice")],
["unit new twice", ["--org", "mosaic-stack"], argsWith(["--org", "mosaic-stack", "--org", "mosaic-stack"], SELECT.new), before("--org given twice")],
["unit new missing", SELECT.old, [...ARGS.slice(0, 6), ...ARGS.slice(8, 15), "--org"], before("--org needs one value")],
["unit new flag-shaped", SELECT.old, argsWith(["--org"], SELECT.old), before("--org needs one value, not --copy")],
["unit new invalid", SELECT.old, argsWith(["--org", "Mosaic"], SELECT.old), before("--org Mosaic is not valid")],
["unit names another business", SELECT.new, argsWith(["--org", "beta"], SELECT.new), refusing("/etc/mosaic-stack/dev.args is for business beta, not mosaic-stack")],
// --home takes --org as its value, so --business is the unit's one selector and the home refuses.
["unit --home value", SELECT.old, argsWith(["--business", "mosaic-stack"], SELECT.old).map((a, i) => (i === 5 ? "--org" : a)),
refusing("/etc/mosaic-stack/dev.args lacks a plain --user, --home, --business, --node, --claude or --copy")],
// The args file's serve arguments.
["serve unequal", SELECT.old, argsWith(SELECT.old, ["--business", "mosaic-stack", "--org", "beta"]), after("--business mosaic-stack and --org beta conflict")],
["serve unequal, new first", SELECT.old, argsWith(SELECT.old, ["--org", "mosaic-stack", "--business", "beta"]), after("--org mosaic-stack and --business beta conflict")],
["serve old twice", SELECT.old, argsWith(SELECT.old, ["--business", "mosaic-stack", "--business", "mosaic-stack"]), after("--business given twice")],
["serve new twice", SELECT.old, argsWith(SELECT.new, ["--org", "mosaic-stack", "--org", "mosaic-stack"]), after("--org given twice")],
["serve new missing", SELECT.old, [...argsWith(SELECT.old, []), "--org"], after("--org needs one value")],
["serve new flag-shaped", SELECT.old, argsWith(SELECT.old, ["--org"]), after("--org needs one value, not --tester-socket")],
["serve new invalid", SELECT.old, argsWith(SELECT.old, ["--org", "Mosaic"]), after("--org Mosaic is not valid")],
["serve names another business", SELECT.new, argsWith(SELECT.new, ["--org", "beta"]), refusing("serve business differs")],
["serve has no selector", SELECT.old, argsWith(SELECT.old, []), refusing("/etc/mosaic-stack/dev.args needs --business or --org after --")],
// --port takes --org as its value; the serve selector is the --business after it.
["serve --port value", SELECT.old, [...argsWith(SELECT.old, []).slice(0, 17), "--org", "--business", "mosaic-stack", "--tester-socket"],
refusing("/etc/mosaic-stack/dev.args lacks a privileged numeric --port after --")],
];
for (const [name, sel, file, err] of cases) {
const h = host(t);
if (file) put(h.args, file.join("\n") + "\n", 0o644);
const r = run(h, {}, invocation(h, sel));
assert.equal(r.status, 2, `${name}: ${r.out}${r.err}`);
assert.equal(r.err, err, name);
assert.equal(r.out, "", name);
assert.ok(!existsSync(join(h.hc, "calls.log")), name);
}
// A missing value at the end of the script's own arguments is a usage error.
const h = host(t);
const r = run(h, {}, ["--args", "/etc/mosaic-stack/dev.args", "--claude-pin", CLAUDE_PIN, "--node-pin", NODE_PIN, "--root", h.root, "--org"]);
assert.equal(r.status, 2);
assert.equal(r.err, usage);
});
test("row 195 accept: a copy of the script without selector.sh beside it refuses before parsing", (t) => {
const h = host(t), lone = join(h.dir, "lone", "case1-accept.sh");
put(lone, read(SCRIPT), 0o755);
const r = run(h, {}, undefined, lone);
assert.equal(r.status, 2, r.out + r.err);
assert.ok(r.err.endsWith(`case1-accept: refusing: cannot read ${join(h.dir, "lone", "selector.sh")}\n`), r.err);
assert.equal(r.out, "");
assert.ok(!existsSync(join(h.hc, "calls.log")));
});
test("row 195 accept: every in-instance call goes through sudo -u mosaic -H env PATH=<node dir>:...", (t) => {
const h = host(t);
const r = run(h);
+70 -2
View File
@@ -71,7 +71,7 @@ function world(t, { profile = "library" } = {}) {
script(join(bin, "stack-copy"), `echo "stack-copy $*" >>'${log}'
[ "$1" = copy ] && [ "$3" = --copies ] || exit 4
mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2"
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/business/src/vocabulary.mjs packages/plugins/model-provider/cliproxy/index.mjs; do
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do
mkdir -p "$4/$2/$(dirname "$file")" && cp '${REPO}'"$file" "$4/$2/$file" || exit 1
done
mkdir -p "$4/$2/node_modules" && cp -R '${REPO}node_modules/yaml' "$4/$2/node_modules/yaml"`);
@@ -250,6 +250,64 @@ test("the install refuses root, a business id mismatch, another --home and a con
assert.equal(existsSync(x.business), false);
});
// The selectors (rename design r2): --org/--business and --org-file/--business-file are two
// families, each its own scope. A bad selector refuses with exit 4 before anything is read or written.
const rest = (x) => ["--commit", COMMIT, "--profile", "library", "--personas", x.staging];
test("the install takes --org and --org-file, alone or matching their old spelling in either order", (t) => {
for (const sel of [
(f) => ["--org", "acme", "--org-file", f],
(f) => ["--business", "acme", "--org", "acme", "--business-file", f, "--org-file", f],
(f) => ["--org", "acme", "--business", "acme", "--org-file", f, "--business-file", f],
]) {
const x = world(t);
const r = install(x, { args: [...sel(x.input), ...rest(x)] });
assert.equal(r.code, 0, r.out);
assert.equal(JSON.parse(readFileSync(x.business, "utf8")).id, "acme");
assert.equal(calls(x), `stack-copy copy ${COMMIT} --copies ${join(x.home, ".local", "share", "mosaic-stack", "copies")}\n`);
}
});
test("the install refuses a bad selector in either family before it reads or writes anything", (t) => {
const x = world(t);
const f = x.input, g = `${x.root}/input/../input/acme.json`; // join() would normalize it to f
assert.notEqual(g, f);
const usage = "usage: case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library] [--personas DIR] [--home DIR]\n";
const notId = (v) => `case2-install: --business must be a business id: ${v}\n`;
for (const [args, out] of [
[["--business", "acme", "--org", "beta", "--business-file", f], "case2-install: --business acme and --org beta conflict\n"],
[["--org", "acme", "--business", "beta", "--business-file", f], "case2-install: --org acme and --business beta conflict\n"],
[["--business", "acme", "--business", "acme", "--business-file", f], "case2-install: --business given twice\n"],
[["--org", "acme", "--org", "acme", "--business-file", f], "case2-install: --org given twice\n"],
[["--business-file", f, "--org"], usage],
[["--business-file", f, "--business"], usage],
[["--org", "", "--business-file", f], usage],
[["--org", "--business-file", f], notId("--business-file")],
[["--business", "-x", "--business-file", f], notId("-x")],
[["--org", "Acme", "--business-file", f], notId("Acme")],
[["--org", "a.b", "--business-file", f], notId("a.b")],
// The file family compares the text given: the same file named another way still conflicts.
[["--org", "acme", "--business-file", f, "--org-file", g], `case2-install: --business-file ${f} and --org-file ${g} conflict\n`],
[["--org", "acme", "--org-file", g, "--business-file", f], `case2-install: --org-file ${g} and --business-file ${f} conflict\n`],
[["--org", "acme", "--org-file", f, "--org-file", f], "case2-install: --org-file given twice\n"],
[["--org", "acme", "--business-file", f, "--business-file", f], "case2-install: --business-file given twice\n"],
[["--org", "acme", "--org-file"], usage],
[["--org", "acme", "--org-file", ""], usage],
[["--org", "acme", "--org-file", "--commit", COMMIT], "case2-install: --org-file needs one value, not --commit\n"],
]) {
const last = ["--business-file", "--business", "--org", "--org-file"].includes(args.at(-1));
const r = install(x, { args: last ? args : [...args, ...rest(x)] });
assert.equal(r.code, 4, args.join(" "));
assert.equal(r.out, out, args.join(" "));
}
// A selector spelling taken as another option's value is that option's text.
const r = install(x, { args: ["--org", "acme", "--business-file", f, "--commit", "--org", "--profile", "library", "--personas", x.staging] });
assert.equal(r.code, 4, r.out);
assert.equal(r.out, "case2-install: --commit must be a full 40-character commit id: --org\n");
assert.equal(calls(x), "");
assert.equal(existsSync(x.business), false);
});
test("the library install needs --personas", (t) => {
const x = world(t);
const r = install(x, { args: ["--business", "acme", "--business-file", x.input, "--commit", COMMIT] });
@@ -433,7 +491,7 @@ function dependencyFreeClone(t) {
const x = world(t);
const clone = join(x.root, "clone");
mkdirSync(join(clone, "scripts"), { recursive: true });
for (const file of ["case2-install.sh", "case2-persona-import.mjs"]) {
for (const file of ["case2-install.sh", "case2-persona-import.mjs", "selector.sh"]) {
cpSync(join(REPO, "scripts", file), join(clone, "scripts", file));
}
// Isolate the persona step; bootstrap and business validation are covered above.
@@ -463,6 +521,16 @@ test("row 322 F8: library personas install from the copy with no clone node_modu
assert.equal(existsSync(join(x.clone, "node_modules")), false);
});
// Without selector.sh beside it the install refuses before parsing, rather than skip the selector rules.
test("the install refuses when its selector.sh is missing, before it reads or writes anything", (t) => {
const x = dependencyFreeClone(t);
rmSync(join(x.clone, "scripts", "selector.sh"));
const r = x.run();
assert.equal(r.code, 4, r.out);
assert.ok(r.out.endsWith(`case2-install: cannot read ${join(x.clone, "scripts", "selector.sh")}\n`), r.out);
assert.equal(existsSync(x.business), false);
});
test("row 322 F9: importer refusal keeps the error line rather than the Node banner", (t) => {
const x = dependencyFreeClone(t);
const copy = join(x.home, ".local", "share", "mosaic-stack", "copies", COMMIT);
@@ -398,3 +398,62 @@ test("usage: stage needs one of --staging and --dry-run, install takes only its
assert.equal(run(["stage", "--bogus", "x"]).status, 2);
assert.ok(!existsSync(s.staging));
});
// Rename design r2, "Selector scopes and parser matrix": --org and --business are one option in
// this script's own parser, run directly, not through case2-install.sh.
test("install takes --org or --business, alone or matching in either order", (t) => {
for (const selector of [["--business", "case2"], ["--org", "case2"], ["--business", "case2", "--org", "case2"], ["--org", "case2", "--business", "case2"]]) {
const s = scratch(t);
assert.equal(stage(s).status, 0);
const r = run(["install", "--staging", s.staging, "--config-dir", s.configDir, ...selector]);
assert.equal(r.status, 0, `${selector.join(" ")}: ${r.all}`);
assert.deepEqual(readdirSync(join(s.configDir, "personas")), ["case2"], selector.join(" "));
assert.deepEqual(readdirSync(join(s.configDir, "personas", "case2")).sort(), ["bean.md", "chet.md"], selector.join(" "));
}
});
test("a bad selector refuses before the staging directory or the Hermes home is read", (t) => {
const s = scratch(t);
assert.equal(stage(s).status, 0);
for (const [selector, status, err] of [
[["--business", "case2", "--org", "other"], 2, "case2-persona-import: --business case2 and --org other conflict\n"],
[["--org", "case2", "--business", "other"], 2, "case2-persona-import: --org case2 and --business other conflict\n"],
[["--business", "case2", "--business", "case2"], 2, "case2-persona-import: --business given twice\n"],
[["--org", "case2", "--org", "case2"], 2, "case2-persona-import: --org given twice\n"],
[["--org"], 2, "case2-persona-import: --org needs one value\n"],
[["--business"], 2, "case2-persona-import: --business needs one value\n"],
[["--org", ""], 2, "case2-persona-import: --org needs one value\n"],
[["--org", "--dry-run"], 2, "case2-persona-import: --org needs one value, not --dry-run\n"],
[["--org", "-x"], 1, "case2-persona-import: refused: business -x isn't a business id\n"],
[["--org", "Case2"], 1, "case2-persona-import: refused: business Case2 isn't a business id\n"],
[["--business", "../x"], 1, "case2-persona-import: refused: business ../x isn't a business id\n"],
[["--org", "a.b"], 1, "case2-persona-import: refused: business a.b isn't a business id\n"],
]) {
for (const verb of [["install", "--staging", s.staging, "--config-dir", s.configDir], ["stage", "--hermes-root", s.hermes, ...PAIRS, "--dry-run"]]) {
rmSync(s.trace, { force: true });
const r = run([...verb, ...selector], { trace: s.trace });
const label = `${verb[0]} ${selector.join(" ")}`;
assert.equal(r.status, status, `${label}: ${r.all}`);
assert.equal(r.err, err, label);
assert.equal(r.out, "", label);
const opened = readFileSync(s.trace, "utf8").split("\n").filter(Boolean).map((l) => l.slice(l.indexOf(" ") + 1));
assert.deepEqual(opened.filter((p) => p.startsWith(s.root)), [], `${label} read nothing of the scratch tree`);
}
}
assert.equal(existsSync(join(s.configDir, "personas")), false);
// The control: the same trace sees install read the staging directory.
rmSync(s.trace, { force: true });
assert.equal(install(s, "case2", { trace: s.trace }).status, 0);
assert.ok(readFileSync(s.trace, "utf8").includes(join(s.staging, "manifest.json")));
});
test("a selector spelling is never another option's value, and stage still ignores a valid selector", (t) => {
const s = scratch(t);
// This grammar refuses any value starting "--", so a selector spelling can't be one.
const r = run(["install", "--staging", "--org", "case2", "--config-dir", s.configDir]);
assert.equal(r.status, 2, r.all);
assert.equal(r.err, "case2-persona-import: --staging needs a value\n");
const st = stage(s, ["--org", "case2"]);
assert.equal(st.status, 0, st.all);
assert.deepEqual(readdirSync(s.staging).sort(), ["bean.md", "chet.md", "manifest.json"]);
});
+113 -4
View File
@@ -107,9 +107,9 @@ const libraryFlags = (h) => ["--port", "443", "--business", "acme", "--public-ho
"--allow-user", "melanie", "--proof-header", "X-Proxy-Proof", "--proof-file", h.proof];
const factoryFlags = () => ["--port", "443", "--business", "acme", "--tester-socket"];
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {} } = {}) {
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv } = {}) {
const serve = flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h));
const args = ["--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
const args = argv ?? ["--user", "mosaic", "--home", h.home, ...outer, "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
const r = spawnSync(UNIT, args, {
encoding: "utf8",
timeout: 30000,
@@ -338,6 +338,71 @@ test("a clean read-only library render writes both units 0644 with the fixed lin
]);
});
// The selector (rename design r2): --org and --business are one option in each of two scopes, the
// script's own flags before -- and the serve flags after it. Nothing is counted across --; the serve
// selector is required, must be the unit's business, and renders as --business.
const SELECTORS = [["--business", "acme"], ["--org", "acme"], ["--business", "acme", "--org", "acme"], ["--org", "acme", "--business", "acme"]];
test("the unit takes --org or --business on either side of --, renders --business once, and re-installs as the same units", (t) => {
const h = hub(t);
assert.equal(unit(h, { profile: "factory", flags: ["--port", "443", "--business", "acme"] }).code, 0);
const before = [read(busFile(h)), read(webuiFile(h))];
assert.deepEqual(lines(before[1], "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve --port 443 --business acme`]);
for (const outer of SELECTORS) {
for (const child of SELECTORS) {
const r = unit(h, { profile: "factory", outer, flags: ["--port", "443", ...child] });
assert.equal(r.code, 0, `${outer.join(" ")} -- ${child.join(" ")}: ${r.err}`);
assert.deepEqual([read(busFile(h)), read(webuiFile(h))], before, `${outer.join(" ")} -- ${child.join(" ")}`);
}
}
// The selector keeps its place among the serve flags.
const g = hub(t);
assert.equal(unit(g, { profile: "factory", outer: ["--org", "acme"], flags: ["--org", "acme", "--port", "443", "--business", "acme"] }).code, 0);
assert.deepEqual(lines(read(webuiFile(g)), "ExecStart"), [`${USR_NODE} ${copyDir(g)}/packages/webui/src/cli.mjs serve --business acme --port 443`]);
});
test("the unit refuses a bad selector in either scope, and an outer and serve pair that differ, before writing a unit", (t) => {
const h = hub(t);
const serve = ["--port", "443", "--business", "acme"];
const usage = "usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n";
const head = ["--user", "mosaic", "--home", h.home, "--copy", COPY_A, "--node", USR_NODE, "--profile", "factory"];
for (const [label, argv, code, err] of [
["outer unequal, old first", [...head, "--business", "acme", "--org", "beta", "--", ...serve], 4, "case2-unit: --business acme and --org beta conflict\n"],
["outer unequal, new first", [...head, "--org", "acme", "--business", "beta", "--", ...serve], 4, "case2-unit: --org acme and --business beta conflict\n"],
["outer old repeated", [...head, "--business", "acme", "--business", "acme", "--", ...serve], 4, "case2-unit: --business given twice\n"],
["outer new repeated", [...head, "--org", "acme", "--org", "acme", "--", ...serve], 4, "case2-unit: --org given twice\n"],
["outer missing", [...head, "--org"], 4, usage],
["outer empty", [...head, "--org", "", "--", ...serve], 4, usage],
["outer none", [...head, "--", ...serve], 4, usage],
["outer invalid", [...head, "--org", "Acme", "--", ...serve], 1, "case2-unit: --business must be a business id: Acme\n"],
["outer flag-shaped", [...head, "--business", "-x", "--", ...serve], 1, "case2-unit: --business must be a business id: -x\n"],
["outer takes no separator", [...head, "--org", "--", ...serve], 1, "case2-unit: --business must be a business id: --\n"],
["serve unequal, old first", [...head, "--business", "acme", "--", "--port", "443", "--business", "acme", "--org", "beta"], 1, "case2-unit: serve --business acme and --org beta conflict\n"],
["serve unequal, new first", [...head, "--business", "acme", "--", "--port", "443", "--org", "acme", "--business", "beta"], 1, "case2-unit: serve --org acme and --business beta conflict\n"],
["serve old repeated", [...head, "--business", "acme", "--", "--port", "443", "--business", "acme", "--business", "acme"], 1, "case2-unit: serve flag --business given twice\n"],
["serve new repeated", [...head, "--business", "acme", "--", "--port", "443", "--org", "acme", "--org", "acme"], 1, "case2-unit: serve flag --org given twice\n"],
["serve missing", [...head, "--business", "acme", "--", "--port", "443", "--org"], 1, "case2-unit: serve flag --org needs a value\n"],
["serve empty", [...head, "--business", "acme", "--", "--port", "443", "--org", ""], 1, "case2-unit: serve flag --org needs a value\n"],
["serve none", [...head, "--org", "acme", "--", "--port", "443"], 1, "case2-unit: serve flag --business is required\n"],
["serve invalid", [...head, "--business", "acme", "--", "--port", "443", "--org", "Acme"], 1, "case2-unit: serve --org Acme is not the unit's business acme\n"],
["serve flag-shaped", [...head, "--business", "acme", "--", "--port", "443", "--org", "--tester-socket"], 1, "case2-unit: serve --org --tester-socket is not the unit's business acme\n"],
["outer and serve differ", [...head, "--org", "acme", "--", "--port", "443", "--org", "beta"], 1, "case2-unit: serve --org beta is not the unit's business acme\n"],
["serve and outer differ", [...head, "--org", "beta", "--", "--port", "443", "--business", "acme"], 1, "case2-unit: serve --business acme is not the unit's business beta\n"],
["a selector as --copy's value", ["--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", "--org", "--node", USR_NODE, "--profile", "factory", "--", ...serve], 1, "case2-unit: --copy must be a full 40-character lower-case commit id: --org\n"],
]) {
const r = unit(h, { argv });
assert.equal(r.code, code, `${label}: ${r.err}`);
assert.equal(r.err, err, label);
assert.equal(r.out, "", label);
}
// A selector spelling taken as a library serve flag's value is that flag's text.
const library = libraryFlags(h).map((f) => (f === "books.example" ? "--org" : f));
refuses(unit(h, { flags: library }), /^case2-unit: public host must be a hostname, no port or scheme: --org\n$/);
assert.equal(existsSync(busFile(h)), false);
assert.equal(existsSync(webuiFile(h)), false);
assert.equal(existsSync(h.log), false);
});
test("the read-only library profile explicitly refuses --send before writing a unit", (t) => {
const h = hub(t);
const r = unit(h, { flags: [...libraryFlags(h), "--send"] });
@@ -736,8 +801,8 @@ function pathWithoutClaude(x) {
return `${x.bin}:${usr}`;
}
function precheck(x, { profile = "library", env = {}, path } = {}) {
const r = spawnSync(PRECHECK, ["--business", "acme", "--profile", profile], {
function precheck(x, { profile = "library", env = {}, path, args = ["--business", "acme", "--profile", profile] } = {}) {
const r = spawnSync(PRECHECK, args, {
encoding: "utf8",
timeout: 60000,
env: {
@@ -772,6 +837,50 @@ test("the precheck passes on a good host and prints free memory, without the key
assert.doesNotMatch(r.out, /claude .*fake|marker/);
});
// The selector (rename design r2): --org and --business are one option in the precheck's scope, and a
// bad one refuses before any check line, with the script's usage exit 4.
test("the precheck takes --org or --business, alone or matching in either order, and refuses a bad selector before any check", (t) => {
const x = host(t);
for (const sel of [["--business", "acme"], ["--org", "acme"], ["--business", "acme", "--org", "acme"], ["--org", "acme", "--business", "acme"]]) {
const r = precheck(x, { args: [...sel, "--profile", "library"] });
assert.equal(r.code, 0, `${sel.join(" ")}: ${r.out}`);
assert.match(r.out, /ok {6}config validates/, sel.join(" "));
}
const usage = "usage: case2-precheck.sh --business ID [--profile factory|library] | --business ID --library-status\n";
const notId = (v) => `case2-precheck: --business must be a business id: ${v}\n`;
for (const [args, out] of [
[["--business", "acme", "--org", "beta"], "case2-precheck: --business acme and --org beta conflict\n"],
[["--org", "acme", "--business", "beta"], "case2-precheck: --org acme and --business beta conflict\n"],
[["--business", "acme", "--business", "acme"], "case2-precheck: --business given twice\n"],
[["--org", "acme", "--org", "acme"], "case2-precheck: --org given twice\n"],
[["--org"], usage],
[["--business"], usage],
[["--org", ""], usage],
[["--org", "--profile", "library"], notId("--profile")],
[["--business", "-x"], notId("-x")],
[["--org", "Acme"], notId("Acme")],
[["--org", "acme/../x"], notId("acme/../x")],
]) {
const r = precheck(x, { args });
assert.equal(r.code, 4, args.join(" "));
assert.equal(r.out, out, args.join(" "));
}
// A selector spelling taken as --profile's value is that option's text.
const r = precheck(x, { args: ["--profile", "--org", "--business", "acme"] });
assert.equal(r.code, 4, r.out);
assert.equal(r.out, "case2-precheck: --profile must be factory or library: --org\n");
});
test("the precheck refuses when its selector.sh is missing, before any check", (t) => {
const x = host(t), lone = join(x.root, "lone", "scripts", "case2-precheck.sh");
mkdirSync(dirname(lone), { recursive: true });
cpSync(PRECHECK, lone);
const r = spawnSync(lone, ["--org", "acme"], { encoding: "utf8", env: { PATH: `${x.bin}:/usr/bin:/bin`, HOME: x.home } });
assert.equal(r.status, 4, r.stdout + r.stderr);
assert.equal(r.stdout, "");
assert.ok(r.stderr.endsWith(`case2-precheck: cannot read ${join(x.root, "lone", "scripts", "selector.sh")}\n`), r.stderr);
});
test("the precheck refuses each failed host check", (t) => {
blocked(precheck(host(t), { env: { CASE2_TEST_NODE: "v22.22.2" } }), /REFUSED node v22\.22\.2 is older than 24/);
blocked(precheck(host(t), { env: { CASE2_TEST_GROUPS: `${GID} 998` } }), /REFUSED id -G prints more than one group/);
+86 -4
View File
@@ -1,12 +1,14 @@
// `mosaic dev fixture`: refusals, the state file's checks, and one real
// init/status/stop of the loopback endpoint in a scratch HOME.
// `mosaic dev fixture`: refusals, the state file's checks, one real
// init/status/stop of the loopback endpoint in a scratch HOME, and the
// replacement of a record whose endpoint has ended.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { devCommand, fixtureFile, fixtureLaunchEnv, fixtureLauncherOptions, isEndpoint, orgUsesFixture, readFixture } from '../src/dev-fixture.mjs';
import { devCommand, fixtureFile, fixtureLaunchEnv, fixtureLauncherOptions, isEndpoint, orgUsesFixture, readFixture, takeStale } from '../src/dev-fixture.mjs';
import { FIXTURE_ENTRY, FIXTURE_KEY } from '../../plugins/model-provider/fixture/index.mjs';
import { TTL_DEFAULT_S, TTL_MAX_S } from '../../plugins/model-provider/fixture/endpoint.mjs';
import { io } from './helpers.mjs';
// devOwnerInstance reads the instance from MOSAIC_CONFIG under $HOME/.config.
@@ -36,6 +38,8 @@ test('init refuses before it starts anything', async () => {
await refused(INIT, envFor('fxtest', { ANTHROPIC_API_KEY: 'sk-ant-value-never-printed' }), system, 'fixture-credential-present');
await assert.rejects(devCommand(INIT, io(envFor('fxtest', { ZAI_API_KEY: 'zai-value-never-printed' })), { system }), e => /ZAI_API_KEY/.test(e.message) && !/never-printed/.test(e.message));
await refused(INIT, envFor(), { ...system, proxy: { url: 'http://127.0.0.1:1' } }, 'fixture-proxy-configured');
for (const ttl of ['-1', '1.5', '59', String(TTL_MAX_S + 1), '060', '1e3', 'soon', ''])
await assert.rejects(devCommand([...INIT, '--ttl', ttl], io(envFor()), { system }), e => e.exitCode === 4 && /--ttl takes 0 \(no expiry\) or whole seconds/.test(e.message), `--ttl ${ttl}`);
assert.equal(existsSync(join(system.dataRoot, 'dev-fixture')), false);
});
@@ -98,6 +102,7 @@ test('a real init, status and stop on loopback', async t => {
assert.equal(receipt.receipt, 'dev-fixture-init');
assert.match(receipt.endpoint, /^http:\/\/127\.0\.0\.1:[0-9]+$/);
assert.deepEqual([receipt.loopback, receipt.external, receipt.upstream, receipt.instance, receipt.principal, receipt.modelKey], [true, false, 'none', 'fxtest', 'local-dev:local-dev-owner', 'fixture-deterministic']);
assert.deepEqual([receipt.ttlSeconds, receipt.replaced], [TTL_DEFAULT_S, null]);
assert.equal(isEndpoint(receipt.pid), true);
assert.equal(JSON.parse(readFileSync(fixtureFile(system.dataRoot), 'utf8')).pid, receipt.pid);
assert.doesNotMatch(readFileSync(fixtureFile(system.dataRoot), 'utf8'), new RegExp(FIXTURE_KEY));
@@ -106,6 +111,8 @@ test('a real init, status and stop on loopback', async t => {
await devCommand(['fixture', 'status', '--json'], s, { system });
const status = JSON.parse(s.out.text);
assert.deepEqual([status.ready, status.alive, status.external, status.upstream, status.proxyConfigured, status.health.upstream], [true, true, false, 'none', false, 'none']);
// The default lifetime reaches the endpoint process.
assert.deepEqual([status.ttlSeconds, status.health.ttlSeconds], [TTL_DEFAULT_S, TTL_DEFAULT_S]);
const p = io(env);
await devCommand(['fixture', 'status', '--json'], p, { system: { ...system, proxy: {} } });
assert.equal(JSON.parse(p.out.text).ready, false);
@@ -119,3 +126,78 @@ test('a real init, status and stop on loopback', async t => {
assert.equal(readFixture(system.dataRoot), null);
await refused(['fixture', 'status'], env, system, undefined, 2);
});
// P19 #882. The endpoint's lifetime ends through the same stop as SIGTERM, which leaves the
// record behind; init used to refuse fixture-exists on it until `dev fixture stop`. The
// lifetime's timer is tested in packages/plugins/tests/model-provider-fixture.test.mjs.
test('init replaces the record of an endpoint that has ended, and --ttl reaches the endpoint', async t => {
const system = { dataRoot: dataRoot() };
const env = envFor();
const pids = [];
t.after(() => { for (const pid of pids) if (isEndpoint(pid)) process.kill(pid, 'SIGKILL'); });
const init = async (...extra) => {
const x = io(env); await devCommand([...INIT, '--json', ...extra], x, { system });
const receipt = JSON.parse(x.out.text); pids.push(receipt.pid); return receipt;
};
const status = async () => { const x = io(env); await devCommand(['fixture', 'status', '--json'], x, { system }); return JSON.parse(x.out.text); };
const first = await init('--ttl', '0');
assert.deepEqual([first.ttlSeconds, first.expiresAt, first.replaced], [0, null, null]);
let s = await status();
assert.deepEqual([s.ready, s.health.ttlSeconds, s.health.expiresAt], [true, 0, null]);
process.kill(first.pid, 'SIGTERM');
for (let i = 0; i < 50 && isEndpoint(first.pid); i++) await new Promise(r => setTimeout(r, 100));
assert.equal(isEndpoint(first.pid), false);
assert.equal(readFixture(system.dataRoot).pid, first.pid, 'an ended endpoint leaves its record');
s = await status();
assert.deepEqual([s.ready, s.alive], [false, false]);
const second = await init('--ttl', '120');
assert.notEqual(second.pid, first.pid);
assert.deepEqual(second.replaced, { pid: first.pid, startedAt: first.startedAt });
assert.equal(readFixture(system.dataRoot).pid, second.pid);
s = await status();
assert.deepEqual([s.ready, s.ttlSeconds, s.health.ttlSeconds], [true, 120, 120]);
assert.ok(Math.abs(Date.parse(s.health.expiresAt) - Date.parse(second.expiresAt)) < 10000, `${s.health.expiresAt} vs ${second.expiresAt}`);
assert.equal(Date.parse(second.expiresAt) - Date.parse(second.startedAt), 120000);
// A live endpoint's record still refuses.
await refused(INIT, env, system, 'fixture-exists');
const stopped = io(env);
await devCommand(['fixture', 'stop', '--json'], stopped, { system });
assert.equal(JSON.parse(stopped.out.text).signaled, true);
// A record whose pid now names another process (this test runner) is stale too.
const reused = dataRoot();
writeState(reused, { ...RECORD, pid: process.pid });
const x = io(env); await devCommand([...INIT, '--json'], x, { system: { dataRoot: reused } });
const third = JSON.parse(x.out.text); pids.push(third.pid);
assert.deepEqual(third.replaced, { pid: process.pid, startedAt: null });
await devCommand(['fixture', 'stop'], io(env), { system: { dataRoot: reused } });
assert.equal(isEndpoint(third.pid), false);
});
// Review P2 (org-test-health): two inits both observe the same stale record. The first replaces
// it with its live endpoint's record; the second, resuming, must not delete that new record.
test('a stale record is removed only if it is still the one observed; a newer record stays', () => {
const observed = { ...RECORD, startedAt: '2026-11-08T00:00:00.000Z' };
const root = dataRoot();
writeState(root, observed);
takeStale(root, observed, 'fxtest');
assert.equal(existsSync(fixtureFile(root)), false, 'the observed stale record goes');
assert.deepEqual(readdirSync(join(root, 'dev-fixture')), [], 'nothing is left aside');
const raced = dataRoot();
const newer = { ...RECORD, pid: 4242, startedAt: '2026-11-08T00:00:05.000Z' };
writeState(raced, newer);
const before = readFileSync(fixtureFile(raced), 'utf8');
assert.throws(() => takeStale(raced, observed, 'fxtest'), e => e.code === 'fixture-exists');
assert.equal(readFileSync(fixtureFile(raced), 'utf8'), before, 'the other init\'s record is back, unchanged');
assert.deepEqual(readdirSync(join(raced, 'dev-fixture')), ['fixture.json'], 'nothing is left aside');
const gone = dataRoot();
mkdirSync(join(gone, 'dev-fixture'), { mode: 0o700 });
takeStale(gone, observed, 'fxtest');
assert.equal(existsSync(fixtureFile(gone)), false, 'a record already taken by the other init is not an error');
});
+32
View File
@@ -11,6 +11,8 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { BusError } from "../../bus/src/broker.mjs";
import { CliError } from "../src/errors.mjs";
import { busExit } from "../src/transport.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
import { main, run } from "../src/cli.mjs";
@@ -358,3 +360,33 @@ test("row 194 CLI: lifecycle and veto usage errors exit 4 before any bus call",
}
assert.equal(s.calls.length, 0);
});
test('v1 business instances, instance and veto retain broker proof refusals and leave files untouched', async t => {
const s = await setup(t);
const before = readFileSync(s.file, 'utf8');
const reader = s.bus.broker.bindReader({ business: 'acme' });
const calls = [];
const deps = { system: { dataRoot: s.f.dataRoot }, transport: business => async (verb, args) => {
calls.push({ business, verb, args });
try { return structuredClone(s.bus.broker.request(reader, { verb, args })); }
catch (error) { const e = new CliError(error.code, busExit(error.code)); e.code = error.code; throw e; }
} };
const cases = [
[['instances', 'acme', '--json'], 'instances', {}],
[['instance', 'set', 'acme', 'coder', '--launch', '--revision', 'a'.repeat(64)], 'instances.set', { revision: 'a'.repeat(64), instance: 'coder', op: 'change', launch: true }],
[['veto', 'acme', 'keep', 'coder', '--revision', 'a'.repeat(64)], 'veto.set', { revision: 'a'.repeat(64), op: 'keep', name: 'coder' }],
];
for (const [args, verb, request] of cases) {
const x = io(s.f.env);
assert.equal(await run(['business', ...args], x, deps), 3, args.join(' '));
assert.equal(x.out.text, '');
assert.match(x.err.text, /human-required/);
assert.deepEqual(calls.at(-1), { business: 'acme', verb, args: request });
const count = calls.length;
const agent = io({ ...s.f.env, CODEX_THREAD_ID: 'fixture-agent' });
assert.equal(await run(['business', ...args], agent, deps), 3);
assert.match(agent.err.text, /human shell/);
assert.equal(calls.length, count);
}
assert.equal(readFileSync(s.file, 'utf8'), before);
});
+87 -3
View File
@@ -1,13 +1,13 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
import { get } from 'node:http';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { get, request } from 'node:http';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
import { humanEnv } from './helpers.mjs';
import { consoleStart, consoleStop } from '../src/org-console.mjs';
import { consoleStart, consoleStatus, consoleStop } from '../src/org-console.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
test('detached console reports its live loopback URL and stops', { timeout: 60000 }, async t => {
@@ -29,6 +29,9 @@ test('detached console reports its live loopback URL and stops', { timeout: 6000
const url = new URL(result.url);
assert.equal(url.hostname, '127.0.0.1');
assert.ok(Number.isSafeInteger(result.pid));
// A selector producer (rename r2): the Console child gets the active organization as --business.
const argv = readFileSync(`/proc/${result.pid}/cmdline`, 'utf8').split('\0').slice(0, -1);
assert.deepEqual(argv, [process.execPath, join(REPO, 'packages/webui/src/cli.mjs'), 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner']);
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url.port)));
const status = await new Promise((resolve, reject) => get(url, res => { res.resume(); resolve(res.statusCode); }).on('error', reject));
assert.equal(status, 200);
@@ -49,3 +52,84 @@ test('mosaic console start and stop dispatch through the CLI', { timeout: 60000
assert.equal(stop.status, 0, stop.stderr);
assert.equal(JSON.parse(stop.stdout).stopped, true);
});
// org-lan-bind: --dev-insecure-bind and --dev-allow-host. The Console binds 0.0.0.0 and the test
// reaches it on 127.0.0.1 with an allowed Host name, as a LAN browser would.
const hostGet = (port, host) => new Promise((resolve, reject) => {
request({ host: '127.0.0.1', port, path: '/', headers: { host } }, res => { res.resume(); resolve(res.statusCode); }).on('error', reject).end();
});
async function instance(t) {
const home = mkdtempSync(join(tmpdir(), 's1-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 's1test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-s1test'), dataRoot: join(home, '.mosaic-s1test'), instance: 's1test', env };
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); });
const mosaic = args => spawnSync(join(REPO, 'scripts/mosaic'), args, { env, encoding: 'utf8', timeout: 30000 });
return { ctx, mosaic };
}
test('a LAN console warns, records its bind and answers only its allowed names', { timeout: 60000 }, async t => {
const { ctx, mosaic } = await instance(t);
const warnings = [];
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'], warn: s => warnings.push(s) });
const url = new URL(result.url), port = url.port;
assert.equal(url.hostname, '127.0.0.1');
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(port)));
const warning = `WARNING: dev owner Console listens on 0.0.0.0:${port} with no sign-in; anyone who can reach it acts as the owner.\n`;
assert.deepEqual(warnings, [warning]);
assert.equal(result.bind, '0.0.0.0'); assert.deepEqual(result.allowHosts, ['lan.test', '10.1.10.37']);
const saved = JSON.parse(readFileSync(join(ctx.dataRoot, 'console/console.json'), 'utf8'));
assert.deepEqual({ url: saved.url, bind: saved.bind, allowHosts: saved.allowHosts }, { url: result.url, bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'] });
// The serve process prints the same warning on its own stderr, which the Console log keeps.
const logs = readdirSync(join(ctx.dataRoot, 'console')).filter(x => /^console-\d+-\d+\.log$/.test(x));
assert.equal(logs.length, 1);
assert.ok(readFileSync(join(ctx.dataRoot, 'console', logs[0]), 'utf8').includes(warning), 'serve log has the warning');
assert.equal(await hostGet(port, `lan.test:${port}`), 200);
assert.equal(await hostGet(port, `10.1.10.37:${port}`), 200);
assert.equal(await hostGet(port, `127.0.0.1:${port}`), 200);
assert.equal(await hostGet(port, `other.test:${port}`), 403);
assert.equal(await hostGet(port, `lan.test:${Number(port) + 1}`), 403);
const statusWarnings = [];
const status = consoleStatus(ctx, { warn: s => statusWarnings.push(s) });
assert.deepEqual(status, { running: true, pid: result.pid, url: result.url, bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'] });
assert.deepEqual(statusWarnings, [warning]);
const cli = mosaic(['console', 'status']);
assert.equal(cli.status, 0, cli.stderr);
assert.deepEqual(JSON.parse(cli.stdout), status);
assert.equal(cli.stderr, warning);
assert.equal((await consoleStop(ctx)).stopped, true);
assert.deepEqual(consoleStatus(ctx), { running: false });
// A state file whose LAN fields do not validate is refused, not trusted.
for (const bad of [{ bind: '0.0.0.0' }, { bind: '0.0.0.0', allowHosts: [] }, { bind: '0.0.0.0', allowHosts: ['a.test,b.test'] }, { bind: 'lan.test', allowHosts: ['lan.test'] }, { allowHosts: ['lan.test'] }]) {
writeFileSync(join(ctx.dataRoot, 'console/console.json'), JSON.stringify({ pid: 2147483647, startTime: '1', url: 'http://127.0.0.1:1/', ...bad }), { mode: 0o600 });
assert.throws(() => consoleStatus(ctx), /console state is invalid/, JSON.stringify(bad));
}
rmSync(join(ctx.dataRoot, 'console/console.json'));
});
test('mosaic console start refuses the LAN flags without the dev owner and allowed names', { timeout: 60000 }, async t => {
const { ctx, mosaic } = await instance(t);
for (const [args, reason] of [
[['--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /--dev-insecure-bind needs --dev-local-owner/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0'], /needs at least one --dev-allow-host/],
[['--dev-local-owner', '--dev-allow-host', 'lan.test'], /--dev-allow-host needs --dev-insecure-bind/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test:7350'], /without a port/],
[['--dev-local-owner', '--dev-insecure-bind', '10.1.10.37', '--dev-allow-host', 'lan.test'], /must also be a --dev-allow-host name/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'a.test', '--dev-allow-host', 'b.test'], /--dev-allow-host needs one value/],
]) {
const r = mosaic(['console', 'start', '--detach', '--port', 'auto', ...args]);
assert.equal(r.status, 4, `${args.join(' ')}: ${r.stderr}`);
assert.match(r.stderr, reason, args.join(' '));
assert.doesNotMatch(r.stderr, /WARNING: dev owner/);
assert.equal(r.stdout, '');
assert.equal(existsSync(join(ctx.dataRoot, 'console')), false, args.join(' '));
}
await assert.rejects(consoleStart(ctx, { detach: true, bind: '0.0.0.0', allowHosts: ['lan.test'] }), /--dev-insecure-bind needs --dev-local-owner/);
assert.equal(existsSync(join(ctx.dataRoot, 'console')), false);
const none = mosaic(['console', 'status']);
assert.equal(none.status, 0, none.stderr);
assert.deepEqual(JSON.parse(none.stdout), { running: false });
assert.equal(mosaic(['console', 'status', '--json']).status, 4);
});
+211
View File
@@ -0,0 +1,211 @@
// The organization selector on every CLI parser that takes --business (rename design r2, "Selector
// scopes and parser matrix"): --org is the same option in that parser's scope. Each entry point runs
// through main() with a stub transport that records the organization callFor binds, then stops; a
// refused selector binds nothing.
import { test } from "node:test";
import assert from "node:assert/strict";
import { main } from "../src/cli.mjs";
import { accessCommand } from "../src/access.mjs";
import { parseLimits } from "../src/limits.mjs";
import { parseArgs } from "../src/thread.mjs";
import { CliError } from "../src/errors.mjs";
import { OrgError } from "../../business/src/org-registry.mjs";
import { io, orgFixture, tmp } from "./helpers.mjs";
const STOP = "selector test stop";
const stop = () => { throw new CliError(STOP, 99); };
function stub(t) {
const h = { bound: [], calls: [], prompted: 0 };
const transport = b => { h.bound.push(b); return async (verb, args) => { h.calls.push({ verb, args }); stop(); }; };
h.deps = {
system: { dataRoot: tmp(t, "sel-") },
transport,
testerTransport: transport,
promptProxyKey: async () => { h.prompted++; stop(); },
};
return h;
}
// What one argv does: reached the bus (or the key prompt), refused, or failed some other way.
async function outcome(t, argv, { tty = false, input } = {}) {
const h = stub(t), term = io();
if (tty) { term.stdin.isTTY = true; term.stdout.isTTY = true; }
if (input !== undefined) term.stdin.end(input);
const seen = { bound: h.bound, calls: h.calls, get prompted() { return h.prompted; }, stdin: term.stdin };
try {
await main(argv, term, h.deps);
return { ...seen, kind: "done" };
} catch (e) {
if (e instanceof CliError && e.message === STOP) return { ...seen, kind: "reached" };
if (e instanceof OrgError) return { ...seen, kind: "org", code: e.code };
if (e instanceof CliError) return { ...seen, kind: "refused", exitCode: e.exitCode, code: e.code, message: e.message };
throw e;
}
}
// The design's per-parser cases. The selector goes after the verb's own arguments.
const CASES = [
["old alone", ["--business", "acme"], "acme"],
["new alone", ["--org", "acme"], "acme"],
["matching, old first", ["--business", "acme", "--org", "acme"], "acme"],
["matching, new first", ["--org", "acme", "--business", "acme"], "acme"],
["unequal, old first", ["--business", "acme", "--org", "beta"], "conflict"],
["unequal, new first", ["--org", "acme", "--business", "beta"], "conflict"],
["old repeated", ["--business", "acme", "--business", "acme"], "usage"],
["new repeated", ["--org", "acme", "--org", "acme"], "usage"],
["old missing", ["--business"], "usage"],
["new missing", ["--org"], "usage"],
["new empty", ["--org", ""], "usage"],
["new flag-shaped", ["--org", "--json"], "usage"],
["old flag-shaped", ["--business", "--org", "acme"], "usage"],
["new invalid", ["--org", "Acme"], "invalid"],
["old invalid", ["--business", "acme/../x"], "invalid"],
];
const ENTRIES = [
["inbox"], ["decide", "d1234567", "yes"], ["tasks"], ["agents"], ["trail", "t1"],
["talk", "pm", "hi"], ["follow"], ["names"], ["name", "coder", "Bob"],
["halt"], ["resume"], ["processing", "status"], ["launches", "off"], ["launches", "on"],
["project", "list"], ["project", "create", "work"], ["threads"], ["thread", "read", "work/t1"], ["session", "list", "work/t1"],
["limits"], ["limits", "set", "--file", "patch.json"], ["usage"], ["plugins"], ["plugins", "set", "--file", "patch.json"],
["credentials", "status"], ["credentials", "delete", "coder", "--reason", "gone"],
["hosts", "status"], ["hosts", "revoke", "coder", "h1", "--reason", "gone"],
["grants", "status", "gitea"], ["grants", "revoke", "coder", "gitea", "--reason", "gone"],
];
function check(r, expected, label) {
if (expected === "acme") {
assert.deepEqual(r.bound, ["acme"], `${label}: ${r.kind} ${r.message ?? r.code ?? ""}`);
return;
}
assert.deepEqual(r.bound, [], `${label} bound nothing`);
assert.equal(r.calls.length, 0, `${label} made no call`);
if (expected === "invalid") {
assert.equal(r.kind, "org", label);
assert.equal(r.code, "org-invalid", label);
} else {
assert.equal(r.kind, "refused", `${label}: ${r.kind}`);
assert.equal(r.exitCode, 4, label);
}
}
for (const base of ENTRIES) {
test(`selector on mosaic ${base.join(" ")}`, async (t) => {
for (const [name, selector, expected] of CASES) {
const r = await outcome(t, [...base, ...selector]);
check(r, expected, `${base.join(" ")} ${name}`);
// The verbs whose usage error carries the reason say which spellings conflict.
if (expected === "conflict" && !["limits", "usage", "plugins", "credentials", "hosts", "grants"].includes(base[0])) assert.match(r.message, /conflict/i, name);
}
});
}
test("selector on mosaic proxy init: the key prompt waits for a valid selector", async (t) => {
for (const [name, selector, expected] of CASES) {
const r = await outcome(t, ["proxy", "init", ...selector], { tty: true });
check(r, expected, `proxy init ${name}`);
assert.equal(r.prompted, expected === "acme" ? 1 : 0, `proxy init ${name} prompted`);
if (r.kind === "refused") assert.equal(r.code, "proxy-usage", name);
}
// fetch takes no selector.
assert.equal((await outcome(t, ["proxy", "fetch", "--org", "acme"])).code, "proxy-usage");
});
test("selector on mosaic access: the human proof binds the selected organization", async (t) => {
for (const verb of [["list"], ["user", "add", "ana"]]) {
for (const [name, selector, expected] of CASES) {
const bound = [], term = io();
const deps = { system: { dataRoot: tmp(t, "sel-") }, transport: b => { bound.push(b); return async () => stop(); }, contextFor: () => { throw new Error("context read before the proof"); } };
let r;
try { await accessCommand([...verb, ...selector], term, deps); r = { kind: "done" }; }
catch (e) {
if (e instanceof CliError && e.message === STOP) r = { kind: "reached" };
else if (e instanceof OrgError) r = { kind: "org", code: e.code };
else if (e instanceof CliError) r = { kind: "refused", exitCode: e.exitCode, message: e.message };
else throw e;
}
check({ ...r, bound, calls: [] }, expected, `access ${verb.join(" ")} ${name}`);
if (expected === "conflict") assert.match(r.message, /conflict/i);
}
}
});
test("a selector token taken as another option's value is that option's text", async (t) => {
const note = await outcome(t, ["decide", "d1234567", "yes", "--note", "--org", "--business", "acme"]);
assert.deepEqual(note.bound, ["acme"]);
const project = await outcome(t, ["threads", "--project", "--org", "--business", "acme"]);
assert.deepEqual(project.bound, ["acme"]);
// The channel's first call reads plugins, so the repo shows in the shared parser's result.
const repo = await outcome(t, ["project", "create", "work", "--repo", "--org", "--business", "acme"]);
assert.deepEqual(repo.bound, ["acme"]);
assert.deepEqual(parseArgs(["work", "--repo", "--org", "--business", "acme"], "u", { values: ["--repo", "--business"] }), { positional: ["work"], flags: new Set(), values: { "--repo": "--org", "--business": "acme" } });
for (const argv of [
["credentials", "delete", "coder", "--reason", "--org", "--business", "acme"],
["hosts", "revoke", "coder", "h1", "--reason", "--business", "--org", "acme"],
["grants", "revoke", "coder", "gitea", "--reason", "--org", "--business", "acme"],
]) {
const r = await outcome(t, argv);
assert.deepEqual(r.bound, ["acme"], argv.join(" "));
assert.equal(r.calls[0].args.reason, argv[argv.indexOf("--reason") + 1], argv.join(" "));
}
// callFor binds before the file is read, so the bound organization and the file name both show.
const plugins = await outcome(t, ["plugins", "set", "--file", "--org", "--business", "acme"]);
assert.deepEqual(plugins.bound, ["acme"]);
assert.match(plugins.message, /--org/);
assert.deepEqual(parseLimits(["set", "--file", "--org", "--business", "acme"]).values, { "--file": "--org", "--business": "acme" });
// proxy init: --port takes --org, so the port check refuses, not the selector.
const port = await outcome(t, ["proxy", "init", "--port", "--org", "--business", "acme"], { tty: true });
assert.equal(port.code, "proxy-config-invalid");
// access keeps its own grammar: an option value may not start with --.
await assert.rejects(accessCommand(["list", "--business", "acme", "--user", "--org"], io(), { transport: stop, contextFor: stop }), /--user needs one value/);
});
test("positional text next to a selector stays positional", async (t) => {
const r = await outcome(t, ["talk", "pm", "--org", "acme", "--business", "acme", "hi"]);
assert.deepEqual(r.bound, ["acme"]);
assert.deepEqual(r.calls[0], { verb: "message.send", args: { to: "pm", body: "hi", class: "REQUEST" } });
});
test("an invalid selector refuses before stdin is read", async (t) => {
const argv = ["grants", "admin", "set", "gitea", "--url", "https://git.example", "--account", "a", "--rotate-by", "2027-01-01"];
const bad = await outcome(t, [...argv, "--org", "Bad"], { input: "tok" });
assert.equal(bad.code, "org-invalid");
assert.ok(bad.stdin.readableLength > 0, "the token is still unread");
const good = await outcome(t, [...argv, "--org", "acme"], { input: "tok" });
assert.equal(good.kind, "reached");
assert.deepEqual(good.bound, ["acme"]);
assert.equal(good.calls[0].args.token, "tok");
});
test("--org is no longer a global option: a verb without a selector refuses it", async (t) => {
for (const argv of [["console", "start", "--org", "acme"], ["bus", "status", "--org", "acme"]]) {
const r = await outcome(t, argv);
assert.equal(r.kind, "refused", argv.join(" "));
assert.match(r.message, /--org/, argv.join(" "));
}
});
test("stop and launches list resolve the selected organization", async (t) => {
const { f } = await orgFixture(t, "acme");
const run = async (argv) => {
const term = io(f.env);
try { await main(argv, term, { system: { dataRoot: f.dataRoot } }); return { kind: "done", out: term.out.text }; }
catch (e) {
if (e instanceof OrgError) return { kind: "org", code: e.code };
if (e instanceof CliError) return { kind: "refused", exitCode: e.exitCode, message: e.message.split("\n")[0] };
throw e;
}
};
for (const [name, selector, expected] of CASES) {
const list = await run(["launches", "list", ...selector]);
if (expected === "acme") assert.deepEqual(list, { kind: "done", out: "no sessions\n" }, name);
else if (expected === "invalid") assert.deepEqual(list, { kind: "org", code: "org-invalid" }, name);
else assert.equal(list.exitCode, 4, name);
}
const reference = await run(["stop", "r1", "--business", "acme"]);
assert.notEqual(reference.kind, "org");
for (const selector of [["--org", "acme"], ["--org", "acme", "--business", "acme"]]) assert.deepEqual(await run(["stop", "r1", ...selector]), reference);
assert.deepEqual(await run(["stop", "r1", "--org", "beta"]), { kind: "org", code: "org-unknown" });
assert.equal((await run(["stop", "r1", "--org", "acme", "--business", "beta"])).exitCode, 4);
});
+25 -2
View File
@@ -1,10 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { main, run } from '../src/cli.mjs';
import { fixture, humanEnv, io, tmp } from './helpers.mjs';
import { fixture, humanEnv, io, tmp, orgFixture } from './helpers.mjs';
import { writeJson } from '../../business/tests/helpers.mjs';
test('project init/check use declared business repo and never contact the bus', async t => {
@@ -125,3 +125,26 @@ test('project list --archived includes and marks archived projects', async t =>
assert.deepEqual(calls.at(-1), ['project.list', { includeArchived: true }]);
assert.equal(archived.out.text, `live - ${A.slice(0, 8)}\nshelved archived - ${B.slice(0, 8)}\n`);
});
test('organization project init and check use the organization data root', async t => {
const { f, org } = await orgFixture(t);
const out = io(humanEnv(f.env));
assert.equal(await run(['project', 'init', 'acme', 'stack'], out, {}), 0, out.err.text);
assert.ok(existsSync(join(org.root, 'projects/acme/stack/spec/north-star.md')));
assert.equal(existsSync(join(f.dataRoot, 'projects/acme/stack')), false);
const checked = io(humanEnv(f.env));
assert.equal(await run(['project', 'check', 'acme', 'stack'], checked, {}), 0, checked.err.text);
});
test('organization project init refuses existing instance-root records without changing them', async t => {
const { f, org } = await orgFixture(t);
const old = join(f.dataRoot, 'projects/acme/stack'), expected = join(org.root, 'projects/acme/stack');
mkdirSync(old, { recursive: true });
writeFileSync(join(old, 'keep'), 'preserve');
const out = io(humanEnv(f.env));
assert.notEqual(await run(['project', 'init', 'acme', 'stack'], out, {}), 0);
assert.ok(out.err.text.includes(old)); assert.ok(out.err.text.includes(expected));
assert.equal(readFileSync(join(old, 'keep'), 'utf8'), 'preserve');
assert.equal(existsSync(expected), false);
});
+57 -1
View File
@@ -22,7 +22,8 @@ export function setup(t, realPin=false){
execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true});
});
mkdirSync(repo);mkdirSync(bin);
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','tools/unslop-hook','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
// A linked node_modules (a deploy tree) is not a directory to .gitignore's node_modules/; npm ci makes the copy's own.
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','tools/unslop-hook','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation',':(exclude,glob)**/node_modules'],{encoding:'utf8'}).trim().split('\n');
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
// Lightweight npm-ci fixture; real pin/host/proof integration is tested separately.
const pkg={name:'copy-fixture',version:'1.0.0'};
@@ -361,6 +362,61 @@ test('row 232: check --units external reads the system units, the default reads
}
});
// Rename r2 "Selector scopes and parser matrix": check takes --org as --business, each spelling once, both only
// with one value; a refused selector reads no unit, and --org is refused outside check as --business is.
test('rename r2: check takes --org as --business, once per spelling, and refuses before any unit read',async t=>{
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
assert.equal((await s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--create'])).status,0);
const copy=join(s.copies,s.id),argv=join(s.root,'argv.log'),base=['check','--copies',s.copies,'--bin',s.bin];
s.fake(`echo "$*" >> '${argv}'; echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}'; case "$*" in *mosaic-bus*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; esac\n`);
const shows=b=>[`--user show mosaic-bus@${b}.service -p LoadState -p WorkingDirectory -p ExecStart`,'--user show mosaic-webui.service -p LoadState -p WorkingDirectory -p ExecStart'];
for(const sel of [['--business','demo'],['--org','demo'],['--business','demo','--org','demo'],['--org','demo','--business','demo'],['--org','demo','--units','user']]){
const r=await s.run('stack-copy.sh',[...base,...sel]);
assert.equal(r.status,0,sel.join(' ')+r.stderr);assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`,sel.join(' '));
assert.deepEqual(readFileSync(argv,'utf8').trimEnd().split('\n'),shows('demo'),sel.join(' '));rmSync(argv);
}
// The selector may come before the other options too.
let r=await s.run('stack-copy.sh',['check','--org','demo','--copies',s.copies,'--bin',s.bin]);assert.equal(r.status,0,r.stderr);
assert.deepEqual(readFileSync(argv,'utf8').trimEnd().split('\n'),shows('demo'));rmSync(argv);
for(const [sel,status,message] of [
[['--business','demo','--org','beta'],4,'--business demo and --org beta conflict'],
[['--org','beta','--business','demo'],4,'--org beta and --business demo conflict'],
[['--business','demo','--business','demo'],4,'--business given twice'],
[['--org','demo','--org','demo'],4,'--org given twice'],
[['--org','demo','--business','demo','--org','demo'],4,'--org given twice'],
[['--org'],4,'--org needs one value'],
[['--business'],4,'--business needs one value'],
[['--org',''],4,'--org needs one value'],
[['--org','--units','user'],4,'--org needs one value, not --units'],
[['--business','-x'],4,'--business needs one value, not -x'],
[['--org','Demo'],1,'--org Demo is not valid'],
[['--business','demo_x'],1,'--business demo_x is not valid'],
[['--org','-demo'],4,'--org needs one value, not -demo'],
]){
r=await s.run('stack-copy.sh',[...base,...sel]);
assert.equal(r.status,status,sel.join(' '));assert.equal(r.stderr,`stack-copy: ${message}\n`,sel.join(' '));assert.equal(r.stdout,'',sel.join(' '));
assert.ok(!existsSync(argv),`${sel.join(' ')} reads no unit`);
}
// Positional text after a selector value stays a usage error; --org outside check is one too.
for(const args of [[...base,'--org','demo','beta'],['copy',s.id,'--copies',s.copies,'--org','demo'],['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--org','demo'],['copy',s.id,'--business','demo']]){
r=await s.run('stack-copy.sh',args);assert.equal(r.status,4,args.join(' '));assert.match(r.stderr,/^usage: stack-copy\.sh /,args.join(' '));
assert.ok(!existsSync(argv),args.join(' '));
}
});
test('rename r2: check --instance takes --org for its business and still needs one selector',async t=>{
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
assert.equal((await s.run('bootstrap.sh',['--instance','alpha'])).status,0);
assert.equal((await s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin])).status,0);
const copy=join(s.copies,s.id),argv=join(s.root,'argv.log'),base=['check','--instance','alpha','--copies',s.copies,'--bin',s.bin];
s.fake(`echo "$*" >> '${argv}'; echo 'LoadState=loaded';echo 'WorkingDirectory=${copy}';case "$*" in *mosaic-bus-alpha@demo*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *mosaic-webui-alpha*) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; *) exit 1;; esac\n`);
for(const sel of [['--org','demo'],['--business','demo','--org','demo']]){
const r=await s.run('stack-copy.sh',[...base,...sel]);assert.equal(r.status,0,sel.join(' ')+r.stderr);
assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`,sel.join(' '));
assert.equal(readFileSync(argv,'utf8').split('\n')[0],'--user show [email protected] -p LoadState -p WorkingDirectory -p ExecStart',sel.join(' '));rmSync(argv);
}
const r=await s.run('stack-copy.sh',base);assert.equal(r.status,4);assert.match(r.stderr,/^usage: stack-copy\.sh /);assert.ok(!existsSync(argv));
});
test('Vikunja 614: bundle and checkout copy leave an unreadable caller directory before find',async t=>{
const s=setup(t),cwd=join(s.root,'unreadable');mkdirSync(cwd);
const bundle=join(s.root,'release.bundle');git(s.repo,'bundle','create','-q',bundle,'refactor');
+2 -1
View File
@@ -272,7 +272,8 @@ after(()=>{execFileSync('chmod',['-R','u+w',base]);rmSync(base,{recursive:true,f
export const repo=join(base,'repo'),COPIES=join(base,'copies'),SBIN=join(base,'sbin');
mkdirSync(repo);mkdirSync(COPIES);mkdirSync(SBIN);
writeFileSync(join(base,'package.json'),'{"type":"commonjs"}\n');
export const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
// A linked node_modules (a deploy tree) is not a directory to .gitignore's node_modules/; npm ci makes the copy's own.
export const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation',':(exclude,glob)**/node_modules'],{encoding:'utf8'}).trim().split('\n');
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
export const pkg={name:'copy-fixture',version:'1.0.0'};
writeFileSync(join(repo,'package.json'),JSON.stringify(pkg));
+1 -1
View File
@@ -2069,7 +2069,7 @@ export class Store {
const RCOPIES=join(base,'rcopies');mkdirSync(RCOPIES);
for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],[C1,'3g',['3f']]]){
const d=join(RCOPIES,id);
for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/cli/src/access.mjs','packages/cli/src/transport.mjs','packages/bus/src/business.mjs','packages/bus/src/request-store.mjs','packages/bus/src/requirement-catalog.mjs','packages/tasks/src','packages/seat/src/landing.mjs','packages/seat/src/checkout.mjs','packages/processing-control/src','packages/plugins/src/kinds.mjs','packages/business/src','packages/library/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/cli/src/access.mjs','packages/cli/src/org-selector.mjs','packages/cli/src/transport.mjs','packages/bus/src/business.mjs','packages/bus/src/request-store.mjs','packages/bus/src/requirement-catalog.mjs','packages/tasks/src','packages/seat/src/landing.mjs','packages/seat/src/checkout.mjs','packages/processing-control/src','packages/plugins/src/kinds.mjs','packages/business/src','packages/library/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
// Row 333 R2 (#1831): what the business loader imports from outside its directory, model-quota.mjs included.
for(const p of businessImportedFiles(repo))if(!existsSync(join(d,p)))cpSync(join(repo,p),join(d,p));
if(id===C1)writeFileSync(join(d,'scripts','mosaic'),`#!/usr/bin/env bash\nexec node "${join(RCOPIES,B,'packages','cli','src','cli.mjs')}" "$@"\n`,{mode:0o755});
+105 -3
View File
@@ -9,7 +9,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {execFileSync,spawn,spawnSync} from 'node:child_process';
import {mkdirSync,writeFileSync,readFileSync,existsSync,readdirSync,rmSync,chmodSync,copyFileSync,symlinkSync,statSync} from 'node:fs';
import {mkdirSync,writeFileSync,readFileSync,existsSync,readdirSync,rmSync,chmodSync,copyFileSync,symlinkSync,statSync,realpathSync} from 'node:fs';
import {DatabaseSync} from 'node:sqlite';
import {join,dirname} from 'node:path';
import {once} from 'node:events';
@@ -47,7 +47,7 @@ function fakeSudo(K){
// case2-unit.sh: logs its argv, then installs the copy's units as the fixture's user units (its
// WorkingDirectory under the stack's copies) and starts the optional proxy, bus, then Console,
// as case2-unit.sh does. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
// as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
// probe.sh, a second driver, and records its exit and stderr.
function fakeRender(K){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process');
@@ -64,7 +64,7 @@ function fakeRender(K){
const sh=(n,...x)=>cp.execFileSync('bash',[path.join(get('--home'),'src','mosaic-stack','scripts',n),...x],{stdio:['ignore','ignore','inherit']});
sh('bus-service.sh','install','--copy',copy,'--copies',copies,...(pre.includes('--pm')?['--pm']:[]),'--claude',get('--claude'));
sh('webui-service.sh','install','--copy',copy,'--copies',copies,...serve);
const names=[`mosaic-bus@${get('--business')}.service`,'mosaic-webui.service'];
const names=[`mosaic-bus@${get(pre.includes('--business')?'--business':'--org')}.service`,'mosaic-webui.service'];
if(pre.includes('--proxy')){
fs.writeFileSync(path.join(process.env.XDG_CONFIG_HOME,'systemd','user','mosaic-proxy.service'),`[Service]\nWorkingDirectory=${copies}/${copy}\nExecStart=${get('--node')} ${copies}/${copy}/packages/model-proxy/src/run.mjs\n`);
names.unshift('mosaic-proxy.service');
@@ -592,6 +592,108 @@ test('#385 R2-F1 controls: library args without --send and a factory args file p
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
});
// Rename r2, "Selector scopes and parser matrix": --org is the same option as --business before -- (the
// unit's flags) and after it (the serve flags). selArgs moves the business into each scope as given: the
// unit's selectors first, the serve flags' last, the other lines as useed wrote them.
const selArgs=(e,outer,serve)=>{
const l=e.argsText.trimEnd().split('\n'),cut=l.indexOf('--'),drop=(a)=>{a.splice(a.indexOf('--business'),2);return a};
return [...outer,...drop(l.slice(0,cut)),'--',...drop(l.slice(cut+1)),...serve].join('\n')+'\n';
};
test('rename r2: --org or --business names the business before and after --, read_args passes either spelling or both, matched, in either order',async(t)=>{
const e=await useed(t);
// A bundle that does not exist stops the run at its first step after read_args, with nothing stopped.
const gone=join(e.root,'no.bundle');
for(const [outer,serve] of [
[['--org','acme'],['--org','acme']],
[['--business','acme'],['--org','acme']],
[['--org','acme'],['--business','acme']],
[['--business','acme','--org','acme'],['--org','acme','--business','acme']],
[['--org','acme','--business','acme'],['--business','acme','--org','acme']],
]){
const why=`${outer.join(' ')} -- ${serve.join(' ')}`,text=selArgs(e,outer,serve);
writeFileSync(e.args,text);chmodSync(e.args,0o640);
const n=existsSync(e.slog)?slog(e).length:0;
const r=await drive(e,C1,'upgrade','--instance','dev','--bundle',gone,'--sha',C1,...TO);assert.equal(r.code,3,`${why}: ${r.all}`);
assert.deepEqual(slog(e).slice(n).map((x)=>x.split(' ')[0]),['start','fetch','result'],why);
assert.match(slog(e).at(-1),/^result exit=3 fetch-failed /,why);
assert.equal(readFileSync(e.args,'utf8'),text,why);
}
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
});
test('rename r2: an upgrade from an args file that spells both selectors --org keeps every line but --copy\'s value, and the render gets them as written',async(t)=>{
const e=await useed(t);
e.argsText=selArgs(e,['--org','acme'],['--org','acme']);writeFileSync(e.args,e.argsText);
arm(e,'confirm.sh',`cp ${e.args} ${join(e.sd,'args-at-confirm')}\n`);
const r=await upgrade(e,C1);assert.equal(r.code,0,r.all);
assert.equal(r.out.split('\n').at(-2),`stack-upgrade: upgrade ok: ${C1} is live for acme`);
assert.deepEqual(names(e),[...PRE,...A2,'stage','render','confirm','args','result']);
assert.equal(readFileSync(join(e.sd,'args-at-confirm'),'utf8'),e.argsText);
const after=e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`);assert.notEqual(after,e.argsText);
assert.equal(readFileSync(e.args,'utf8'),after);
assert.equal(statSync(e.args).mode&0o777,0o640);assert.deepEqual(readdirSync(e.etc),['dev.args']);
assert.deepEqual(lines(join(e.sd,'render.log')).map((x)=>JSON.parse(x)),[after.trimEnd().split('\n')]);
// The stack's user's steps get the business as a word, never a spelling.
for(const k of ['activate-plan','rehearse','activate','confirm'])assert.ok(JSON.parse(lines(join(e.sd,'sudo.log')).find((x)=>JSON.parse(x).key===k)).cmd.includes('acme'),k);
assert.ok(!lines(join(e.sd,'sudo.log')).some((x)=>JSON.parse(x).cmd.includes('--org')));
assert.deepEqual(live(e),['active','active',C1,C1]);
});
test('rename r2: a bad selector in either scope, or scopes that name different businesses, refuse args-unreadable before anything stops',async(t)=>{
const e=await useed(t),f=e.args;
const l=e.argsText.trimEnd().split('\n'),pre=l.slice(0,l.indexOf('--')).filter((x,i,a)=>x!=='--business'&&a[i-1]!=='--business');
const before=(m)=>`${f} before --: ${m}`,after=(m)=>`${f} after --: ${m}`;
const cases=[
['unit: two spellings, two businesses',selArgs(e,['--business','acme','--org','beta'],['--business','acme']),before('--business acme and --org beta conflict')],
['unit: two businesses, the other order',selArgs(e,['--org','acme','--business','beta'],['--business','acme']),before('--org acme and --business beta conflict')],
['unit: --org twice',selArgs(e,['--org','acme','--org','acme'],['--business','acme']),before('--org given twice')],
['unit: --business twice',selArgs(e,['--business','acme','--business','acme'],['--business','acme']),before('--business given twice')],
['unit: --org at the end',[...pre,'--org'].join('\n')+'\n',before('--org needs one value')],
['unit: --org with an empty line',selArgs(e,['--org',''],['--business','acme']),before('--org needs one value')],
['unit: --org before a flag',selArgs(e,['--org'],['--business','acme']),before('--org needs one value, not --user')],
['unit: --business before --',[...pre,'--business','--','--port',e.port,'--business','acme'].join('\n')+'\n',before('--business needs one value, not --')],
['unit: an invalid business',selArgs(e,['--org','Acme'],['--org','Acme']),before('--org Acme is not valid')],
['unit: --org as --home\'s value is no selector',selArgs(e,['--business','acme'],['--business','acme']).replace(`--home\n${e.env.HOME}\n`,'--home\n--org\n'),`${f} lacks a plain --user, --home, --business, --node or --copy`],
['serve: two spellings, two businesses',selArgs(e,['--business','acme'],['--business','acme','--org','beta']),after('--business acme and --org beta conflict')],
['serve: two businesses, the other order',selArgs(e,['--business','acme'],['--org','beta','--business','acme']),after('--org beta and --business acme conflict')],
['serve: --org twice',selArgs(e,['--business','acme'],['--org','acme','--org','acme']),after('--org given twice')],
['serve: --business twice',selArgs(e,['--org','acme'],['--business','acme','--business','acme']),after('--business given twice')],
['serve: --org at the end',selArgs(e,['--business','acme'],['--org']),after('--org needs one value')],
['serve: --org before a flag',selArgs(e,['--business','acme'],['--org','--sso']),after('--org needs one value, not --sso')],
['serve: an invalid business',selArgs(e,['--business','acme'],['--org','acme_x']),after('--org acme_x is not valid')],
['serve: another business',selArgs(e,['--business','acme'],['--org','beta']),`${f} names business acme before -- and beta after it`],
['serve: another business, unit spelled --org',selArgs(e,['--org','acme'],['--business','beta']),`${f} names business acme before -- and beta after it`],
['serve: no selector',selArgs(e,['--business','acme'],[]),`${f} needs --business or --org after --, as case2-unit.sh does`],
['serve: --business as --allow-user\'s value is no selector',selArgs(e,['--business','acme'],['--allow-user','--business']),`${f} needs --business or --org after --, as case2-unit.sh does`],
['serve: no -- at all',[...pre,'--org','acme'].join('\n')+'\n',`${f} needs --business or --org after --, as case2-unit.sh does`],
['serve: a flag case2-unit.sh does not take',selArgs(e,['--business','acme'],['--business','acme','--tester']),`${f} has --tester after --, which case2-unit.sh does not take`],
];
for(const [why,text,msg] of cases){
writeFileSync(e.args,text);chmodSync(e.args,0o640);
const n=existsSync(e.slog)?slog(e).length:0;
const r=await upgrade(e,C1);assert.equal(r.code,3,`${why}: ${r.all}`);
assert.equal(r.err.split('\n').at(-2),`stack-upgrade: args-unreadable: ${msg} (nothing was stopped)`,why);
assert.deepEqual(slog(e).slice(n).map((x)=>x.split(' ')[0]),['start','result'],why);
assert.equal(readFileSync(e.args,'utf8'),text,why);assert.deepEqual(readdirSync(e.etc),['dev.args'],why);
}
// A rollback reads the same file the same way.
writeFileSync(e.args,cases[0][1]);
const rb=await drive(e,B,'rollback','--instance','dev','--to',B,...TO);assert.equal(rb.code,3,rb.all);
assert.equal(rb.err.split('\n').at(-2),`stack-upgrade: args-unreadable: ${cases[0][2]} (nothing was stopped)`);
assert.ok(!existsSync(join(e.sd,'sudo.log')));assert.ok(!existsSync(join(e.sd,'render.log')));
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
});
test('rename r2: a checkout without scripts/selector.sh refuses checkout-unreadable before reading the args file',async(t)=>{
const e=await useed(t),d=join(realpathSync(e.root),'lone');
mkdirSync(join(d,'scripts'),{recursive:true});copyFileSync(join(checkout(C1),'scripts','stack-upgrade.sh'),join(d,'scripts','stack-upgrade.sh'));
const r=spawnSync('bash',[join(d,'scripts','stack-upgrade.sh'),'upgrade','--instance','dev','--bundle',BUNDLE,'--sha',C1,...TO],{env:e.denv,encoding:'utf8'});
assert.equal(r.status,3,r.stdout+r.stderr);
assert.equal(r.stderr.split('\n').at(-2),`stack-upgrade: checkout-unreadable: cannot read ${d}/scripts/selector.sh (nothing was stopped)`);
assert.deepEqual(slog(e).map((x)=>x.split(' ')[0]),['start','result']);
assert.ok(!existsSync(join(e.sd,'sudo.log')));assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));argsKept(e);
});
// R3-F1 (#385, PR 1836): a library host rendered by base case2-unit.sh has --send in its installed Console
// unit's ExecStart, not only in its args file. With the args line removed, A2 would stop both units and A4
// and render-old would both refuse the installed unit (check_unit's ExecStart and the full-text match), so
@@ -2,20 +2,33 @@
// messages API with FIXTURE_WORD for FIXTURE_MODEL, only on 127.0.0.1, only
// with the published fixture key, and forwards nothing anywhere.
// Run as a process: prints one JSON line {"ready":true,"port":N} and serves
// until SIGTERM/SIGINT or its lifetime ends.
// until SIGTERM/SIGINT or its lifetime (MOSAIC_FIXTURE_TTL_S) ends.
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
import { FIXTURE_KEY, FIXTURE_MODEL, FIXTURE_WORD } from './index.mjs';
export const HOST = '127.0.0.1';
const BODY_MAX = 1024 * 1024;
export const TTL_DEFAULT_S = 14400;
export const TTL_MIN_S = 60;
// The longest delay a Node timer holds; a longer one fires after 1 ms.
export const TTL_MAX_S = 2147483;
export function createEndpoint({ log = () => {} } = {}) {
// Seconds the process lives, from MOSAIC_FIXTURE_TTL_S. Unset, empty or not a number is the
// default; 0 is no expiry (`mosaic dev fixture init --ttl 0`, a named dev instance only);
// anything else is held to [TTL_MIN_S, TTL_MAX_S].
export function lifetimeSeconds(raw) {
const n = raw === undefined || raw === '' ? TTL_DEFAULT_S : Number(raw);
if (!Number.isFinite(n)) return TTL_DEFAULT_S;
return n === 0 ? 0 : Math.min(TTL_MAX_S, Math.max(TTL_MIN_S, n));
}
export function createEndpoint({ log = () => {}, ttlSeconds = null, expiresAt = null } = {}) {
const counts = { messages: 0, refused: 0 };
const server = createServer((req, res) => {
const reply = (status, body, type = 'application/json') => { res.writeHead(status, { 'content-type': type }); res.end(typeof body === 'string' ? body : JSON.stringify(body)); };
const error = (status, type, message) => { counts.refused++; log(`${req.method} ${req.url} ${status} ${type}`); reply(status, { type: 'error', error: { type, message } }); };
if (req.method === 'GET' && req.url === '/health') return reply(200, { ok: true, model: FIXTURE_MODEL, upstream: 'none', counts });
if (req.method === 'GET' && req.url === '/health') return reply(200, { ok: true, model: FIXTURE_MODEL, upstream: 'none', counts, ttlSeconds, expiresAt });
if (req.method !== 'POST' || !/^\/v1\/messages(\?|$)/.test(req.url ?? '')) return error(404, 'not_found_error', 'fixture serves POST /v1/messages only');
if (req.headers['x-api-key'] !== FIXTURE_KEY) return error(401, 'authentication_error', 'fixture accepts only the fixture key');
let size = 0; const chunks = [];
@@ -50,14 +63,24 @@ export function createEndpoint({ log = () => {} } = {}) {
};
}
async function main() {
const ttl = Number(process.env.MOSAIC_FIXTURE_TTL_S ?? 14400);
const endpoint = createEndpoint({ log: line => process.stderr.write(`${new Date().toISOString()} ${line}\n`) });
const { port } = await endpoint.listen(0);
process.stdout.write(`${JSON.stringify({ ready: true, port, pid: process.pid })}\n`);
const stop = async () => { await endpoint.close(); process.exit(0); };
process.on('SIGTERM', stop); process.on('SIGINT', stop);
// Listens on loopback and runs `expire` once ttlSeconds pass; ttlSeconds 0 never expires.
export async function serve({ ttlSeconds, log, expire }) {
const expiresAt = ttlSeconds ? new Date(Date.now() + ttlSeconds * 1000).toISOString() : null;
const endpoint = createEndpoint({ log, ttlSeconds, expiresAt });
const address = await endpoint.listen(0);
// A bounded lifetime, so an endpoint a crashed caller left behind still ends.
setTimeout(stop, Math.max(60, Number.isFinite(ttl) ? ttl : 14400) * 1000).unref();
const timer = ttlSeconds ? setTimeout(expire, ttlSeconds * 1000) : null;
timer?.unref();
return { endpoint, ...address, expiresAt, timer };
}
async function main() {
let endpoint;
// Expiry and SIGTERM end the process the same way, leaving any record its caller wrote.
const stop = async () => { await endpoint?.close(); process.exit(0); };
const served = await serve({ ttlSeconds: lifetimeSeconds(process.env.MOSAIC_FIXTURE_TTL_S), log: line => process.stderr.write(`${new Date().toISOString()} ${line}\n`), expire: stop });
endpoint = served.endpoint;
process.stdout.write(`${JSON.stringify({ ready: true, port: served.port, pid: process.pid })}\n`);
process.on('SIGTERM', stop); process.on('SIGINT', stop);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main().catch(e => { process.stderr.write(`fixture endpoint: ${e.message}\n`); process.exit(1); });
@@ -4,7 +4,7 @@ import { request } from 'node:http';
import { createLoader, hasModule } from '../src/loader.mjs';
import { selectModelProvider } from '../src/model-provider-selection.mjs';
import * as fixture from '../model-provider/fixture/index.mjs';
import { createEndpoint, HOST } from '../model-provider/fixture/endpoint.mjs';
import { createEndpoint, HOST, lifetimeSeconds, serve, TTL_DEFAULT_S, TTL_MAX_S, TTL_MIN_S } from '../model-provider/fixture/endpoint.mjs';
const { FIXTURE_ENTRY, FIXTURE_KEY, FIXTURE_MODEL, FIXTURE_WORD } = fixture;
@@ -82,3 +82,37 @@ test('the endpoint binds loopback, answers the fixed word and refuses a wrong ke
assert.equal((await call(baseUrl, { method: 'GET' })).status, 404);
assert.deepEqual(ep.counts, { messages: 2, refused: 4 });
});
// P19 #882: the lifetime an endpoint process takes from MOSAIC_FIXTURE_TTL_S.
test('the endpoint lifetime: a default, 0 for no expiry, and bounds a Node timer can hold', () => {
for (const raw of [undefined, '', 'soon']) assert.equal(lifetimeSeconds(raw), TTL_DEFAULT_S, String(raw));
assert.equal(TTL_DEFAULT_S, 14400);
assert.equal(lifetimeSeconds('0'), 0);
assert.equal(lifetimeSeconds('120'), 120);
for (const raw of ['5', '-3']) assert.equal(lifetimeSeconds(raw), TTL_MIN_S, raw);
// Past 2^31-1 ms a timer fires after 1 ms, so a longer lifetime is held to the longest one.
for (const raw of [String(TTL_MAX_S + 1), '1e12']) assert.equal(lifetimeSeconds(raw), TTL_MAX_S, raw);
assert.ok(TTL_MAX_S * 1000 <= 2 ** 31 - 1);
});
test('serve expires the endpoint when its lifetime ends, and never with a lifetime of 0', async t => {
const open = await serve({ ttlSeconds: 0, expire: () => assert.fail('a lifetime of 0 expired') });
t.after(() => open.endpoint.close());
assert.equal(open.timer, null);
const health = JSON.parse((await call(open.baseUrl, { method: 'GET', path: '/health' })).text);
assert.deepEqual([health.ok, health.ttlSeconds, health.expiresAt], [true, 0, null]);
t.mock.timers.enable({ apis: ['setTimeout'] });
let expired = 0;
const before = Date.now();
const timed = await serve({ ttlSeconds: 120, expire: () => { expired++; } });
t.after(() => timed.endpoint.close());
const at = Date.parse(timed.expiresAt) - before;
assert.ok(at >= 120000 && at < 130000, timed.expiresAt);
t.mock.timers.tick(119999);
assert.equal(expired, 0);
t.mock.timers.tick(1);
assert.equal(expired, 1);
t.mock.timers.tick(TTL_MAX_S * 1000);
assert.equal(expired, 1, 'a lifetime of 0 set no timer');
});
+1
View File
@@ -8,6 +8,7 @@ import { ProjectError, checkedId, documentPath, noLinks, projectRepo, testerProj
import { git, identity, inspectGit, requireClean } from './git.mjs';
import { validateRecord } from './validate.mjs';
export { ProjectError, projectRepo, testerProjectArea, validateRecord };
export { recordContext } from './paths.mjs';
const DIRECTORIES = ['spec', 'docs', 'requirements', 'decisions', 'templates'];
const TEMPLATES = ['project.json', 'requirement.json', 'decision.json', 'document.md', 'north-star.md'];
+16
View File
@@ -31,6 +31,22 @@ const contains = (base, child) => {
const rel = relative(base, child);
return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
};
// Resolve organization record storage once for CLI, Console and bus. An old instance-root
// repository is never silently hidden by selecting the organization root.
export function recordContext({ business, project, dataRoot, orgRoot = null }) {
if (orgRoot === null) return { business, project, dataRoot };
checkedId(business?.id); checkedId(project); absolute(dataRoot); absolute(orgRoot);
const entry = business.projects?.[project];
if (entry) {
const old = join(dataRoot, 'projects', business.id, project);
const target = join(orgRoot, 'projects', business.id, project);
if (old !== target) {
try { lstatSync(old); throw new ProjectError('project-records-conflict', `instance-root records exist at ${old}; organization records belong at ${target}. Inspect both before continuing`); }
catch (e) { if (e.code !== 'ENOENT') throw e; }
}
}
return { business, project, dataRoot: orgRoot };
}
export function projectRepo({ business, project, dataRoot }, { allowLibrary = false } = {}) {
checkedId(business?.id); checkedId(project);
if (!Object.hasOwn(business?.projects ?? {}, project))
+11
View File
@@ -24,6 +24,17 @@ async function ready(t, custom) {
return { c, repo: initialized.repo, commit: initialized.commit };
}
test('organization records refuse instance-root conflicts even with an explicit repository', t => {
const c = context(t, 'records'), orgRoot = join(c.root, 'org');
const old = join(c.dataRoot, 'projects/acme/stack'), target = join(orgRoot, 'projects/acme/stack');
mkdirSync(old, { recursive: true }); mkdirSync(target, { recursive: true });
writeFileSync(join(old, 'keep'), 'old'); writeFileSync(join(target, 'keep'), 'org');
assert.throws(() => api.recordContext({ ...c, orgRoot }), e =>
e.code === 'project-records-conflict' && e.message.includes(old) && e.message.includes(target));
assert.equal(readFileSync(join(old, 'keep'), 'utf8'), 'old');
assert.equal(readFileSync(join(target, 'keep'), 'utf8'), 'org');
});
test('C2-04: record APIs refuse a library before creating a repository', async t => {
const c = context(t);
c.business.projects = { books: { kind: 'library' }, ...c.business.projects };
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { initProject, readSpec } from '../src/index.mjs';
const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false', '-c', 'user.name=fixture', '-c', '[email protected]', '-C', repo, ...args], { encoding: 'utf8' });
const bytes = (repo, rev, path) => git(repo, 'show', `${rev}:${path}`);
test('v1 metadata at each reachable committed revision is checked before reading', async t => {
const root = mkdtempSync(join(tmpdir(), 'revision-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const business = { id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } } };
const ctx = { business, project: 'stack', dataRoot: root };
const { repo, commit: initial } = await initProject(ctx);
const original = bytes(repo, initial, 'project.json');
const meta = JSON.parse(original);
assert.deepEqual({ ...meta, createdAt: '<time>' }, { layoutVersion: 1, business: 'acme', project: 'stack', name: 'stack', createdAt: '<time>' });
assert.match(meta.createdAt, /^\d{4}-\d\d-\d\dT/);
assert.equal(bytes(repo, initial, 'templates/project.json'), readFileSync(new URL('../templates/project.json', import.meta.url), 'utf8'));
const save = (metadata, content) => {
writeFileSync(join(repo, 'project.json'), JSON.stringify(metadata, null, 2) + '\n');
writeFileSync(join(repo, 'spec/north-star.md'), content);
git(repo, 'add', 'project.json', 'spec/north-star.md');
git(repo, 'commit', '-m', 'fixture revision');
return git(repo, 'rev-parse', 'HEAD').trim();
};
const valid = save({ ...meta, name: 'Second' }, '# Second\n');
const badOrg = save({ ...meta, business: 'other' }, '# Wrong org\n');
const badProject = save({ ...meta, project: 'other' }, '# Wrong project\n');
const badMix = save({ ...meta, organization: 'acme' }, '# Mixed\n');
const head = save(meta, '# Head\n');
for (const rev of [initial, valid, head]) assert.equal((await readSpec({ ...ctx, commit: rev })).revision, rev);
assert.equal((await readSpec({ ...ctx, commit: valid })).content, '# Second\n');
for (const rev of [badOrg, badProject, badMix]) await assert.rejects(readSpec({ ...ctx, commit: rev }), { code: 'project-invalid' });
writeFileSync(join(repo, 'project.json'), '{"business":"dirty"}');
assert.equal((await readSpec({ ...ctx, commit: initial })).revision, initial);
assert.equal(bytes(repo, initial, 'project.json'), original);
});
+3
View File
@@ -1333,3 +1333,6 @@ state, dead writer lock and inactive socket. It never sends a signal. Retry
`mosaic console start` and `mosaic console stop` move a dead or mismatched
Console state to `console.json.stale-<UTC timestamp>-<suffix>`, report the
move on stderr, and continue. A live matching Console blocks another start.
`mosaic console status` prints the recorded state (pid, URL, `bind`, and
`allowHosts` for a LAN Console) and whether that process still runs. It moves
nothing. `--dev-insecure-bind` and `--dev-allow-host` are in docs/TOOLS.md.
+32 -9
View File
@@ -18,20 +18,29 @@ import { showOrg } from '../../business/src/org-service.mjs';
import { configDir } from '../../business/src/business.mjs';
import { consoleReads, redactor } from './reads.mjs';
import { createDevOwner } from './dev-owner.mjs';
import { devOwnerInstance } from '../../cli/src/dev-local-owner.mjs';
import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.mjs';
import { createSso, readSlot } from './sso.mjs';
import { statSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
import { selectorScope } from '../../business/src/selector.mjs';
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] [--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] [--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;
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null;
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.
const selector = selectorScope({
valid: id => ID_PATTERN.test(id),
refuse: (kind, why) => { throw new Error(kind === 'invalid' || kind === 'flag' ? 'business must be a business id' : why); },
});
while (args.length) {
const flag = args.shift();
if (selector.owns(flag)) { selector.take(flag, args.shift()); continue; }
// Row 76 (#1570): the one flag without a value.
if (flag === '--send') { sendOn = true; continue; }
// Q175 phase 1 (#1690): the Console signs people in itself, from console.sso in config.json.
@@ -53,19 +62,23 @@ try {
if (!dir) throw new Error('--checkout must be the absolute path of a directory');
options.checkout = value;
}
else if (flag === '--business') {
if (!ID_PATTERN.test(value)) throw new Error('business must be a business id');
options.business = value;
}
// Row 74 (#1568): the preview mode, all three or none, checked before any config is read.
else if (flag === '--public-host') preview.publicHost = value;
else if (flag === '--auth-header') preview.authHeader = value;
else if (flag === '--allow-user') preview.allowUser = value;
else if (flag === '--proof-header') proofFlags.proofHeader = value;
else if (flag === '--proof-file') proofFlags.proofFile = value;
// org-lan-bind: the dev owner on the LAN, no sign-in. Both flags or neither, and only with
// --dev-local-owner (devLanOptions below, before any config is read).
else if (flag === '--dev-insecure-bind') {
if (devBind !== null) throw new Error('--dev-insecure-bind takes one address');
devBind = value;
} else if (flag === '--dev-allow-host') devNames.push(value);
else throw new Error(`unknown option: ${flag}`);
}
if (selector.given) options.business = selector.value;
if (devOwner && (ssoOn || testerSocket || Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--dev-local-owner cannot be combined with --sso, preview, proof or --tester-socket');
const lan = devLanOptions({ devOwner, bind: devBind, names: devNames });
const instance = devOwner ? devOwnerInstance() : null;
// The local-development console command has no separate --send flag.
if (devOwner) sendOn = true;
@@ -130,13 +143,16 @@ try {
return {
...consoleBus(call, { send: sendOn, ...(session.user === undefined ? {} : { owner }) }),
decisionWrite: session.user === undefined && sendOn ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
// The owner reads request status on the selected organization's bus, as on the root Console. Request
// writes stay off: the README "Console actions" inventory marks them unavailable for the organizations release.
requestRead: session.user === undefined ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
reads: consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: id, orgRoot: root }),
};
},
}) : null;
let server;
try {
server = await startServer({ 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({ ...(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) {
@@ -162,8 +178,15 @@ try {
const health = sso?.health() ?? {};
const ssoLines = sso && !devOwner ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send), grant Approve and Deny' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
const port = server.address().port;
if (devOwner) console.log(`DEV LOCAL OWNER MODE: no sign-in, every local request acts as the owner (named instance ${instance})`);
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
// 0.0.0.0 is reachable on loopback too; a specific address only at that address.
const urlHost = !lan || lan.bind === '0.0.0.0' ? '127.0.0.1' : lan.bind;
if (lan) {
console.error(`WARNING: dev owner Console listens on ${lan.bind}:${port} with no sign-in; anyone who can reach it acts as the owner.`);
console.log(`Listening: ${lan.bind}:${port}, allowed Host names ${lan.allowHosts.map(name => `${name}:${port}`).join(', ')}`);
}
console.log(`Mosaic Console: http://${urlHost}:${port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
+28 -2
View File
@@ -21,13 +21,16 @@
// handler returns false for them.
import { open } from 'node:fs/promises';
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';
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\/([^/]+)))?$/;
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
// The org-template placeholder (packages/business/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.
@@ -42,6 +45,7 @@ export function verbFor(step, id, opts = {}) {
if (step === 'project') return `mosaic org project repo init ${id} ${opts.project} ${tail}`;
if (step === 'archive' || step === 'restore') return `mosaic org ${step} ${id} --if-revision ${opts.revision}`;
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`;
throw new Error(`unknown step ${step}`);
}
@@ -428,7 +432,8 @@ async function route(req, res, { path, search, org, admin, principal, write, log
return json(res, 201, { outcome: 'done', step: 'create', receipt: forReceipt(ctx, receipt), verb: verbFor('create', body.id) });
}
if (!org) return json(res, 404, { error: 'not found' });
const [, id, kind, sub, project] = org;
const [, id, kind, sub, repoProject, recordsProject] = org;
const project = repoProject ?? recordsProject;
if (!ID.test(id)) return json(res, 404, { error: 'org-unavailable' });
const service = await need(admin, 'service', res, kind); if (!service) return;
const ctx = await ctxOf(admin, principal);
@@ -479,6 +484,27 @@ async function route(req, res, { path, search, org, admin, principal, write, log
try { receipt = await service.updateOrg(ctx, id, doc, { ifRevision: body.ifRevision }); } catch (e) { return refusal(res, e, { step: 'update', id, unknownOk: true }); }
return json(res, 200, { outcome: 'done', step: 'update', receipt: forReceipt(ctx, receipt), verb: verbFor('update', id, { revision: body.ifRevision }) });
}
if (kind === 'setup' && sub?.startsWith('records/')) {
let name;
try { name = decodeURIComponent(project); } catch { return json(res, 404, { error: 'not found' }); }
if (!ID.test(name)) return json(res, 404, { error: 'not found' });
if (write) { const body = await readBody(req); strictKeys(body, []); }
try {
const shown = await service.showOrg(ctx, id);
if (!Object.hasOwn(shown.doc?.projects ?? {}, name)) return json(res, 404, { error: 'project-unknown', step: 'records' });
const context = recordContext({ business: shown.doc, project: name, dataRoot: ctx.dataRoot, orgRoot: shown.root });
const repo = projectRepo(context);
let exists = true;
try { lstatSync(repo); } catch (e) { if (e.code === 'ENOENT') exists = false; else throw e; }
if (!exists && write) { refuseInsideAgent(); await initProject(context); }
if (!exists && !write) return json(res, 200, { state: 'missing', step: 'records', verb: verbFor('records', id, { project: name }) });
const checked = await checkProject(context);
if (!checked.ok) return json(res, 409, { error: 'readback-failed', step: 'records', message: 'Project records could not be verified; inspect them before retrying.' });
return json(res, 200, { state: 'done', outcome: 'done', step: 'records', verb: verbFor('records', id, { project: name }) });
} catch (e) {
return json(res, 409, { error: e.code ?? 'readback-failed', step: 'records', message: plainMessage(ctx, e.message ?? 'Project records could not be verified.') });
}
}
if (kind === 'setup' && !sub && !write) {
try { return json(res, 200, await readSetup(admin, ctx, id)); } catch (e) { return refusal(res, e, { step: 'setup', id }); }
}
+18 -3
View File
@@ -185,13 +185,14 @@
}
const historyButton = r => `<button type="button" class="btn conv-open" data-history="${esc(key(r))}" aria-label="History for ${esc(r.agent)}">History</button>`;
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
const activityCell = r => relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `<span class="source">${esc(r.lastActivity)}</span>`}` : 'unknown';
function cards(rows) {
if (!rows.length) return '<p class="muted">Nothing here.</p>';
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'))}</span><span>${historyButton(r)}${seenButton(r)}</span></div></li>`).join('')}</ul>`;
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted" data-board-age="${esc(key(r))}">${esc(relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'))}</span><span>${historyButton(r)}${seenButton(r)}</span></div></li>`).join('')}</ul>`;
}
function table(rows) {
if (!rows.length) return `<div class="empty"><b>No sessions match these filters.</b><p>${data.sessions.length ? 'Clear Hide offline or Hide seen, or pick All projects, to see the rest.' : 'Sessions appear here when the board scan finds an agent session on this machine.'}</p></div>`;
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"${selected === key(r) ? ' class="is-selected"' : ''}><td>${openButton(r)}<span class="source">${esc(r.project)}</span>${historyButton(r)}</td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${relaunchNotice(r) ? esc(relaunchNotice(r)) : activity(r) ? `${esc(activity(r))}${activity(r) === r.lastActivity ? '' : `<span class="source">${esc(r.lastActivity)}</span>`}` : 'unknown'}</td></tr>`).join('')}</tbody></table></div>`;
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"${selected === key(r) ? ' class="is-selected"' : ''}><td>${openButton(r)}<span class="source">${esc(r.project)}</span>${historyButton(r)}</td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td data-board-activity="${esc(key(r))}">${activityCell(r)}</td></tr>`).join('')}</tbody></table></div>`;
}
function registered(r) {
const reg = r.registered;
@@ -432,10 +433,24 @@
function accept(value) {
if (out()) return; // a read that was in flight when the page signed out
if (!value || !Array.isArray(value.sessions) || value.sessions.some(r => !r || typeof r.project !== 'string' || typeof r.agent !== 'string')) throw new Error('Invalid board session data');
const canonical = v => Array.isArray(v) ? v.map(canonical) : v && typeof v === 'object'
? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v;
const material = board => JSON.stringify(canonical({ counts: board.counts, sessions: board.sessions.map(({ageSeconds, scannedAt, ...row}) => row) }));
const unchanged = data && material(data) === material(value);
data = value; failed = false; $('error').hidden = true; $('error').className = 'banner err';
$('board-refused').hidden = true; document.body.classList.remove('board-refused');
for (const id of awaiting.keys()) { const r = row(id); if (r && !pending(r)) awaiting.delete(id); }
render();
if (unchanged) {
for (const r of data.sessions) {
for (const el of document.querySelectorAll('[data-board-age]')) if (el.dataset.boardAge === key(r)) { const text = relaunchNotice(r) || (activity(r) ? `Last activity ${activity(r)}` : 'No activity'); if (el.textContent !== text) el.textContent = text; }
for (const el of document.querySelectorAll('[data-board-activity]')) if (el.dataset.boardActivity === key(r)) { const html = activityCell(r); if (el.innerHTML !== html) el.innerHTML = html; }
}
const selectedRow = row(selected);
const field = [...$('inspection').querySelectorAll('dt')].find(el => /^(Historical )?Last activity$/.test(el.textContent))?.nextElementSibling;
if (selectedRow && field) { const text = !selectedRow.lastActivity ? 'unknown' : activity(selectedRow) === selectedRow.lastActivity ? selectedRow.lastActivity : `${activity(selectedRow)} (${selectedRow.lastActivity})`; if (field.textContent !== text) field.textContent = text; }
$('status').textContent = `Scanned ${window.MosaicLocalTime(data.generatedAt)} · ${paused ? 'auto-refresh paused' : 'refresh every 10s'}`;
fresh();
} else render();
}
// A refusal (403) fails closed: the last data is dropped, not kept or shown as current, and so is
// everything drawn from it: the cards, the table, the inspector, an open conversation and its
+1 -1
View File
@@ -167,7 +167,7 @@
document.addEventListener('mosaic-processing-state', e => {
halted = e.detail.halted;
const cell = $('dashboard-kpis').lastElementChild;
if (cell) { cell.querySelector('strong').textContent = halted === null ? '–' : halted ? 'Stopped' : 'On'; cell.querySelector('span').textContent = halted === null ? 'Not available yet' : 'Instance status'; }
if (cell) { const strong=cell.querySelector('strong'), span=cell.querySelector('span'), value=halted === null ? '–' : halted ? 'Stopped' : 'On', caption=halted === null ? 'Not available yet' : 'Instance status'; if(strong.textContent!==value) strong.textContent=value; if(span.textContent!==caption) span.textContent=caption; }
});
setInterval(age, 1000);
const button = $('avatar-button'), menu = $('avatar-menu');
+6
View File
@@ -54,3 +54,9 @@ table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;col
/* Role names stay whole; the wrapper scrolls if they need more room. */
table.s1-roles { min-width:480px; }
table.s1-roles td,table.s1-roles th { overflow-wrap:normal; word-break:normal; }
/* Role names remain readable without making the organization panel wider on phones. */
@media (max-width: 699px) {
table.s1-roles { min-width: 0; width: 100%; table-layout: fixed; }
table.s1-roles td, table.s1-roles th { overflow-wrap: anywhere; }
}
+57 -9
View File
@@ -22,6 +22,18 @@
// ---------------------------------------------------------------- reads
// One GET per Q1 read. The last good answer for each read is kept, so a failed read
// can show what was read before, labelled as such.
let spriteRetry = false, spriteVersion = 0;
window.MosaicIcons = {
failed() { spriteRetry = true; },
succeeded() {
if (!spriteRetry) return;
spriteRetry = false; spriteVersion++;
for (const use of document.querySelectorAll('use[href^="/icons.svg"]')) {
const fragment = use.getAttribute('href').split('#')[1];
if (fragment) use.setAttribute('href', `/icons.svg?retry=${spriteVersion}#${fragment}`);
}
},
};
const last = new Map();
class ReadError extends Error { constructor(code, message, status) { super(message); this.code = code; this.status = status; } }
// Row 95 (#1589): every Console fetch takes `redirect: 'manual'`. A redirect comes only from the
@@ -39,11 +51,13 @@
if (out()) throw signedOut();
let res, body;
try { res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : verb === 'messages' ? 'after' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }
catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
catch { window.MosaicIcons.failed(); throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
try { body = await res.json(); } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
try { body = await res.json(); } catch { window.MosaicIcons.failed(); throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
if (!res.ok) window.MosaicIcons.failed();
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
window.MosaicIcons.succeeded();
return { ...body, rows: body.rows, at: body.at };
}
// Row 54 (#1543): the queue, business and settings reads (src/reads.mjs), GET only. Their
@@ -52,11 +66,13 @@
if (out()) throw signedOut();
const unreachable = () => Object.assign(new ReadError('unreachable', 'The Console server did not answer.', 0), { module: true });
let res, body;
try { res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' }); } catch { throw unreachable(); }
try { res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' }); } catch { window.MosaicIcons.failed(); throw unreachable(); }
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
try { body = await res.json(); } catch { throw unreachable(); }
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
if (!res.ok) window.MosaicIcons.failed();
if (!res.ok || !body || typeof body !== 'object' || Array.isArray(body)) throw Object.assign(new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status), { module: true });
window.MosaicIcons.succeeded();
return body;
}
// A reader for one render. `live` asks the server; otherwise only kept answers are used.
@@ -227,6 +243,8 @@
const blockChip = text => `<span class="tag tag-block">${txt(text)}</span>`;
// An empty state says what would appear and where it comes from.
const empty = (what, from) => `<div class="empty"><b>${what}</b>${from ? `<p>${from}</p>` : ''}</div>`;
// "New organization" everywhere it is offered: the Organizations form with the ID field focused (org-create.js reads ?new=1).
const NEW_ORG = '#/settings/organizations?new=1';
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(String(ref).split('/')[1])}</a>` : '';
// Rows without an actor role come from the human (CLI outside any run, or the signed-in Console)
// or from the broker itself.
@@ -251,7 +269,7 @@
const items = [['/', 'Dashboard', '', 'board'], ['/projects', 'Projects', '', 'inbox'], ['/inbox', 'Inbox', inboxCount?.open > 0 ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount > 0 ? `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>` : '', 'talk'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
const cur = h === '/' ? (route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true">${h === '/projects' ? '<path d="M2 5h6l2 2h8v11H2z"/>' : `<use href="/icons.svg#i-${icon}"/>`}</svg>${l}${extra ? ` ${extra}` : ''}</a>${h === '/projects' ? '<div id="project-tree" aria-label="Project conversations"></div>' : ''}</li>`;
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true">${h === '/projects' ? '<path d="M2 5h6l2 2h8v11H2z"/>' : `<use href="/icons.svg${spriteVersion ? `?retry=${spriteVersion}` : ''}#i-${icon}"/>`}</svg>${l}${extra ? ` ${extra}` : ''}</a>${h === '/projects' ? '<div id="project-tree" aria-label="Project conversations"></div>' : ''}</li>`;
}).join('');
window.MosaicProjectTree?.mount?.({ container: $('project-tree'), path: route });
if (focused) [...$('sections').querySelectorAll('a[href]')].find(a => a.getAttribute('href') === focused)?.focus({ preventScroll: true });
@@ -274,6 +292,32 @@
// reply to its own message, so a reply to a Console send stays unread in `mosaic inbox` until
// the human marks it read with `mosaic inbox --read`.
let isTester = false, orgSetup = false;
// Both Docs and project direction use the same owner-only records setup action.
window.MosaicRecords = {
markup(project) {
const org = window.MosaicOrg?.id;
if (!org) return 'This project has no records yet. Initialize its record repository to add documents.';
const command = `mosaic project init ${org} ${project}`;
return `This project has no records yet. ${orgSetup ? `<button type="button" class="btn" data-record-init="${esc(project)}">Initialize project records</button> ` : 'Run '}<code>${txt(command)}</code><span role="status" data-record-receipt></span>`;
},
};
document.addEventListener('click', async e => {
const button = e.target.closest?.('[data-record-init]');
if (!button || button.disabled || !orgSetup || !window.MosaicOrg) return;
button.disabled = true;
const status = button.parentElement.querySelector('[data-record-receipt]');
const url = `/api/orgs/${encodeURIComponent(window.MosaicOrg.id)}/setup/records/${encodeURIComponent(button.dataset.recordInit)}`;
try {
const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
const body = await response.json();
if (!response.ok || body.outcome !== 'done' || body.step !== 'records') throw Error(body.message || 'Initialization was refused. Inspect the project before retrying.');
const readback = await fetch(url, { cache: 'no-store' });
const checked = await readback.json();
if (!readback.ok || checked.state !== 'done') throw Error('Read-back failed. Inspect the project before retrying.');
document.getElementById('refresh')?.click();
} catch (error) { if (status) status.textContent = ` ${error.message || 'Outcome unknown. Inspect the project before retrying.'}`; }
});
let configRead = Promise.resolve();
let canSend = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
const REPLIES = 50; // the broker's cap
@@ -579,7 +623,7 @@
let html;
if (organizationError) html = `<div class="banner err" role="alert"><p>${txt(organizationError)}</p>${retry}</div>`;
else if (invalidOrganizations.length) html = `<div class="banner err" role="alert">${invalidOrganizations.map(row => `<p>${txt(row.id)}: invalid — ${txt(row.reason)}</p>`).join('')}${retry}</div>`;
else if (noOrganizations && err?.code === 'org-unavailable') html = empty('No organizations yet.', 'Create one in <a href="#/settings/organizations">Settings &gt; Organizations</a>.');
else if (noOrganizations && err?.code === 'org-unavailable') html = `<div class="empty"><b>No organizations yet.</b><p>Create one in <a href="#/settings/organizations">Settings &gt; Organizations</a>.</p><p><a class="btn" href="${NEW_ORG}">New organization</a></p></div>`;
else if (err && (refusal(err) || !r)) html = `<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows no held roles rather than a guess.</p>${retry}</div>`;
else if (!r) return; // not read yet: the skeleton stays
else {
@@ -708,7 +752,8 @@
async function businessView(get) {
const b = (await get.api('business')).business || {};
const orgs = window.MosaicOrg ? await window.MosaicOrg.list : null;
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}${org.invalid ? ' disabled' : ''}>${txt(org.label || org.id)}${org.invalid ? ' · Invalid' : org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select></section>` : '';
if (orgs) await configRead; // orgSetup decides the "New organization" link
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}${org.invalid ? ' disabled' : ''}>${txt(org.label || org.id)}${org.invalid ? ' · Invalid' : org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select>${orgSetup ? `<p><a href="${NEW_ORG}">New organization</a></p>` : ''}</section>` : '';
const instances = Array.isArray(b.instances) ? b.instances : [], creds = Array.isArray(b.credentials) ? b.credentials : [], actions = Array.isArray(b.actions) ? b.actions : [];
const launch = b.launch || {};
const authKnown = instances.some(i => i.authority);
@@ -1204,7 +1249,7 @@
try { files = (await get.api(`project-files?project=${enc(p)}`)).files; }
catch (err) { if (err.code === 'signed-out' || refusal(err)) throw err; code = err.code; }
files = Array.isArray(files) ? files.filter(f => typeof f?.path === 'string' && DOC_PATH.test(f.path)) : [];
blocks.push(`<section class="panel" aria-labelledby="doc-p-${esc(p)}"><h2 id="doc-p-${esc(p)}">${txt(p)} <span class="count">${files.length}</span></h2>${code === 'project-records-missing' ? '<p>This project has no records yet. Initialize its record repository to add documents.</p>' : code ? `<p class="small warn">These files could not be read. Read again to retry. <code>${txt(code)}</code></p>${retry}` : files.length ? `<ul class="s1-plain">${files.map(f => `<li><a href="#/docs/${enc(p)}/${enc(f.path)}">${txt(f.path)}</a></li>`).join('')}</ul>` : '<p class="muted">No spec or docs files.</p>'}</section>`);
blocks.push(`<section class="panel" aria-labelledby="doc-p-${esc(p)}"><h2 id="doc-p-${esc(p)}">${txt(p)} <span class="count">${files.length}</span></h2>${code === 'project-records-missing' ? `<p>${window.MosaicRecords.markup(p)}</p>` : code ? `<p class="small warn">These files could not be read. Read again to retry. <code>${txt(code)}</code></p>${retry}` : files.length ? `<ul class="s1-plain">${files.map(f => `<li><a href="#/docs/${enc(p)}/${enc(f.path)}">${txt(f.path)}</a></li>`).join('')}</ul>` : '<p class="muted">No spec or docs files.</p>'}</section>`);
}
return `${head('Docs', `${readLine()} The spec and the files under docs/ of each project. ${canEdit ? 'Open one to edit it; a save makes one commit in the project with you as the author.' : 'Saving needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.'}`)}
${blocks.join('') || empty('This business declares no projects.', 'A project shows here when the business file names it.')}`;
@@ -1335,7 +1380,7 @@
const refusal = err => err.status === 403 || ['not-configured', 'no-bus-host', 'queue-refused'].includes(err.code);
// After a refusal nothing read before is shown anywhere: not in a view, the palette or the Inbox count.
function forget() { window.MosaicChat?.forget(); last.clear(); inboxCount = null; messageCount = null; }
const failedView = err => `${head(failTitle(err))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err, err.code === 'queue-refused')} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : err.module ? 'Read again.' : 'Read again, or come back when the bus answers.')}`;
const failedView = (err, path) => `${head(path === '/queue' ? 'Queue' : failTitle(err))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err, err.code === 'queue-refused')} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : err.module ? 'Read again.' : 'Read again, or come back when the bus answers.')}`;
const staleBanner = err => `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
// Freshness line in the command bar for bus pages.
const stamp = iso => { const t = Date.parse(iso); return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 19)} UTC` : 'unknown'; };
@@ -1484,7 +1529,7 @@
if (err.code === 'signed-out') return;
const kept = reader(false);
try { if (refusal(err)) throw err; html = await view(kept, path, new URLSearchParams(query), opts, current); readAt = kept.oldest(); html = staleBanner(err) + html; state = 'stale'; }
catch { html = failedView(err) + (path === '/settings' ? appearance() : ''); state = refusal(err) ? 'refused' : 'failed'; }
catch { html = failedView(err, path) + (path === '/settings' ? appearance() : ''); state = refusal(err) ? 'refused' : 'failed'; }
if (refusal(err)) forget();
}
if (!current()) return;
@@ -1657,6 +1702,9 @@
canSend = !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;
// The header menu offers "New organization" to the owner of a Console with organizations; nobody else can create one.
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) {
+173 -40
View File
@@ -2,6 +2,15 @@
(() => {
'use strict';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// crypto.randomUUID exists only in a secure context; a dev owner Console opened by LAN name
// (http://NAME:PORT/) is not one, so build the same v4 form from getRandomValues there.
const opId = () => {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80;
const h = [...b].map(x => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
};
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const inert = value => String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g,
c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
@@ -76,13 +85,15 @@
});
} catch (err) {
if (err.name === 'AbortError') throw err;
window.MosaicIcons?.failed();
throw new ChatError(value === undefined ? 'unreachable' : 'outcome-unknown');
}
if (res.type === 'opaqueredirect' || res.status === 401) { signOut(); throw new ChatError('signed-out'); }
let data;
try { data = await res.json(); }
catch { throw new ChatError(value === undefined ? 'invalid-response' : 'outcome-unknown'); }
if (!res.ok) throw new ChatError(typeof data?.error === 'string' ? data.error : 'invalid-response', res.status, data?.sent);
if (!res.ok) { window.MosaicIcons?.failed(); throw new ChatError(typeof data?.error === 'string' ? data.error : 'invalid-response', res.status, data?.sent); }
window.MosaicIcons?.succeeded();
if (!data || typeof data !== 'object' || (value === undefined && !path.includes('/operation?') && !Array.isArray(data.rows))) {
throw new ChatError(value === undefined ? 'invalid-response' : 'outcome-unknown');
}
@@ -96,7 +107,7 @@
const heading = text => `<h1 tabindex="-1">${txt(text)}</h1>`;
const selectedInactive = () => { const s = current?.sessions?.find(r => r.id === current.session); return !!s?.state && s.state !== 'active'; };
const disabledSession = pending => disabled(pending || selectedInactive());
const disabled = pending => !current.canSend || out() || pending ? ' disabled' : '';
const disabled = pending => !current.canSend || current.orgArchived || out() || pending ? ' disabled' : '';
const stamp = at => { const date=new Date(at); const projectSurface=window.MosaicProjects && !current?.user && !current?.tester && (!legacyRoute() || current.path?.startsWith('/projects')); return projectSurface && Number.isFinite(date.getTime()) ? `<time datetime="${esc(at)}">${txt(date.toLocaleTimeString([], {hour:'numeric',minute:'2-digit'}))}</time>` : `<time>${txt(at)}</time>`; };
// Per-grant UI hints; all writes still use the caller's bus principal.
const scopeWrites = (project, instance) => !current.user || current.chatGrants.some(g =>
@@ -167,7 +178,7 @@
if (l.error) return `<div class="chat-library"><p role="status">Library status unavailable (${txt(l.error)}).</p></div>`;
const v = l.value, parts = [];
parts.push(SHA.test(v.served ?? '') ? `Library at <code>${txt(v.served.slice(0, 7))}</code>` : 'Library not served yet');
if (typeof v.lastFetch === 'string') parts.push(`fetched ${txt(ago(v.lastFetch))}`);
if (typeof v.lastFetch === 'string') parts.push(`<span data-library-age="${esc(v.lastFetch)}">fetched ${txt(ago(v.lastFetch))}</span>`);
if (SHA.test(v.diverged?.commit ?? '')) parts.push(`<b>library-diverged</b> at <code>${txt(v.diverged.commit.slice(0, 7))}</code>`);
if (CODE.test(v.lastError?.code ?? '')) parts.push(`fetch error <code>${txt(v.lastError.code)}</code>`);
const limits = v.replyLimits;
@@ -196,7 +207,7 @@
}
if (g !== generation || out()) return;
libraries.set(project, NO_LIBRARY.includes(entry.error) ? { hidden: true } : entry);
paint();
paint(false, true);
}
// design (writes): Revert is library.revert {commit}, her act. A refusal shows its code; before
// C2-02's verb exists that is the bus's refusal of the verb.
@@ -412,7 +423,7 @@
if (err.status === 403 || err.status === 404) { s.rows = []; current.members = []; current.projects = []; }
current.projectArchived = current.projects.some(p => p.id === id && p.archived);
}
if (g === generation && !out()) { paint(navigated); void loadLibrary(libraryOf(current), g, signal); }
if (g === generation && !out()) { paint(navigated, true); void loadLibrary(libraryOf(current), g, signal); }
}
async function openRoom(projectId) {
const c = current, id = projectId || c?.id;
@@ -477,18 +488,120 @@
const dialogFocus = dialog?.contains(active) ? { id:active.id, index:[...dialog.querySelectorAll('button,a,input,select,textarea,[tabindex]')].indexOf(active), value:'value' in active ? active.value : null, start:active.selectionStart, end:active.selectionEnd } : null;
const toggles = [...current.container.querySelectorAll('.project-new-conversation,.project-create')].map(e=>e.open);
const content = current.container.querySelector('[data-project-content]');
const projectFocus = content?.contains(active) ? {id:active.id,index:[...content.querySelectorAll('a,button,select,input,textarea,summary,[tabindex]')].indexOf(active)} : null;
const disclosures = content ? [...content.querySelectorAll('details')].map((e,i)=>e.open?i:-1).filter(i=>i>=0) : [];
const { projectFocus, disclosures, closed } = window.MosaicProjects?.captureView ? window.MosaicProjects.captureView(content) : { projectFocus:null, disclosures:[], closed:[] };
// A repaint can replace a log while its own pin is waiting for layout.
// Carry that intent only while the offset still matches the pending pin.
return { tabScroll: current.container.querySelector('.project-tabs')?.scrollLeft, field, dialog:dialog?.id, dialogValues, dialogFocus, toggles, projectFocus, disclosures, detailsOpen: current.container.querySelector('.chat-details')?.open, top: log ? (pageFlow(log) ? document.scrollingElement.scrollTop : log.scrollTop) : undefined, tail: log ? transcriptAtTail(log) : true, settlingPin: !!log && !pageFlow(log) && (pinnedScrolls.get(log) === log.scrollTop || settlingPins.get(log) === log.scrollTop) };
return { tabScroll: current.container.querySelector('.project-tabs')?.scrollLeft, field, dialog:dialog?.id, dialogValues, dialogFocus, toggles, projectFocus, disclosures, closed, detailsOpen: current.container.querySelector('.chat-details')?.open, top: log ? (pageFlow(log) ? document.scrollingElement.scrollTop : log.scrollTop) : undefined, tail: log ? transcriptAtTail(log) : true, settlingPin: !!log && !pageFlow(log) && (pinnedScrolls.get(log) === log.scrollTop || settlingPins.get(log) === log.scrollTop) };
}
let transcriptObserver = null, transcriptResize = null;
function paint(navigated = false) {
let transcriptObserver = null, transcriptResize = null, lastPaint = null;
const visualKey = html => html.replace(/(<textarea\b[^>]*>)[\s\S]*?(<\/textarea>)/g, '$1$2')
.replace(/<span data-library-age="[^"]*">[^<]*<\/span>/g, '<span data-library-age></span>');
function refreshLibraryAge(c) {
const el = c.container.querySelector('[data-library-age]');
const at = libraries.get(libraryOf(c, c.thread))?.value?.lastFetch;
if (!el || typeof at !== 'string') return;
const text = `fetched ${ago(at)}`;
if (el.textContent !== text) el.textContent = text;
if (el.dataset.libraryAge !== at) el.dataset.libraryAge = at;
}
// Refreshes often read exactly the same view. Remember the generated presentation,
// not the mutable DOM (which also contains independently mounted request controls).
function refreshProjectData(c, redesigned, restore) {
if (c.user || c.tester || !window.MosaicProjects) return;
const project = c.projects.find(p => p.id === c.id);
const projectContent = c.container.querySelector('[data-project-content]');
if (redesigned && (c.kind === 'project' || c.kind === 'thread' || c.kind === 'room')) {
const surfaceProject = c.kind === 'thread' ? c.projects.find(p => p.id === c.thread?.project) : project;
if (surfaceProject) void window.MosaicProjects.enhanceSurface(c.container, surfaceProject, c.threads, c.kind === 'thread' ? c.thread : null, c.canSend, form('room-open:' + surfaceProject.id).receipt);
}
if (projectContent && project) void window.MosaicProjects.mountContent({ container: projectContent, project, threads: c.threads, section: c.section, canSend: c.canSend, restore, roomReceipt: form('room-open:' + project.id).receipt });
if (c.kind === 'list') window.MosaicProjects.enhanceList(c.container, c.projects, c.threads);
}
// Request panels, files and the request summary reread on every refresh, including one whose
// visible view is unchanged: a request's phase moves without a new message. A quiet reread
// keeps the present content until a changed result arrives.
function refreshRequestData(c, quiet) {
window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived });
const t = c.kind === 'thread' ? c.thread : null;
const repo = c.kind === 'project' ? c.projects.find(p => p.id === c.id)?.repo : libraryOf(c, t);
const cached = knownRequests.get(c.id);
const remembered = c.kind === 'project' && cached && cached.repo === repo ? cached.ids : [];
const selected = t ? requestRows(state(c.key).rows) : new Set();
const ids = !roomOwner() ? [] : t ? [...new Set(state(c.key).rows.filter(m => selected.has(m.id)).map(m => m.request))] : [...remembered].slice(-REQUEST_ROWS);
const files = c.container.querySelector('[data-chat-files]');
if (files && typeof window.MosaicFiles?.mount === 'function') { if (!quiet) files.replaceChildren(); window.MosaicFiles.mount({ container: files, requestIds: ids, key: `thread:${c.id}`, quiet }); }
const summary = c.container.querySelector('[data-chat-summary]');
if (summary && repo && ids.length) {
if (typeof window.MosaicRequests?.projectSummary === 'function') window.MosaicRequests.projectSummary({ container: summary, requestIds: ids, project: repo, quiet });
else summary.textContent = 'Request summary unavailable.';
}
}
// When only the transcript changed, reconcile its keyed children in the
// existing log. In particular, never detach an unchanged message or composer.
function updateTranscript(c, html, next, keep) {
if (!lastPaint || lastPaint.container !== c.container || lastPaint.path !== c.path) return false;
const previous = document.createElement('template'); previous.innerHTML = lastPaint.html;
const oldLog = previous.content.querySelector('.chat-log'), newLog = next.content.querySelector('.chat-log');
const liveLog = c.container.querySelector('.chat-log');
if (!oldLog || !newLog || !liveLog) return false;
const oldItems = [...oldLog.children], newItems = [...newLog.children], liveItems = [...liveLog.children];
oldLog.replaceChildren(); newLog.replaceChildren();
const outside = template => visualKey(template.innerHTML);
if (outside(previous) !== outside(next)) return false;
const key = el => el.dataset.message ? 'message:' + el.dataset.message : el.querySelector('[data-origin-message]') ? 'request:' + el.querySelector('[data-origin-message]').dataset.originMessage : null;
const oldByKey = new Map(oldItems.map(el => [key(el), el]).filter(([id]) => id));
const liveByKey = new Map(liveItems.map(el => [key(el), el]).filter(([id]) => id));
const desired = newItems.map(el => {
const id = key(el), old = id && oldByKey.get(id), live = id && liveByKey.get(id);
return old && live && old.outerHTML === el.outerHTML ? live : el;
});
for (let i = 0; i < desired.length; i++) if (liveLog.children[i] !== desired[i]) liveLog.insertBefore(desired[i], liveLog.children[i] || null);
while (liveLog.children.length > desired.length) liveLog.lastElementChild.remove();
window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived });
const s = state(c.key ?? c.id);
if (s.tailRequested || s.follow && ((keep?.tail ?? true) || keep?.settlingPin)) pinTranscript(liveLog);
else if (keep?.top !== undefined) { if (pageFlow(liveLog)) window.scrollTo(0, keep.top); else liveLog.scrollTop = keep.top; }
s.tailRequested = false;
return true;
}
function updateConversations(c, next) {
if (c.kind !== 'project' || c.section !== 'conversations' || !lastPaint || lastPaint.container !== c.container || lastPaint.path !== c.path) return false;
const previous = document.createElement('template'); previous.innerHTML = lastPaint.html;
const oldList = previous.content.querySelector('[data-project-conversations]'), newList = next.content.querySelector('[data-project-conversations]');
const liveList = c.container.querySelector('[data-project-conversations]');
if (!oldList || !newList || !liveList) return false;
const oldItems = [...oldList.children], newItems = [...newList.children], liveItems = [...liveList.children];
oldList.replaceChildren(); newList.replaceChildren();
const normalize = template => {
const heading = template.content.querySelector('.project-heading p');
for (const node of heading?.childNodes || []) if (node.nodeType === Node.TEXT_NODE) node.data = node.data.replace(/\d+ conversations?/g, '# conversations');
for (const count of template.content.querySelectorAll('.project-tabs .project-count')) count.textContent = '#';
return visualKey(template.innerHTML);
};
if (normalize(previous) !== normalize(next)) return false;
const oldById = new Map(oldItems.filter(el => el.dataset.conversationId).map(el => [el.dataset.conversationId, el]));
const liveById = new Map(liveItems.filter(el => el.dataset.conversationId).map(el => [el.dataset.conversationId, el]));
const desired = newItems.map(el => {
const id = el.dataset.conversationId, old = oldById.get(id), live = liveById.get(id);
return old && live && old.outerHTML === el.outerHTML ? live : el;
});
const focused = liveList.contains(document.activeElement) ? document.activeElement : null;
for (let i = 0; i < desired.length; i++) if (liveList.children[i] !== desired[i]) liveList.insertBefore(desired[i], liveList.children[i] || null);
while (liveList.children.length > desired.length) liveList.lastElementChild.remove();
if (focused?.isConnected && document.activeElement !== focused) focused.focus({preventScroll:true});
const count = c.threads.filter(t => !t.shared && t.project === c.id).length;
const heading = c.container.querySelector('.project-heading p');
for (const node of heading?.childNodes || []) if (node.nodeType === Node.TEXT_NODE) { const value = node.data.replace(/\d+ conversations?/g, `${count} ${count === 1 ? 'conversation' : 'conversations'}`); if (node.data !== value) node.data = value; }
const tab = c.container.querySelector('.project-tabs [href$="/conversations"] .project-count');
if (tab && tab.textContent !== String(count)) tab.textContent = String(count);
return true;
}
function paint(navigated = false, read = false) {
if (!current || out()) return;
const keep = capture(), c = current;
const redesigned = (!legacyRoute() || c.path?.startsWith('/projects')) && !!window.MosaicProjects && !c.user && !c.tester;
let html = '<div class="chat-area"><p><a href="#/projects">Projects</a></p>';
if (c.orgArchived) html += `<p role="status">${c.orgArchived === 'room-not-open' ? 'This room is not open.' : 'This organization is archived.'} Sending is unavailable; your draft is kept.</p>`;
if (c.error && !(c.kind === 'room' && c.error === ROOM_LINES['room-not-open'] && redesigned)) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
if (c.archiveUnknown && !c.loading) html += '<p role="status" data-archive-notice>Archive status unavailable. Some changes are unavailable; sending may be refused; refresh to check.</p>';
else if (c.kind === 'thread' && c.projectArchived) html += '<p role="status">This project is archived; its conversation stays readable.</p>';
@@ -517,9 +630,41 @@
} 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 += '<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 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.
const fingerprint = visualKey(html);
if (read && !navigated && lastPaint?.container === c.container && lastPaint.path === c.path && lastPaint.fingerprint === fingerprint && c.container.querySelector('.chat-area')) {
lastPaint.html = html;
refreshLibraryAge(c);
refreshProjectData(c, redesigned, keep);
refreshRequestData(c, true);
updateRoomCompletion();
return;
}
const next = document.createElement('template');
next.innerHTML = html;
if (read && !navigated && (updateTranscript(c, html, next, keep) || updateConversations(c, next))) {
lastPaint = { container: c.container, path: c.path, html, fingerprint };
refreshLibraryAge(c);
refreshProjectData(c, redesigned, keep);
refreshRequestData(c, true);
updateRoomCompletion();
return;
}
// updateTranscript empties the template log while comparing the rest.
next.innerHTML = html;
// Keep existing, unchanged message nodes when a later read adds more messages.
if (!navigated && lastPaint?.container === c.container && lastPaint.path === c.path) {
for (const message of next.content.querySelectorAll('[data-message]')) {
const previous = [...c.container.querySelectorAll('[data-message]')].find(el => el.dataset.message === message.dataset.message);
if (previous && previous.outerHTML === message.outerHTML) message.replaceWith(previous);
}
}
lastPaint = { container: c.container, path: c.path, html, fingerprint };
transcriptObserver?.disconnect(); transcriptResize?.disconnect();
c.container.innerHTML = html + '</div>';
c.container.replaceChildren(next.content);
const observedLog = c.container.querySelector('.project-chat .chat-log');
if (observedLog) {
const observedState = state(c.key ?? c.id);
@@ -533,20 +678,7 @@
if (pageFlow(observedLog)) transcriptResize.observe(c.container);
}
}
window.MosaicRequests?.mount?.(c.container, { archiveUnknown: !!c.archiveUnknown, projectArchived: !!c.projectArchived });
const t = c.kind === 'thread' ? c.thread : null;
const repo = c.kind === 'project' ? c.projects.find(p => p.id === c.id)?.repo : libraryOf(c, t);
const cached = knownRequests.get(c.id);
const remembered = c.kind === 'project' && cached && cached.repo === repo ? cached.ids : [];
const selected = t ? requestRows(state(c.key).rows) : new Set();
const ids = !roomOwner() ? [] : t ? [...new Set(state(c.key).rows.filter(m => selected.has(m.id)).map(m => m.request))] : [...remembered].slice(-REQUEST_ROWS);
const files = c.container.querySelector('[data-chat-files]');
if (files && typeof window.MosaicFiles?.mount === 'function') { files.replaceChildren(); window.MosaicFiles.mount({ container: files, requestIds: ids, key: `thread:${c.id}` }); }
const summary = c.container.querySelector('[data-chat-summary]');
if (summary && repo && ids.length) {
if (typeof window.MosaicRequests?.projectSummary === 'function') window.MosaicRequests.projectSummary({ container: summary, requestIds: ids, project: repo });
else summary.textContent = 'Request summary unavailable.';
}
refreshRequestData(c, false);
const remote = c.container.querySelector('[data-chat-remote]');
const project = c.projects.find(p => p.id === c.id);
if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); }
@@ -555,12 +687,7 @@
const controls = c.container.querySelector('.project-archive-control');
if (controls) c.container.querySelector('.project-tabs')?.insertAdjacentElement('afterend', controls);
}
if (!c.user && !c.tester && window.MosaicProjects) {
const projectContent = c.container.querySelector('[data-project-content]');
if (redesigned && (c.kind === 'project' || c.kind === 'thread' || c.kind === 'room')) { const surfaceProject = c.kind === 'thread' ? c.projects.find(p=>p.id===c.thread?.project) : project; if(surfaceProject) void window.MosaicProjects.enhanceSurface(c.container, surfaceProject, c.threads, c.kind === 'thread' ? c.thread : null, c.canSend, form('room-open:' + surfaceProject.id).receipt); }
if (projectContent && project) void window.MosaicProjects.mountContent({ container: projectContent, project, threads: c.threads, section: c.section, canSend: c.canSend, restore: !navigated ? keep : null, roomReceipt: form('room-open:' + project.id).receipt });
if (c.kind === 'list') window.MosaicProjects.enhanceList(c.container, c.projects, c.threads);
}
refreshProjectData(c, redesigned, !navigated ? keep : null);
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
if (!navigated && keep?.toggles) [...c.container.querySelectorAll('.project-new-conversation,.project-create')].forEach((e,i)=>{e.open=!!keep.toggles[i];});
const details = c.container.querySelector('.chat-details');
@@ -630,13 +757,14 @@
paint(); return;
}
const old = current;
current = { container, kind, id, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
current = { container, kind, id, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived);
if (kind === 'thread' && old?.kind === 'thread' && old.id === id && !navigated && old.archiveUnknown === false && old.projects.some(p => p.id === old.thread?.project)) current.archiveUnknown = false;
if (current.projectArchived) current.archiveUnknown = false;
// Keep the previous conversation in place, but immediately withdraw controls whose
// server paths lack archive guards, and the request panel's writes.
const withdraw = () => {
lastPaint = null;
for (const el of container.querySelectorAll('#chat-session-form input, #chat-session-form button, #chat-share-form input, #chat-share-form button, [data-unshare], #chat-cancel, [data-revert]')) el.disabled = true;
window.MosaicRequests?.mount?.(container, { archiveUnknown: !!current.archiveUnknown, projectArchived: !!current.projectArchived });
if (!current.archiveUnknown) return;
@@ -672,6 +800,7 @@
]);
if (g !== generation || out()) return;
current.channelDisabled = false;
current.orgArchived = false;
current.projects = projects.rows;
const own = new Set(listed.rows.map(t => t.id));
current.threads = [...listed.rows, ...shared.rows.filter(t => !own.has(t.id)).map(t => ({ ...t, shared: true }))];
@@ -823,7 +952,7 @@
}
if (project) document.dispatchEvent(new CustomEvent('mosaic-project-context', { detail: { projectId, repositoryKey: project.repo ?? null, thread: kind === 'thread' ? id : null, session: kind === 'thread' ? current.session : null } }));
}
paint(navigated);
paint(navigated, true);
const t = kind === 'thread' ? current.threads.find(t => t.id === id) : null;
if (t) void loadLibrary(libraryOf(current, t), g, signal);
}
@@ -831,6 +960,7 @@
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
current.loading = false;
current.error = readError(err);
if (err.code === 'org-archived' || err.code === 'room-not-open') current.orgArchived = err.code;
current.readFailed = true;
if (err.code === 'channel-disabled') current.channelDisabled = true;
// A bad session selector is local to that read. Only a thread-level refusal
@@ -846,7 +976,7 @@
current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived);
if (current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project))) current.archiveUnknown = false;
else if (kind === 'thread' && !user && !tester) current.archiveUnknown = true;
paint(navigated);
paint(navigated, true);
}
}
function refresh(next = false) {
@@ -917,17 +1047,20 @@
// input that scrolls nothing gives no later scroll event to decide it.
settlingPins.set(log, pinned);
const mark = ++settleMark;
requestAnimationFrame(() => requestAnimationFrame(() => {
// Resize delivery can lag the scroll event by several frames. Keep the
// provenance long enough for the observer to restore a following tail.
const settle = frames => frames ? requestAnimationFrame(() => settle(frames - 1)) : (() => {
if (mark !== settleMark) return;
if (settlingPins.get(log) === pinned) settlingPins.delete(log);
if (log.isConnected && scroller.scrollTop === pinned && !transcriptAtTail(log)) s.follow = false;
}));
})();
settle(6);
return;
}
s.follow = transcriptAtTail(log);
}, true);
async function write(action, key, value, path) {
if (!current || !current.canSend || out() || current.channelDisabled) return;
if (!current || !current.canSend || current.orgArchived || out() || current.channelDisabled) return;
const isThread = action === 'send' || action === 'open';
if ((isThread || action === 'createSession') && !threadWrites()) return;
if (action === 'createSession' && current.archiveUnknown) return;
@@ -982,7 +1115,7 @@
} else if (event.target.id === 'chat-session-form') {
const key = 'session:' + current.id, f = form(key);
// Keep an ambiguous create's key and payload; editing requires a deliberate new attempt.
f.op ??= crypto.randomUUID();
f.op ??= opId();
f.payload ??= { name: f.name, op: f.op };
void write('createSession', key, f.payload, `threads/${current.id}/sessions`);
} else if (event.target.id === 'chat-operation-form') {
@@ -1091,7 +1224,7 @@
if (!window.confirm(`Clear context for "${selected.name}"? This ends this session after a safe stop and starts fresh context. History and the old checkout (including unpushed work) are retained. The successor uses a fresh approved checkout; old work is not copied. This can wait for safe stop or capacity.`)) return;
// A lost answer retains this key. Only status reads are allowed afterward.
if (f.op && !['cancelled', 'failed'].includes(f.record?.state)) { await checkClear(c.id, key, f); return; }
f.op = crypto.randomUUID(); f.pending = true; f.receipt = `Operation ${f.op}: requesting clear.`; paint();
f.op = opId(); f.pending = true; f.receipt = `Operation ${f.op}: requesting clear.`; paint();
try {
const r = await request(`threads/${c.id}/clear`, { session: selected.id, revision: selected.revision, confirm: true, op: f.op });
if (out()) return;
+7 -4
View File
@@ -32,7 +32,7 @@
else { a.target = '_blank'; a.rel = 'noopener noreferrer'; }
return a;
}
async function mount({ container, requestIds, key }) {
async function mount({ container, requestIds, key, quiet = false }) {
if (!(container instanceof Element)) return;
const generation = (generations.get(container) || 0) + 1;
generations.set(container, generation);
@@ -41,17 +41,20 @@
const cacheKey = typeof key === 'string' && key.length > 0 && key.length <= 320 ? `key:${key}` : `ids:${JSON.stringify(ids)}`;
const order = ++started;
const previous = !signedOut && !document.body.classList.contains('signed-out') && cache.get(cacheKey);
container.replaceChildren(...(previous ? previous.map(node => node.cloneNode(true)) : [element('p', 'Reading files…')]));
// A quiet reread keeps the present list (and any focus in it) until a changed result arrives.
const keep = quiet && container.childNodes.length > 0;
if (!keep) container.replaceChildren(...(previous ? previous.map(node => node.cloneNode(true)) : [element('p', 'Reading files…')]));
const same = nodes => keep && container.innerHTML === nodes.map(node => node.outerHTML ?? node.textContent).join('');
// A settled read paints and caches only if no later-started mount for this key has already
// committed; otherwise this container shows that newer result. A pending later mount never
// blocks an earlier completion. Callers keep their own fences before calling this.
function commit(nodes) {
if ((committed.get(cacheKey) || 0) > order) {
const newer = cache.get(cacheKey);
if (newer) container.replaceChildren(...newer.map(node => node.cloneNode(true)));
if (newer && !same(newer)) container.replaceChildren(...newer.map(node => node.cloneNode(true)));
return;
}
container.replaceChildren(...nodes);
if (!same(nodes)) container.replaceChildren(...nodes);
if (signedOut || document.body.classList.contains('signed-out')) return;
committed.set(cacheKey, order);
cache.set(cacheKey, nodes.map(node => node.cloneNode(true)));
+7 -2
View File
@@ -309,6 +309,7 @@
{ title: `Initialize project repository: ${project}`, verb: `mosaic org project repo init ${id} ${project} ${remote ? `--remote ${remote}` : '--local-only --accept-local-loss-risk'}` },
{ title: 'Start the bus', verb: `mosaic bus start ${id} --dev-local-owner --detach` },
{ title: `Create project: ${project}`, verb: `mosaic project create ${project} --repo ${project} --business ${id}` },
{ title: 'Initialize project records', verb: `mosaic project init ${id} ${project}` },
];
const list = $('org-quick-steps');
list.innerHTML = steps.map((s, i) => `<li class="panel org-step" data-quick-step="${i}" tabindex="-1"><div class="org-step-head"><h4>${txt(s.title)}</h4><span class="org-pill">Not started</span></div><p class="org-verb">Same as <code>${txt(s.verb)}</code></p><p class="org-field-err" role="alert"></p></li>`).join('');
@@ -325,6 +326,7 @@
[`/api/orgs/${enc(id)}/setup/project/${enc(project)}`, remote ? { mode: 'remote', remote } : local, 'project'],
[`/api/orgs/${enc(id)}/setup/bus`, {}, 'bus'],
[`/api/orgs/${enc(id)}/chat/projects`, { name: project, repo: project }, 'chat-project'],
[`/api/orgs/${enc(id)}/setup/records/${enc(project)}`, {}, 'records'],
];
for (current = 0; current < requests.length; current++) {
const card = list.children[current]; card.querySelector('.org-pill').textContent = 'In progress';
@@ -343,7 +345,7 @@
card.querySelector('.org-pill').textContent = 'Stopped'; card.querySelector('.org-field-err').textContent = current === 4
? `${e.message} Open the organization and check Projects before creating the project; its outcome may be unknown.`
: `${e.message} Open the organization to inspect and continue; do not repeat a step whose outcome is unknown.`;
if (current === 4) { editorDirty = false; $('org-new-id').value = ''; $('org-quick-open-org').hidden = false; }
if (current >= 4) { editorDirty = false; $('org-new-id').value = ''; $('org-quick-open-org').hidden = false; }
card.classList.add('org-step-warn'); card.focus(); status('Setup stopped.');
await refreshList();
}
@@ -620,9 +622,12 @@
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; });
const params = query(), org = params.get('org'), setup = params.get('step') === 'setup', created = params.get('created') === '1';
// ?new=1 ("New organization" in the header menu, the Organization picker and the empty state): focus the ID field, then drop
// the flag so the same link focuses it again next time. This runs after the page heading took the navigation focus.
if (params.get('new') === '1' && !org) { $('org-new-id').focus(); setQuery(null, null); }
await refreshList();
// Resume: ?org=ID reopens the organization and its setup, read back from the server.
const params = query(), org = params.get('org'), setup = params.get('step') === 'setup', created = params.get('created') === '1';
if (org && ID.test(org)) {
await openExisting(org, { toSetup: setup });
if (created && st.setup?.notifications?.receipt) showReceipt('Organization created', st.setup.notifications);
+7 -1
View File
@@ -12,7 +12,7 @@
? new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })
: new Event('mosaic-signed-out'));
};
let snapshot = null, canWrite = false, busy = false, sequence = 0, timer;
let snapshot = null, canWrite = false, busy = false, sequence = 0, timer, drawn;
async function responseBody(response) {
if (response.type === 'opaqueredirect') { signOut(false); return null; }
const body = await response.json();
@@ -20,6 +20,12 @@
return body;
}
function draw() {
const signature = JSON.stringify([snapshot?.version, snapshot?.generation, snapshot?.halted, snapshot?.stopping, canWrite, busy, out()]);
if (signature === drawn) {
document.dispatchEvent(new CustomEvent('mosaic-processing-state', { detail: { halted: snapshot?.halted ?? null } }));
return;
}
drawn = signature;
const halted = snapshot?.halted === true, valid = snapshot?.version === 1 && UUID.test(snapshot?.generation);
host.dataset.state = halted ? 'halted' : snapshot?.halted === false ? 'running' : 'unknown';
status.textContent = halted ? snapshot.stopping ? 'Processing halted. Stopping workloads.' : 'Processing halted.'
+9 -3
View File
@@ -81,7 +81,9 @@
.room-chip[aria-pressed=true] { background:var(--nav-active); border-color:var(--nav-active-border); color:var(--strong); }
.project-card-icon svg,.project-header-icon svg,.project-tree-row svg { width:55%; height:55%; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linejoin:round; }
.project-tree-row svg { width:16px; height:16px; flex:none; margin-right:6px; }
.breadcrumbs:has(.project-crumb-current) a { display:inline-flex; align-items:center; min-width:44px; min-height:44px; }
.breadcrumbs:has(.project-crumb-current) a { position:relative; display:inline-flex; align-items:center; min-height:44px; }
/* The 44px target width comes from an overlay, not the layout box: a short name such as "work" must not leave blank space that reads as an empty crumb. */
.breadcrumbs:has(.project-crumb-current) a::after { content:''; position:absolute; top:0; bottom:0; left:50%; width:max(100%,44px); transform:translateX(-50%); }
.chat-area:has(.project-header),.chat-area:has(.project-list-head) { max-width:none; margin-inline:0; }
.project-tabs { scrollbar-width:none; } .project-tabs::-webkit-scrollbar { display:none; }
@media (max-width:699px) { .project-tabs { mask-image:linear-gradient(to right, #000 calc(100% - 22px), transparent 100%); } }
@@ -255,7 +257,9 @@ body:has(.project-chat) #bus-view, body:has(.project-chat) .chat-area { flex:1 1
.project-chat .chat-audience .room-chip { display:inline-flex; align-items:center; min-height:32px; margin-left:6px; background:var(--nav-active); border-color:var(--nav-active-border); }
.project-chat [data-conversation-request-status]:empty { display:none; }
.chat-conversation.project-chat > .project-header { margin-bottom:0; }
.project-chat [data-conversation-request-status] { min-height:16px; line-height:16px; margin:0; }
.project-work-wait { min-height:1.5em; }
/* Conversation fidelity and single-line tree labels. */
.project-tree-row a { font-size:14px; font-weight:600; }
.project-tree-children a { font-size:14px; font-weight:500; }
@@ -267,7 +271,6 @@ body:has(.project-chat) #bus-view, body:has(.project-chat) .chat-area { flex:1 1
.project-chat .chat-message-meta { opacity:1; font-size:12px; line-height:16px; }
.project-chat .chat-message-meta strong { font-size:13px; font-weight:600; }
.project-chat .chat-body { font-size:14px; }
.project-chat [data-conversation-request-status]:not(:empty) { margin:16px 0 0; }
@media(max-width:699px) { .project-heading p { font-size:14px; } }
.project-tree-children a { padding-left:40px; }
@@ -498,3 +501,6 @@ body:has(.chat-area .project-header):has(.project-chat) .breadcrumbs { margin-bo
}
/* A long Conversation title wraps beside Details instead of squeezing it. */
.project-header[data-project-conversation="true"] .project-actions { flex:none; }
/* Keep the action in flow when a read error occupies the top of the page. */
@media (min-width:700px) { .chat-area:has(.chat-error) .project-create:not([open]) { position:static; margin:12px 0; } }
+78 -23
View File
@@ -29,6 +29,7 @@
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const storageKey=window.MosaicOrg ? `mosaic-project-tree:${window.MosaicOrg.id}` : 'mosaic-project-tree';
let treeRows=[], treeThreads=[], treeGeneration=0, treeState, treeRead=null, treeReadAt=0;
const treeFocus = new WeakMap();
try { treeState=JSON.parse(localStorage.getItem(storageKey) || '{}'); if (!treeState || Array.isArray(treeState) || typeof treeState !== 'object') treeState={}; } catch { treeState={}; }
async function read(url) {
const response=await fetch(url,{cache:'no-store',redirect:'manual'});
@@ -45,12 +46,13 @@
function drawTree(container, route) {
if(!container?.isConnected || document.body.classList.contains('signed-out')) return;
const current=currentProject(route), threadId=route.match(/\/threads\/([0-9a-f-]{36})/i)?.[1], room=/\/rooms\//.test(route);
container.innerHTML=`<ul class="project-tree-list">${treeRows.filter(p=>!p.archived && UUID.test(p.id)).map(p=>{
const html=`<ul class="project-tree-list">${treeRows.filter(p=>!p.archived && UUID.test(p.id)).map(p=>{
const expanded=Object.hasOwn(treeState,p.id)?treeState[p.id]:p.id===current;
const own=treeThreads.filter(t=>t.project===p.id && !t.shared && UUID.test(t.id)).slice(-5).reverse();
const active=p.id===current && !room && !threadId;
return `<li><div class="project-tree-row${active?' active':''}"><button type="button" data-project-disclosure="${p.id}" aria-expanded="${!!expanded}" aria-controls="project-children-${p.id}" aria-label="${expanded?'Collapse':'Expand'} ${clean(p.name)}">${expanded?'⌄':'›'}</button><a href="${path(p.id,'overview')}" title="${clean(p.name)}"${active?' aria-current="page"':''}>${folder}<span>${clean(p.name)}</span></a></div><ul id="project-children-${p.id}" class="project-tree-children"${expanded?'':' hidden'}><li><a href="${path(p.id,'room')}"${p.id===current && room?' aria-current="page"':''}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 4h18v14H8l-5 3z"/></svg><span>Project room</span></a></li>${own.map(t=>`<li><a href="#/projects/threads/${t.id}"${t.id===threadId?' aria-current="page"':''} title="${clean(t.name)}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 4h18v14H8l-5 3z"/></svg><span>${clean(t.name)}</span><small>Private conversation</small></a></li>`).join('')}<li><a href="${path(p.id,'conversations')}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg><span>View conversations</span></a></li></ul></li>`;
}).join('')}</ul>`;
if (container.dataset.treePaint !== html) { container.innerHTML = html; container.dataset.treePaint = html; }
const ancestor=document.querySelector('#sections > li > a[href="#/projects"]'); if(ancestor) { ancestor.classList.toggle('project-tree-ancestor',!!current); if(current) ancestor.removeAttribute('aria-current'); }
}
async function mountTree({container,path:route}) {
@@ -62,10 +64,22 @@
treeRead=Promise.all([read('/api/chat/projects'),read('/api/chat/threads')]);
}
const [projects,threads]=await treeRead;
if(generation!==treeGeneration || !container.isConnected || !Array.isArray(projects.rows) || !Array.isArray(threads.rows)) return;
if(generation!==treeGeneration || !container.isConnected) return;
if(!Array.isArray(projects.rows) || !Array.isArray(threads.rows)) throw Error('invalid tree');
treeRows=projects.rows; treeThreads=threads.rows; drawTree(container,route);
const saved=treeFocus.get(container);
if(saved && (document.activeElement===document.body || container.contains(document.activeElement))) {
const target=[...container.querySelectorAll('a,button[data-project-disclosure]')].find(el=>saved.href ? el.getAttribute('href')===saved.href : el.dataset.projectDisclosure===saved.disclosure);
target?.focus({preventScroll:true});
}
treeFocus.delete(container);
container.onclick=e=>{const button=e.target.closest('[data-project-disclosure]');if(!button)return;const id=button.dataset.projectDisclosure;treeState[id]=button.getAttribute('aria-expanded')!=='true';try{localStorage.setItem(storageKey,JSON.stringify(treeState));}catch{}drawTree(container,route);container.querySelector(`[data-project-disclosure="${id}"]`)?.focus();};
} catch { treeRead=null; if(generation===treeGeneration) container.replaceChildren(); }
} catch { if(generation===treeGeneration && container.isConnected && !document.body.classList.contains('signed-out')) {
treeRead=null;
const active=document.activeElement;
if(container.contains(active)) treeFocus.set(container, {href:active.getAttribute('href'), disclosure:active.dataset.projectDisclosure});
container.innerHTML='<p role="status">Projects unavailable. Refresh to try again.</p>'; delete container.dataset.treePaint;
} }
}
document.addEventListener('mosaic-signed-out',()=>{treeGeneration++;treeRead=null;treeRows=[];treeThreads=[];document.querySelector('#project-tree')?.replaceChildren();});
const requestId = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(value);
@@ -158,7 +172,7 @@
const requestCode = id => `<code title="${clean(id)}">${clean(UUID.test(id) ? id.slice(0,8) : id)}</code>`;
function card(thread,request,failed) {
const file=request && delivery(request);
return `<article class="project-work"><div class="project-work-head"><div><span class="project-conversation-icon" aria-hidden="true">◌</span><a href="${conversationLink(thread)}"><strong>${clean(thread.name)}</strong></a><p>With ${clean(thread.instance)}${request?` · Request ${requestCode(request.id)}${request.state?.mode?' · '+clean(modeLabel(request.state.mode)):''} · Observed just now`:failed?' · Request status unavailable':' · No active request'}</p></div>${request?chip(request.state):''}</div>${stepper(request?.state)}${request?.state?.wait?.code?`<p>Waiting: ${clean(request.state.wait.code)}</p>`:''}${file?`<p><code>${clean(file.name)}</code> · Reviewed delivery <a download href="/api/requests/${encodeURIComponent(request.id)}/artifact/${encodeURIComponent(file.id)}">Download</a> <a href="${path(request.thread.project,'files')}">Open files</a></p>`:''}<div class="project-work-bottom">${request?.state?.tasks?.length?`<span>Tasks: ${request.state.tasks.filter(v=>/^vikunja:[0-9]+\/[0-9]+$/.test(v)).map(taskLink).join(', ')}</span>`:''}<a class="btn primary" href="${conversationLink(thread)}">Open conversation</a></div></article>`;
return `<article class="project-work"><div class="project-work-head"><div><span class="project-conversation-icon" aria-hidden="true">◌</span><a href="${conversationLink(thread)}"><strong>${clean(thread.name)}</strong></a><p>With ${clean(thread.instance)}${request?` · Request ${requestCode(request.id)}${request.state?.mode?' · '+clean(modeLabel(request.state.mode)):''} · Observed just now`:failed?' · Request status unavailable':' · No active request'}</p></div>${request?chip(request.state):''}</div>${stepper(request?.state)}<p class="project-work-wait">${request?.state?.wait?.code?`Waiting: ${clean(request.state.wait.code)}`:''}</p>${file?`<p><code>${clean(file.name)}</code> · Reviewed delivery <a download href="/api/requests/${encodeURIComponent(request.id)}/artifact/${encodeURIComponent(file.id)}">Download</a> <a href="${path(request.thread.project,'files')}">Open files</a></p>`:''}<div class="project-work-bottom">${request?.state?.tasks?.length?`<span>Tasks: ${request.state.tasks.filter(v=>/^vikunja:[0-9]+\/[0-9]+$/.test(v)).map(taskLink).join(', ')}</span>`:''}<a class="btn primary" href="${conversationLink(thread)}">Open conversation</a></div></article>`;
}
const requestOrder=(a,b)=>(Number(needs(b.state))-Number(needs(a.state))) || (Number(!!b.state && !ended(b.state))-Number(!!a.state && !ended(a.state))) || (Date.parse(b.at)||0)-(Date.parse(a.at)||0);
function docsList(data) { return Array.isArray(data.docs?.files)?data.docs.files.filter(f=>typeof f?.path==='string' && /^(spec\/north-star\.md|docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md)$/.test(f.path)).map(f=>f.path):null; }
@@ -177,7 +191,7 @@
}
function filesView(data,project) {
const f=filesData(data), docs=f.docs;
return `<h2>Reviewed deliveries <span class="project-count">${f.reviewed.length}</span></h2>${f.reviewed.length?f.reviewed.map(({r,file})=>`<div class="project-panel"><span class="project-delivery-icon" aria-hidden="true">▤</span><strong>${clean(file.name)}</strong> <span class="project-chip success">Reviewed</span><p>Request ${clean(r.id)} · from ${clean(r.thread.name)} · Reviewed delivery. Not landed or deployed.</p><div class="project-delivery-actions"><a class="btn" target="_blank" rel="noopener noreferrer" href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}/text">Open as text</a> <a class="btn" download href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}">Download</a>${deliveryPr(r)?` <a class="btn" target="_blank" rel="noopener noreferrer" href="${esc(deliveryPr(r))}">Open pull request</a>`:''}</div><p>Open shows plain text in a new tab. Downloaded HTML can be opened locally after saving.</p></div>`).join(''):`<p>${data.disabled?'Files are unavailable.':data.failed?'Reviewed files are unavailable.':data.requests.length?'No reviewed file is available.':'No request files yet. Files appear when a request in this project produces them.'}</p>`}<h2>Draft workspace <span class="project-count">${f.drafts.length}</span></h2><p>Names only. Draft files may change and are not reviewed deliveries.</p><div class="project-panel project-file-list">${f.drafts.length?f.drafts.map(({r,name})=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><span>Request ${clean(r.id)} · <span class="project-chip">Draft</span></span></div>`).join(''):data.failed || data.requests.some(r=>r.workspace?.available!==true || !Array.isArray(r.workspace.entries))?'<p>Could not read draft workspace. Try again later.</p>':'<p>No draft files found.</p>'}${data.requests.some(r=>r.workspace?.truncated)?'<p>The draft list was truncated.</p>':''}</div><h2>Project documents ${docs?`<span class="project-count">${docs.length}</span>`:''}</h2><div class="project-panel project-file-list">${!project.repo?'No linked repository.':data.recordsMissing?'This project has no records yet. Initialize its record repository to add documents.':!docs?'Project documents unavailable. Docs could not read this project’s repository.':docs.length?docs.map(name=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><a href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(name)}">Open in Docs</a></div>`).join(''):'No project documents found.'}</div>`;
return `<h2>Reviewed deliveries <span class="project-count">${f.reviewed.length}</span></h2>${f.reviewed.length?f.reviewed.map(({r,file})=>`<div class="project-panel"><span class="project-delivery-icon" aria-hidden="true">▤</span><strong>${clean(file.name)}</strong> <span class="project-chip success">Reviewed</span><p>Request ${clean(r.id)} · from ${clean(r.thread.name)} · Reviewed delivery. Not landed or deployed.</p><div class="project-delivery-actions"><a class="btn" target="_blank" rel="noopener noreferrer" href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}/text">Open as text</a> <a class="btn" download href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}">Download</a>${deliveryPr(r)?` <a class="btn" target="_blank" rel="noopener noreferrer" href="${esc(deliveryPr(r))}">Open pull request</a>`:''}</div><p>Open shows plain text in a new tab. Downloaded HTML can be opened locally after saving.</p></div>`).join(''):`<p>${data.disabled?'Files are unavailable.':data.failed?'Reviewed files are unavailable.':data.requests.length?'No reviewed file is available.':'No request files yet. Files appear when a request in this project produces them.'}</p>`}<h2>Draft workspace <span class="project-count">${f.drafts.length}</span></h2><p>Names only. Draft files may change and are not reviewed deliveries.</p><div class="project-panel project-file-list">${f.drafts.length?f.drafts.map(({r,name})=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><span>Request ${clean(r.id)} · <span class="project-chip">Draft</span></span></div>`).join(''):data.failed || data.requests.some(r=>r.workspace?.available!==true || !Array.isArray(r.workspace.entries))?'<p>Could not read draft workspace. Try again later.</p>':'<p>No draft files found.</p>'}${data.requests.some(r=>r.workspace?.truncated)?'<p>The draft list was truncated.</p>':''}</div><h2>Project documents ${docs?`<span class="project-count">${docs.length}</span>`:''}</h2><div class="project-panel project-file-list">${!project.repo?'No linked repository.':data.recordsMissing?window.MosaicRecords.markup(project.repo):!docs?'Project documents unavailable. Docs could not read this project’s repository.':docs.length?docs.map(name=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><a href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(name)}">Open in Docs</a></div>`).join(''):'No project documents found.'}</div>`;
}
const expandedPrds = new Set();
function requirementView(data,project,selected) {
@@ -187,7 +201,7 @@
const candidate=d?.candidate, expanded=r && expandedPrds.has(project.id+':'+r.id);
const docPath=candidate?.prd_path, docLink=project.repo && typeof docPath==='string' && /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(docPath) ? `#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(docPath)}` : null;
const rows=candidate ? [['PRD version',candidate.prd_version],['Approved snapshot',d.snapshot.id || 'None'],['PRD file',docPath],['PRD blob',candidate.prd_blob],['Records commit',candidate.records_commit],['Confirmed intent commit',candidate.spec_commit],['Mode revision',candidate.mode_revision]].map(([label,value])=>`<div class="project-provenance-row"><span>${label}</span>${label==='PRD file' && docLink?`<a href="${docLink}"><code>${clean(value)}</code></a>`:`<code>${clean(value)}</code>`}</div>`).join('') : '<p>Unavailable until a PRD is read.</p>';
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?'<p>This project has no records yet. Initialize its record repository to add documents.</p>':data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?`<p>${window.MosaicRecords.markup(project.repo)}</p>`:data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details data-restore-key="req:${clean(r.id)}:${clean(req.id)}"><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details data-restore-key="provenance"${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
}
function overview(data,project,canSend,receipt='') {
const active=data.requests.filter(r=>r.state && !ended(r.state)), waiting=active.filter(r=>needs(r.state)), ordered=[...data.requests].sort(requestOrder), first=ordered[0], threads=[...data.threads];
@@ -195,7 +209,7 @@
const f=filesData(data), filesKnown=!data.failed && !!f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries) && r.state), count=filesKnown?f.reviewed.length+f.drafts.length+f.docs.length:'–';
const n=typeof data.north?.content==='string'?data.north.content:null, candidate=ordered.find(r=>r.draft)?.draft;
const metrics=[['Requests in progress',data.failed?'–':active.length,data.failed?'Not available yet':active[0]?label(active[0].state):'No active requests',first?conversationLink(first.thread):path(project.id,'conversations')],['Needs your input',data.failed?'–':waiting.length,data.failed?'Not available yet':waiting.length?label(waiting[0].state):'Nothing waiting',waiting[0]?conversationLink(waiting[0].thread):path(project.id,'conversations')],['Conversations',threads.length,'Project conversations',path(project.id,'conversations')],['Files',count,filesKnown?`${f.reviewed.length} reviewed · ${f.drafts.length} draft · ${f.docs.length} docs`:data.recordsMissing?'No project records yet':'Not available yet',path(project.id,'files')]];
return `${waiting.length?`<div class="project-panel project-decision" role="status">${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. <a class="btn primary" href="${conversationLink(waiting[0].thread)}">Review decision</a></div>`:''}<div class="project-metrics">${metrics.map(([title,value,caption,href])=>`<a href="${href}"><span>${title}</span><strong>${value}</strong><small>${clean(caption)}</small></a>`).join('')}<p>Updated just now · Showing requests from recent messages.</p></div><div class="project-columns"><section><div class="project-section-title"><h2>Current work</h2>${canSend&&!project.archived?`<a class="btn" href="${path(project.id,'conversations')}?new=1">${plusIcon}New conversation</a>`:''}</div>${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`<a class="project-row" href="${conversationLink(t)}"><strong>${clean(t.name)}</strong><small>With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}</small>${workFor(data,t)?chip(workFor(data,t).state):''}</a>`).join(''):'<p>No conversations yet. Start one to begin work.</p>'}<h2>Tasks <span class="project-count">${taskRefs(data).length}</span></h2><div class="project-panel">${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`<div>${taskLink(ref,data)}</div>`).join(''):data.failed?'<p>Request tasks unavailable. Refresh to try again.</p>':'<p>No request tasks available.</p>'}<a href="${path(project.id,'tasks')}">View all project tasks</a></div></section><aside class="project-panel"><h2>Project direction</h2><h3>North star</h3><p>${n!==null?clean(n):data.northRecordsMissing?'This project has no records yet. Initialize its record repository to add documents.':data.northMissing?'No north star yet.':project.repo?'North star unavailable. Docs could not read this project’s repository.':"This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet."}</p><h3>${candidate?'PRD '+clean(candidate.candidate.prd_version):'PRD'} ${candidate?.snapshot.id?'<span class="project-chip success">Approved</span>':''}</h3><p>${candidate?(candidate.snapshot.id?'Approved snapshot '+clean(candidate.snapshot.id):'Draft'):(data.failed || data.requests.some(x=>x.materialFailed)?'PRD unavailable.':'No current PRD available.')}</p>${candidate && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}<a href="${path(project.id,'requirements')}">Open PRD and requirements</a><h3>Files ${filesKnown?`<span class="project-count">${count}</span>`:''}</h3>${[...f.reviewed.map(x=>({name:x.file.name,reviewed:true})),...f.drafts.map(x=>({name:x.name,reviewed:false}))].slice(0,3).map(file=>file.reviewed?`<p><a href="${path(project.id,'files')}"><code>${clean(file.name)}</code> · Reviewed</a></p>`:`<p><code>${clean(file.name)}</code> · Draft</p>`).join('')||(data.failed?'<p>Request files unavailable.</p>':'<p>No request files available.</p>')}<a href="${path(project.id,'files')}">All files</a><h3>Project facts</h3><div class="project-provenance-row"><span>Repository</span><span>${project.repo?clean(project.repo):'Not linked'}</span></div>${roomControls(project,data.room,canSend,receipt)}<div class="project-provenance-row"><span>Mode</span><span>${first?.state?.mode?clean(modeLabel(first.state.mode)):data.failed?'Unavailable':'No active request'}</span></div></aside></div>`;
return `${waiting.length?`<div class="project-panel project-decision" role="status">${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. <a class="btn primary" href="${conversationLink(waiting[0].thread)}">Review decision</a></div>`:''}<div class="project-metrics">${metrics.map(([title,value,caption,href])=>`<a href="${href}"><span>${title}</span><strong>${value}</strong><small>${clean(caption)}</small></a>`).join('')}<p>Updated just now · Showing requests from recent messages.</p></div><div class="project-columns"><section><div class="project-section-title"><h2>Current work</h2>${canSend&&!project.archived?`<a class="btn" href="${path(project.id,'conversations')}?new=1">${plusIcon}New conversation</a>`:''}</div>${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`<a class="project-row" href="${conversationLink(t)}"><strong>${clean(t.name)}</strong><small>With ${clean(t.instance)} · ${workFor(data,t) ? label(workFor(data,t).state) : data.failed ? 'Status unavailable' : 'No active request'}</small>${workFor(data,t)?chip(workFor(data,t).state):''}</a>`).join(''):'<p>No conversations yet. Start one to begin work.</p>'}<h2>Tasks <span class="project-count">${taskRefs(data).length}</span></h2><div class="project-panel">${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`<div>${taskLink(ref,data)}</div>`).join(''):data.failed?'<p>Request tasks unavailable. Refresh to try again.</p>':'<p>No request tasks available.</p>'}<a href="${path(project.id,'tasks')}">View all project tasks</a></div></section><aside class="project-panel"><h2>Project direction</h2><h3>North star</h3><p>${n!==null?clean(n):data.northRecordsMissing?window.MosaicRecords.markup(project.repo):data.northMissing?'No north star yet.':project.repo?'North star unavailable. Docs could not read this project’s repository.':"This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet."}</p><h3>${candidate?'PRD '+clean(candidate.candidate.prd_version):'PRD'} ${candidate?.snapshot.id?'<span class="project-chip success">Approved</span>':''}</h3><p>${candidate?(candidate.snapshot.id?'Approved snapshot '+clean(candidate.snapshot.id):'Draft'):(data.failed || data.requests.some(x=>x.materialFailed)?'PRD unavailable.':'No current PRD available.')}</p>${candidate && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}<a href="${path(project.id,'requirements')}">Open PRD and requirements</a><h3>Files ${filesKnown?`<span class="project-count">${count}</span>`:''}</h3>${[...f.reviewed.map(x=>({name:x.file.name,reviewed:true})),...f.drafts.map(x=>({name:x.name,reviewed:false}))].slice(0,3).map(file=>file.reviewed?`<p><a href="${path(project.id,'files')}"><code>${clean(file.name)}</code> · Reviewed</a></p>`:`<p><code>${clean(file.name)}</code> · Draft</p>`).join('')||(data.failed?'<p>Request files unavailable.</p>':'<p>No request files available.</p>')}<a href="${path(project.id,'files')}">All files</a><h3>Project facts</h3><div class="project-provenance-row"><span>Repository</span><span>${project.repo?clean(project.repo):'Not linked'}</span></div>${roomControls(project,data.room,canSend,receipt)}<div class="project-provenance-row"><span>Mode</span><span>${first?.state?.mode?clean(modeLabel(first.state.mode)):data.failed?'Unavailable':'No active request'}</span></div></aside></div>`;
}
function tasksView(data) {
const refs=taskRefs(data);
@@ -205,16 +219,59 @@
return `<a class="project-file-row" href="#/tasks/${encodeURIComponent(ref)}"><div><strong>${clean(fields?.title || ref)}</strong>${request?`<small>From request ${clean(request.id)} · ${clean(request.thread.name)}</small>`:''}</div><span class="project-chip ${fields?.done===true?'success':''}">${fields?.done===true?'Done':fields?.bucket!==undefined && fields?.bucket!==null?'bucket '+clean(fields.bucket):'Status unavailable'}${assignees?' · '+assignees:''}</span></a>`;
}).join('')}</div>`:`<p>${data.failed?'Request tasks unavailable. Refresh to try again.':'No request tasks available.'}</p>`}`;
}
const contentPaints = new WeakMap(), contentRuns = new WeakMap(), contentModels = new WeakMap();
// Disclosure and focus state is saved by identity (request and requirement id, provenance), so
// a row inserted or moved above it, or another request's requirement with the same local id,
// cannot inherit its state. Keyed disclosures keep a closed state too, so a default-open panel
// the user closed stays closed. Unkeyed details fall back to their position; a key that no
// longer exists is dropped.
const FOCUSABLE='a,button,select,input,textarea,summary,[tabindex]';
const restoreKey=(el,i)=>el.dataset.restoreKey || 'index:'+i;
function captureView(root) {
if(!root) return {disclosures:[],closed:[],projectFocus:null};
const active=document.activeElement, inside=root.contains(active);
const owner=inside && active.matches('summary') ? active.parentElement?.closest('details[data-restore-key]') : null;
return {
disclosures:[...root.querySelectorAll('details')].map((el,i)=>el.open?restoreKey(el,i):null).filter(Boolean),
closed:[...root.querySelectorAll('details[data-restore-key]')].filter(el=>!el.open).map(el=>el.dataset.restoreKey),
projectFocus:inside ? {id:active.id,details:owner?.dataset.restoreKey || null,index:[...root.querySelectorAll(FOCUSABLE)].indexOf(active)} : null
};
}
function restoreView(root,restore) {
const wanted=new Set((restore.disclosures || []).map(String)), closed=new Set((restore.closed || []).map(String));
[...root.querySelectorAll('details')].forEach((el,i)=>{const key=restoreKey(el,i);if(wanted.has(key)) el.open=true;else if(el.dataset.restoreKey && closed.has(key)) el.open=false;});
const f=restore.projectFocus; if(!f) return;
const focus=f.id ? document.getElementById(f.id) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index];
focus?.focus({preventScroll:true});
}
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt}) {
if(!container || !project) return;
const generation=epoch; container.textContent='Reading…';
const generation=epoch, run=(contentRuns.get(container) || 0)+1;
contentRuns.set(container,run);
if (!contentPaints.has(container)) container.textContent='Reading…';
const data=await gather(project,threads).catch(()=>null);
if(!container.isConnected || generation!==epoch || document.body.classList.contains('signed-out')) return;
if(!data){container.textContent='Project data unavailable. Refresh to try again.';return;}
if(!container.isConnected || generation!==epoch || contentRuns.get(container)!==run || document.body.classList.contains('signed-out')) return;
if(!data){contentPaints.delete(container);container.textContent='Project data unavailable. Refresh to try again.';return;}
const selected=selectedRequests.get(project.id);
if(selected && !data.requests.some(r=>r.id===selected && r.draft)) selectedRequests.delete(project.id);
const selection=selectedRequests.get(project.id);
const view = section==='requirements'?requirementView(data,project,selection):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
const selectedRequest=data.requests.find(r=>r.id===(selection||data.requests.find(x=>x.draft)?.id));
// The generated presentation and separately mounted Markdown determine visible content.
const signature=JSON.stringify([view, section==='requirements' ? selectedRequest?.draft?.prd : null, section==='requirements' ? data.north?.content : null, data.failed && ['files','tasks'].includes(section)]);
const header=container.closest('.chat-area')?.querySelector('.project-tabs');
if(header){const tasks=header.querySelector(`a[href="${path(project.id,'tasks')}"]`), files=header.querySelector(`a[href="${path(project.id,'files')}"]`);const f=filesData(data);if(tasks && !tasks.querySelector('.project-count') && !data.failed)tasks.insertAdjacentHTML('beforeend',` <span class="project-count">${taskRefs(data).length}</span>`);if(files && !files.querySelector('.project-count') && !data.failed && f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries)))files.insertAdjacentHTML('beforeend',` <span class="project-count">${f.reviewed.length+f.drafts.length+f.docs.length}</span>`);}
revealTab(header);
// Retained controls (the request selector) draw from the newest accepted read, even when
// this read changes nothing visible and the DOM is kept.
contentModels.set(container,{data,project,section,canSend,roomReceipt});
if (contentPaints.get(container) === signature) return;
contentPaints.set(container, signature);
// Capture at commit time: the user may have interacted while the read was pending.
const active=document.activeElement;
const liveRestore=contentPaints.has(container) && container.children.length ? captureView(container) : null;
const draw=selected=>{
const {data,project,section,canSend,roomReceipt}=contentModels.get(container);
container.innerHTML=section==='requirements'?requirementView(data,project,selected):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
if(data.failed && ['files','tasks'].includes(section)) container.insertAdjacentHTML('afterbegin','<p role="status">Some request data could not be read; this list may be incomplete.</p>');
const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id));
@@ -222,12 +279,10 @@
const north=container.querySelector('[data-north-star]'); if(north && data.north?.content) north.replaceChildren(window.MosaicMarkdown?.render(data.north.content) || document.createTextNode(data.north.content));
container.querySelector('#project-request-select')?.addEventListener('change',e=>{selectedRequests.set(project.id,e.target.value);draw(e.target.value);container.querySelector('#project-request-select')?.focus({preventScroll:true});});
};
const selected=selectedRequests.get(project.id);
if(selected && !data.requests.some(r=>r.id===selected && r.draft)) selectedRequests.delete(project.id);
draw(selectedRequests.get(project.id));
draw(selection);
restore=liveRestore || restore;
if (restore) {
const details=container.querySelectorAll('details'); for(const i of restore.disclosures || []) if(details[i]) details[i].open=true;
if(restore.projectFocus) { const focus=restore.projectFocus.id ? document.getElementById(restore.projectFocus.id) : container.querySelectorAll('a,button,select,input,textarea,summary,[tabindex]')[restore.projectFocus.index]; focus?.focus({preventScroll:true}); }
restoreView(container,restore);
}
const sectionTarget=new URLSearchParams(location.hash.split('?')[1] || '').get('section');
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'});
@@ -235,7 +290,7 @@
async function enhanceList(container,projects,threads) {
for(const project of projects.filter(p=>!p.archived)) {
const slot=container.querySelector(`[data-project-status="${project.id}"]`); if(!slot) continue;
void gather(project,threads).then(data=>{if(!slot.isConnected)return;const current=[...data.requests].sort(requestOrder).find(r=>r.state);slot.innerHTML=`${current?chip(current.state):''}<span class="project-card-status-line">${data.failed?'Status unavailable · ':current?'':'No active requests · '}${ownThreads(project,threads).length} ${ownThreads(project,threads).length === 1 ? 'conversation' : 'conversations'} · Updated just now</span>`;}).catch(()=>{if(slot.isConnected)slot.textContent='Status unavailable';});
void gather(project,threads).then(data=>{if(!slot.isConnected)return;const current=[...data.requests].sort(requestOrder).find(r=>r.state);const html=`${current?chip(current.state):''}<span class="project-card-status-line">${data.failed?'Status unavailable · ':current?'':'No active requests · '}${ownThreads(project,threads).length} ${ownThreads(project,threads).length === 1 ? 'conversation' : 'conversations'} · Updated just now</span>`;if(slot.innerHTML!==html)slot.innerHTML=html;}).catch(()=>{if(slot.isConnected && slot.textContent!=='Status unavailable')slot.textContent='Status unavailable';});
}
}
@@ -248,21 +303,21 @@
if(!data.failed) { setCount('tasks',taskRefs(data).length); if(f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries))) setCount('files',f.reviewed.length+f.drafts.length+f.docs.length); }
revealTab(tabs);
const requestStatus=container.querySelector('[data-conversation-request-status]');
if(requestStatus && thread) requestStatus.textContent=data.failed?'Request status unavailable.':'';
if(requestStatus && thread) { const text=data.failed?'Request status unavailable.':''; if(requestStatus.textContent!==text) requestStatus.textContent=text; }
const selected=thread?workFor(data,thread):[...data.requests].sort(requestOrder).find(r=>r.state);
const agent=container.querySelector('[data-project-agent]'); if(agent && thread) agent.textContent=thread.instance;
const context=container.querySelector('[data-project-context]'); if(context && selected?.state) context.textContent=' · '+label(selected.state)+(selected.state.mode?' · '+modeLabel(selected.state.mode):'');
for(const row of container.querySelectorAll('[data-conversation-id]')) { const t=data.threads.find(t=>t.id===row.dataset.conversationId), r=t && workFor(data,t), slot=row.querySelector('[data-conversation-status]'); if(slot) slot.innerHTML=r?chip(r.state):data.failed?'Status unavailable':'No active request'; }
const agent=container.querySelector('[data-project-agent]'); if(agent && thread && agent.textContent!==thread.instance) agent.textContent=thread.instance;
const context=container.querySelector('[data-project-context]'); if(context) { const text=selected?.state?' · '+label(selected.state)+(selected.state.mode?' · '+modeLabel(selected.state.mode):''):''; if(context.textContent!==text) context.textContent=text; }
for(const row of container.querySelectorAll('[data-conversation-id]')) { const t=data.threads.find(t=>t.id===row.dataset.conversationId), r=t && workFor(data,t), slot=row.querySelector('[data-conversation-status]'); if(slot) { const html=r?chip(r.state):data.failed?'Status unavailable':'No active request'; if(slot.innerHTML!==html) slot.innerHTML=html; } }
surfaceData.set(project.id,data);
enrichRequest(container, project.id);
}
function enrichRequest(container, projectId) {
const data=surfaceData.get(projectId); if(!data) return;
for(const intro of container.querySelectorAll('.project-request-intro')) { const id=intro.querySelector('code')?.title, r=data.requests.find(r=>r.id===id && r.state?.revision===Number(intro.dataset.requestRevision)); if(!r) continue; const files=filesData(data), prd=intro.querySelector('[data-work-prd]'), counts=intro.querySelector('[data-work-files]'); if(prd) prd.innerHTML=`PRD ${clean(r.draft?.candidate?.prd_version || '–')} ${r.draft ? `<span class="project-chip ${r.draft.snapshot?.id?'success':''}">${r.draft.snapshot?.id?'Approved':'Draft'}</span>` : ''}`; if(counts) counts.textContent=`Files · ${!data.failed ? files.reviewed.length : '–'} reviewed, ${!data.failed && data.requests.every(x=>x.workspace?.available===true && Array.isArray(x.workspace.entries) && !x.workspace.truncated) ? files.drafts.length+' draft' : '– draft (Draft files unavailable)'}`; }
for(const intro of container.querySelectorAll('.project-request-intro')) { const id=intro.querySelector('code')?.title, r=data.requests.find(r=>r.id===id && r.state?.revision===Number(intro.dataset.requestRevision)); if(!r) continue; const files=filesData(data), prd=intro.querySelector('[data-work-prd]'), counts=intro.querySelector('[data-work-files]'); if(prd) { const html=`PRD ${clean(r.draft?.candidate?.prd_version || '–')} ${r.draft ? `<span class="project-chip ${r.draft.snapshot?.id?'success':''}">${r.draft.snapshot?.id?'Approved':'Draft'}</span>` : ''}`; if(prd.innerHTML!==html) prd.innerHTML=html; } if(counts) { const text=`Files · ${!data.failed ? files.reviewed.length : '–'} reviewed, ${!data.failed && data.requests.every(x=>x.workspace?.available===true && Array.isArray(x.workspace.entries) && !x.workspace.truncated) ? files.drafts.length+' draft' : '– draft (Draft files unavailable)'}`; if(counts.textContent!==text) counts.textContent=text; } }
}
document.addEventListener('click',e=>{
const open=e.target.closest?.('[data-project-dialog]');
if(open){const dialog=document.getElementById(`project-${open.dataset.projectDialog}-dialog`);if(dialog && !dialog.open){dialog.showModal();dialog.querySelector('h2')?.focus();}return;}
if(open){const dialog=document.getElementById(`project-${open.dataset.projectDialog}-dialog`);if(dialog && !dialog.open){open.focus({preventScroll:true});dialog.showModal();dialog.querySelector('h2')?.focus();}return;}
const close=e.target.closest?.('[data-dialog-close]');if(close)close.closest('dialog')?.close();
});
window.MosaicProjectTree ??= Object.freeze({mount:mountTree});
@@ -280,5 +335,5 @@
box.innerHTML=`<a href="#/projects">Projects</a><span aria-hidden="true">/</span><a class="project-crumb-name" href="#/projects/${encodeURIComponent(id)}" title="${clean(name)}"><span>${clean(name)}</span></a>${conversation?`<span aria-hidden="true">/</span><a href="${path(id,'conversations')}">Conversations</a>`:''}<span class="project-crumb-current"><span aria-hidden="true">/</span> <span aria-current="page">${clean(leaf)}</span></span>`;
return true;
}
window.MosaicProjects = Object.freeze({ header, revealTab, path, clean, mountContent, enhanceList, enhanceSurface, enrichRequest, breadcrumb, requestIntro, palette });
window.MosaicProjects = Object.freeze({ header, revealTab, path, clean, mountContent, captureView, enhanceList, enhanceSurface, enrichRequest, breadcrumb, requestIntro, palette });
})();
+7 -5
View File
@@ -283,7 +283,7 @@
(focus && !focus.disabled ? focus : el.querySelector('.request-receipt'))?.focus({ preventScroll: true });
}
}
const signature = p => JSON.stringify([p.state, p.inbox, p.projects, p.material, p.materialError, p.failed, p.stale, p.missing, p.writes, p.archiveUnknown, p.projectArchived, Boolean(p.reading)]);
const signature = p => JSON.stringify(canonical([p.state, p.inbox, p.projects, p.material, p.materialError, p.failed, p.stale, p.missing, p.writes, p.archiveUnknown, p.projectArchived, Boolean(p.reading)]));
// The reads that follow a state: the mode decision the selection waits on and the project picker.
async function side(p) {
@@ -527,7 +527,7 @@
p.writes = cfg.writes;
p.archiveUnknown = archiveUnknown;
p.projectArchived = projectArchived;
if (p.state || p.failed) draw(p);
if ((p.state || p.failed) && (!el.firstElementChild || signature(p) !== p.sig)) draw(p);
if (!p.reading && Date.now() - p.at > STALE) void read(p);
}
pulse();
@@ -537,9 +537,10 @@
}
// The project page supplies known IDs; each is reread through the existing authorized boundary.
async function projectSummary({ container, requestIds, project } = {}) {
// A quiet reread keeps the present summary until its read settles, then replaces it only if changed.
async function projectSummary({ container, requestIds, project, quiet = false } = {}) {
if (!container || out()) return;
container.replaceChildren();
if (!quiet) container.replaceChildren();
const run = {}; summaryRuns.set(container, run); summaries.add(container);
if (!text(project) || !ID.test(project) || !Array.isArray(requestIds)) return;
const ids = [...new Set(requestIds.filter(id => text(id) && ID.test(id)))];
@@ -551,7 +552,8 @@
}));
if (out() || !container.isConnected || summaryRuns.get(container) !== run) return;
const valid = rows.filter(Boolean);
container.innerHTML = valid.length ? `<section class="request-project-summary" aria-label="Requests"><h3>Requests</h3>${valid.map(s => `<p>Request <code>${txt(s.request)}</code> · Phase: ${txt(s.phase)}${s.snapshot ? ' · PRD approved' : ''}</p>`).join('')}</section>` : '<p>Request information unavailable.</p>';
const html = valid.length ? `<section class="request-project-summary" aria-label="Requests"><h3>Requests</h3>${valid.map(s => `<p>Request <code>${txt(s.request)}</code> · Phase: ${txt(s.phase)}${s.snapshot ? ' · PRD approved' : ''}</p>`).join('')}</section>` : '<p>Request information unavailable.</p>';
if (container.innerHTML !== html) container.innerHTML = html;
}
window.MosaicRequests = Object.freeze({ mount, forget, projectSummary });
})();
+22 -5
View File
@@ -18,6 +18,7 @@ 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 { devHostName } from '../../cli/src/dev-local-owner.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) {
@@ -691,9 +692,15 @@ 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, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (sso?.devOwner && host !== '127.0.0.1') throw new Error('dev local owner must bind 127.0.0.1');
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
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 } = {}) {
// 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.
if (!Array.isArray(devAllowHosts) || !devAllowHosts.every(devHostName)) throw new Error('dev allowed hosts must be host names or IPv4 addresses without a port');
if (devAllowHosts.length && !sso?.devOwner) throw new Error('dev allowed hosts need the dev local owner');
const devLan = Boolean(sso?.devOwner) && devAllowHosts.length > 0;
if (sso?.devOwner && host !== '127.0.0.1' && !(devLan && isIP(host) === 4)) throw new Error(devLan ? 'dev local owner binds an IPv4 address' : 'dev local owner must bind 127.0.0.1');
if (!isLoopback(host) && !devLan) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
if (tester && sso !== null) throw new Error('tester listener cannot use sso');
@@ -713,7 +720,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
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\/[^/]+))?$/]) {
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\/[^/]+))?$/]) {
for (const method of ['GET', 'POST']) registered.add(method, path, adminRoute);
}
registered.add('POST', '/api/orgs', adminRoute);
@@ -751,10 +758,20 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// never legitimate, so it is refused before any check reads the Host.
if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, sso?.devOwner ? 403 : 400, { error: 'repeated Host refused' });
// Preview mode: a request for the public name, exactly, as the proxy forwards it.
const devHost = sso?.devOwner && [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`].includes(req.headers.host);
const loopHost = sso?.devOwner && [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`].includes(req.headers.host);
const lanHost = devLan && !loopHost && devAllowHosts.some(name => `${name}:${boundPort}` === req.headers.host);
const devHost = loopHost || lanHost;
if (sso?.devOwner && !devHost) return json(res, 403, { error: 'non-local Host refused' });
const localOrigin = devHost ? `http://${req.headers.host}` : null;
if (devHost && req.method !== 'GET' && req.method !== 'HEAD' && (req.headersDistinct.origin?.length !== 1 || req.headers.origin !== localOrigin)) return json(res, 403, { error: 'cross-origin request refused' });
// Chromium sends no Sec-Fetch-* to an http origin that is not loopback (not potentially
// trustworthy), so a LAN page's writes arrive with the exact Origin checked above and no Fetch
// Metadata. Only there, and only when the header is absent, the write counts as same-origin for
// the route checks below. A Sec-Fetch-Site the browser did send is left for them to judge.
if (lanHost && req.method !== 'GET' && req.method !== 'HEAD' && !req.headersDistinct['sec-fetch-site']) {
req.headers['sec-fetch-site'] = 'same-origin';
req.headersDistinct['sec-fetch-site'] = ['same-origin'];
}
const named = devHost || (preview !== null ? (req.headers.host ?? '').toLowerCase() === preview.host : sso !== null && (req.headers.host ?? '').toLowerCase() === sso.publicHost);
// The proof precedes every auth-header read, including the wrong-Host fence.
if (proof !== null) {
+3 -2
View File
@@ -18,11 +18,12 @@ process.once('exit', () => { for (const child of live) killGroup(child); });
// Row 95 (#1589): the Board's machine sessions are off by default. Most tests read the board scan,
// so by default every page this browser opens starts with Show machine sessions on, as if the
// person had turned it on before. Pass { machine: false } to see the page as a first visit does.
export async function browser({ machine = true } = {}) {
// `args` adds Chromium flags, for example --host-resolver-rules for a test host name.
export async function browser({ machine = true, args = [] } = {}) {
const profile = await mkdtemp(join(tmpdir(), 'dewey-brand-browser-'));
const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [
'--headless', '--no-first-run', '--no-default-browser-check',
'--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`,
'--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`, ...args,
], { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
live.add(child);
let id = 0, buffer = '', exited = false;
+17 -3
View File
@@ -58,9 +58,9 @@ async function setup(t, suffix = '', initial = '', startRoute = '#/chat') {
const route = async id => { await b.evaluate(`location.hash='#/chat/threads/${id}',true`); await wait(`document.querySelector('.chat-conversation')?.dataset.thread==='${id}'`); };
await wait('document.querySelector(".chat-projects")');
const refresh = async () => {
await wait('!document.querySelector("#refresh").disabled');
await b.evaluate('document.querySelector(".chat-area").dataset.beforeRefresh="yes";document.querySelector("#refresh").click();true');
await wait('!document.querySelector(".chat-area")?.dataset.beforeRefresh');
// A successful read can leave the existing DOM in place. Await the real
// renderer instead of using replacement of the old element as its signal.
await b.evaluate(`MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
};
return { b, wait, set, click, route, refresh };
}
@@ -552,6 +552,20 @@ test('264e new session keeps its operation key after an unknown response and use
assert.equal(await b.evaluate('location.hash'),`#/chat/threads/${A}/sessions/${B}`);
});
// org-lan-bind: a Console opened by LAN name is not a secure context, so crypto.randomUUID is
// absent there. A new session still gets a random v4 operation key.
test('a new session gets a v4 operation key without crypto.randomUUID', async t => {
const {b,wait,set,click,route}=await setup(t,'','delete Crypto.prototype.randomUUID');
assert.equal(await b.evaluate('typeof crypto.randomUUID'),'undefined');
await route(A); await set('#chat-session-name','new context');
await b.evaluate(`window.__answers.push({body:{id:'${B}'}});window.__sessions['${A}']=[{id:'${A}',name:'default',default:true},{id:'${B}',name:'new context'}];true`);
await click('#chat-session-form button');
await wait(`document.querySelector('.chat-conversation')?.dataset.session==='${B}'`);
const write=await b.evaluate('window.__writes[0]');
assert.equal(write.path, `/api/chat/threads/${A}/sessions`);
assert.match(write.body.op,/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});
test('264e cancel shows before/after retirement and never starts or replays work', async t => {
const {b,wait,set,click,route}=await setup(t);
await b.evaluate(`window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,state:'active',revision:2}];window.confirm=()=>true;true`);
@@ -191,12 +191,20 @@ test('chat replies: unchanged file at a different library commit and incomplete
const data=await res.json();data.rows[1].check=window.__checkOverride;
return new Response(JSON.stringify(data),{headers:{'content-type':'application/json'}});
};true`);
await wait('!document.querySelector("#refresh").disabled');
await b.evaluate('document.querySelector(".chat-area").dataset.beforeRefresh="yes";document.querySelector("#refresh").click();true');
await wait('!document.querySelector(".chat-area")?.dataset.beforeRefresh && !document.querySelector("#refresh").disabled');
await b.evaluate(`MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
assert.equal(await b.evaluate('document.querySelectorAll(".chat-changed").length'), 0);
});
test('library age changes without replacing the conversation', async t => {
const { b, wait } = await setup(t);
await b.evaluate(`window.__status.body.lastFetch=new Date(Date.now()-330000).toISOString();MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
await wait(`window.__statusReads.length>=2`);
await b.evaluate(`(()=>{const field=document.querySelector('#chat-body');field.focus();field.value='draft';field.dispatchEvent(new Event('input',{bubbles:true}));field.setSelectionRange(2,2);window.savedReplyField=field;window.savedReplyMessage=document.querySelector('[data-message]');const now=Date.now();Date.now=()=>now+120000;})()`);
await b.evaluate(`MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
await wait(`document.querySelector('.chat-library')?.textContent.includes('7 min ago')`);
assert.equal(await b.evaluate(`savedReplyMessage.isConnected && savedReplyField.isConnected && document.activeElement===savedReplyField && savedReplyField.selectionStart===2`), true);
});
// Invalid configuration is a diagnostic, unlike an ordinary project with no library mapping.
test('chat status line: an invalid library mapping remains visible for diagnosis', async t => {
const { b, wait, text } = await setup(t, { before: "window.__status={status:502,body:{error:'library-mapping-invalid'}};" });
+136
View File
@@ -0,0 +1,136 @@
// org-lan-bind: the dev owner Console on the LAN (--dev-insecure-bind ADDR --dev-allow-host NAME).
// "Remote" requests bind 0.0.0.0 on port 0 and connect to 127.0.0.1 with a LAN Host header.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { request } from 'node:http';
import { connect } from 'node:net';
import { fileURLToPath } from 'node:url';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { close } from './fixture.mjs';
process.chdir(fileURLToPath(new URL('../../..', import.meta.url)));
const LAN = '10.1.10.37';
function call(port, { host, method = 'GET', path = '/api/config', origin, site, body } = {}) {
const data = body === undefined ? undefined : JSON.stringify(body);
const headers = { host, ...(origin === undefined ? {} : { origin }), ...(site === undefined ? {} : { 'sec-fetch-site': site }),
...(data === undefined ? {} : { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) }) };
return new Promise((resolve, reject) => {
const req = request({ host: '127.0.0.1', port, path, method, headers }, res => {
let raw = ''; res.on('data', c => raw += c);
res.on('end', () => { let parsed = raw; try { parsed = JSON.parse(raw); } catch {} resolve({ status: res.statusCode, type: res.headers['content-type'] ?? '', body: parsed }); });
});
req.on('error', reject); req.end(data);
});
}
// node:http will not send two Host lines, so the repeated-Host request is written by hand.
function raw(port, text) {
return new Promise((resolve, reject) => {
const socket = connect(port, '127.0.0.1', () => socket.end(text));
let out = ''; socket.on('data', c => out += c); socket.on('end', () => resolve(out)); socket.on('error', reject);
});
}
// A refusal must come before listening; a server that starts anyway is closed and fails the test.
async function refused(options, pattern) {
let server;
try { server = await startServer({ port: 0, log: () => {}, ...options }); }
catch (err) { assert.match(err.message, pattern); return; }
await close(server);
assert.fail(`startServer listened with ${JSON.stringify(Object.keys(options))}`);
}
test('a non-loopback bind is refused without the dev owner and its allowed names', async () => {
const sso = { publicHost: 'console.test', publicOrigin: 'https://console.test', session: () => null };
const preview = { host: 'console.test', header: 'x-auth', user: 'u' };
await refused({ host: '0.0.0.0' }, /refusing to bind to non-loopback host/);
await refused({ host: '0.0.0.0', devAllowHosts: [LAN] }, /dev allowed hosts need the dev local owner/);
await refused({ host: '0.0.0.0', sso: createDevOwner() }, /dev local owner must bind 127\.0\.0\.1/);
await refused({ host: '0.0.0.0', sso: createDevOwner(), devAllowHosts: [] }, /dev local owner must bind 127\.0\.0\.1/);
await refused({ host: 'localhost', sso: createDevOwner(), devAllowHosts: [LAN] }, /binds an IPv4 address/);
for (const name of [`${LAN}:80`, 'LAN.test', 'lan_test', '', '-lan', '1.2.3', 'http://lan.test'])
await refused({ host: '0.0.0.0', sso: createDevOwner(), devAllowHosts: [name] }, /without a port/);
await refused({ host: '0.0.0.0', sso: createDevOwner(), devAllowHosts: 'lan.test' }, /without a port/);
// SSO, preview and tester listeners stay loopback-only, with or without allowed names.
for (const extra of [{ sso }, { preview }, { tester: true }]) {
await refused({ host: '0.0.0.0', ...extra }, /refusing to bind to non-loopback host/);
await refused({ host: '0.0.0.0', devAllowHosts: [LAN], ...extra }, /dev allowed hosts need the dev local owner/);
await refused({ host: LAN, devAllowHosts: [LAN], ...extra }, /dev allowed hosts need the dev local owner/);
}
});
test('an allowed LAN name reads and writes with its own Origin only', async t => {
const sent = [];
const server = await startServer({ host: '0.0.0.0', port: 0, devAllowHosts: [LAN, 'lan.test'], sso: createDevOwner(), board: 'http://127.0.0.1:9',
send: async args => { sent.push(args); return { id: `m${sent.length}` }; }, log: () => {} });
t.after(() => close(server));
const port = server.address().port, host = `${LAN}:${port}`, message = { to: 'chief', body: 'hello' };
assert.equal(server.address().address, '0.0.0.0');
const shell = await call(port, { host, path: '/' });
assert.equal(shell.status, 200); assert.match(shell.type, /text\/html/);
const config = await call(port, { host });
assert.equal(config.status, 200); assert.equal(config.body.devOwner, true); assert.equal(config.body.send, true);
// A browser on a LAN http origin sends Origin but no Sec-Fetch-Site; the exact Origin is enough.
const write = await call(port, { host, method: 'POST', path: '/api/bus/message', origin: `http://${host}`, body: message });
assert.equal(write.status, 200, JSON.stringify(write.body)); assert.equal(write.body.id, 'm1');
const named = await call(port, { host: `lan.test:${port}`, method: 'POST', path: '/api/bus/message', origin: `http://lan.test:${port}`, body: message });
assert.equal(named.status, 200, JSON.stringify(named.body));
// A Fetch Metadata header the browser did send is still judged by the route.
const crossSite = await call(port, { host, method: 'POST', path: '/api/bus/message', origin: `http://${host}`, site: 'cross-site', body: message });
assert.equal(crossSite.status, 403); assert.equal(crossSite.body.error, 'cross-site request refused');
for (const origin of [undefined, `http://127.0.0.1:${port}`, `http://localhost:${port}`, 'http://evil.example', `http://lan.test:${port}`, `https://${host}`, `http://${LAN}:${port + 1}`, 'null']) {
const r = await call(port, { host, method: 'POST', path: '/api/bus/message', origin, site: 'same-origin', body: message });
assert.equal(r.status, 403, String(origin)); assert.equal(r.body.error, 'cross-origin request refused', String(origin));
}
for (const other of ['10.1.10.38', 'evil.example', `evil.example:${port}`, `${LAN}:${port + 1}`, LAN, `LAN.TEST:${port}`, `lan.test.:${port}`, `${LAN}:${port}.`]) {
const r = await call(port, { host: other });
assert.equal(r.status, 403, other); assert.equal(r.body.error, 'non-local Host refused', other);
}
const repeated = await raw(port, `GET /api/config HTTP/1.1\r\nHost: ${host}\r\nHost: ${host}\r\nConnection: close\r\n\r\n`);
assert.match(repeated, /^HTTP\/1\.1 403 /); assert.match(repeated, /repeated Host refused/);
// Loopback on the same server is unchanged: no Fetch Metadata stand-in there.
const loop = `127.0.0.1:${port}`;
assert.equal((await call(port, { host: loop })).status, 200);
const bare = await call(port, { host: loop, method: 'POST', path: '/api/bus/message', origin: `http://${loop}`, body: message });
assert.equal(bare.status, 403); assert.equal(bare.body.error, 'cross-site request refused');
const loopWrite = await call(port, { host: loop, method: 'POST', path: '/api/bus/message', origin: `http://${loop}`, site: 'same-origin', body: message });
assert.equal(loopWrite.status, 200, JSON.stringify(loopWrite.body));
assert.equal(sent.length, 3);
});
test('a loopback dev owner without allowed names refuses a LAN Host', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), board: 'http://127.0.0.1:9', send: async () => ({ id: 'm1' }), log: () => {} });
t.after(() => close(server));
const port = server.address().port;
assert.equal(server.address().address, '127.0.0.1');
assert.equal((await call(port, { host: `${LAN}:${port}` })).status, 403);
const r = await call(port, { host: `${LAN}:${port}`, method: 'POST', path: '/api/bus/message', origin: `http://${LAN}:${port}`, body: { to: 'chief', body: 'x' } });
assert.equal(r.status, 403); assert.equal(r.body.error, 'non-local Host refused');
});
// The flags are refused before any configuration is read: an empty MOSAIC_CONFIG would otherwise
// fail with the named-instance refusal instead.
test('serve refuses the LAN flags unless they come together with --dev-local-owner', () => {
const run = args => spawnSync(process.execPath, ['packages/webui/src/cli.mjs', 'serve', '--port', '0', ...args], { encoding: 'utf8', env: { ...process.env, MOSAIC_CONFIG: '' }, timeout: 10000 });
for (const [args, reason] of [
[['--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /--dev-insecure-bind needs --dev-local-owner/],
[['--dev-insecure-bind', '0.0.0.0'], /--dev-insecure-bind needs --dev-local-owner/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0'], /needs at least one --dev-allow-host/],
[['--dev-local-owner', '--dev-allow-host', 'lan.test'], /--dev-allow-host needs --dev-insecure-bind/],
[['--dev-local-owner', '--dev-insecure-bind', 'lan.test', '--dev-allow-host', 'lan.test'], /0\.0\.0\.0 or an IPv4 address/],
[['--dev-local-owner', '--dev-insecure-bind', '::', '--dev-allow-host', 'lan.test'], /0\.0\.0\.0 or an IPv4 address/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test:7350'], /without a port/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test,'], /without a port/],
[['--dev-local-owner', '--dev-insecure-bind', LAN, '--dev-allow-host', 'lan.test'], /must also be a --dev-allow-host name/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /takes one address/],
[['--dev-local-owner', '--sso', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /cannot be combined/],
]) {
const r = run(args);
assert.equal(r.status, 2, args.join(' ')); assert.match(r.stderr, reason, args.join(' '));
assert.doesNotMatch(r.stdout + r.stderr, /Mosaic Console:|WARNING: dev owner/, args.join(' '));
}
// With valid flags the next refusal is the named-instance check, so parsing accepted them.
const valid = run(['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', `lan.test,${LAN}`, '--dev-allow-host', 'other.lan']);
assert.equal(valid.status, 2); assert.match(valid.stderr, /--dev-local-owner requires a named instance/);
});
+30 -6
View File
@@ -103,11 +103,32 @@ test('dev owner resolves a decision through the real Console wrapper and bus', {
const { Store } = await import('../../bus/src/store.mjs');
const { Broker } = await import('../../bus/src/broker.mjs');
const root=mkdtempSync(join(tmpdir(),'o-'));
t.after(()=>rmSync(root,{recursive:true,force:true}));
// P19 #875: t.after hooks run in registration order, so removing root came first, both
// `bus stop` calls then failed, and SIGTERM to the outer `bus start` reached neither the
// --internal-host process nor its broker; they outlived the file. One hook now stops the
// Console and both buses while root exists, fails if any recorded process survives, then
// removes root. Each process is recorded with its start time so a reused pid is not it.
const startOf=pid=>{ try { const raw=readFileSync(`/proc/${pid}/stat`,'utf8'), fields=raw.slice(raw.lastIndexOf(')')+2).split(' '); return fields[0]==='Z'||fields[0]==='X'?null:fields[19]; } catch { return null; } };
const tracked=new Map();
const track=pid=>{ const start=startOf(pid); if(!start||tracked.has(pid))return; tracked.set(pid,start); let kids=''; try { kids=readFileSync(`/proc/${pid}/task/${pid}/children`,'utf8'); } catch {} for(const kid of kids.split(' ').filter(Boolean))track(Number(kid)); };
const survivors=()=>[...tracked].filter(([pid,start])=>startOf(pid)===start).map(([pid])=>pid);
const hostOf=(out,org)=>JSON.parse(out.split('\n').find(line=>line.startsWith('{') && line.includes(`"org":"${org}"`))).pid;
let env, pid;
t.after(async()=>{
try {
if(pid){ try { process.kill(pid,'SIGTERM'); } catch {} }
if(env) for(const org of ['acme','beta']) spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop',org],{env,encoding:'utf8'});
for(let i=0;i<100 && survivors().length;i++) await new Promise(r=>setTimeout(r,100));
const left=survivors();
// Stop what survived so the failure is this assertion, not a leak into the next file.
for(const p of left){ try { process.kill(p,'SIGKILL'); } catch {} }
assert.deepEqual(left,[],`processes still running after teardown: ${left.join(' ')}`);
} finally { rmSync(root,{recursive:true,force:true}); }
});
const f=fixture(root); const home=join(root,'home'), configDir=join(home,'.config','mosaic-test');
mkdirSync(join(home,'.config'),{recursive:true}); renameSync(join(root,'config'),configDir); chmodSync(configDir, 0o700);
writeFileSync(join(f.dataRoot,'instance.json'), JSON.stringify({instanceVersion:1,name:'test'}));
const env={...f.env,HOME:home,MOSAIC_CONFIG:join(configDir,'config.json')};
env={...f.env,HOME:home,MOSAIC_CONFIG:join(configDir,'config.json')};
const ctx={configDir,dataRoot:f.dataRoot,instance:'test',principal:DEV_OWNER};
const doc=structuredClone(f.doc); doc.human=DEV_OWNER.sub; doc.projects.stack.root='/__mosaic_org__/acme/projects/stack';
rmSync(join(configDir,'businesses','acme.json')); chmodSync(join(configDir,'businesses'),0o700);
@@ -128,13 +149,14 @@ test('dev owner resolves a decision through the real Console wrapper and bus', {
betaStore.close();
let betaOut=''; const betaBus=spawn(process.execPath,['packages/cli/src/cli.mjs','bus','start','beta','--dev-local-owner','--no-launch'],{env,stdio:['ignore','pipe','pipe']});
betaBus.stdout.on('data',c=>betaOut+=c); betaBus.stderr.on('data',c=>betaOut+=c);
t.after(()=>{ spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop','beta'],{env,encoding:'utf8'}); if(betaBus.exitCode===null)betaBus.kill('SIGTERM'); });
track(betaBus.pid);
let bus=spawn(process.execPath,['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner','--no-launch'],{env,stdio:['ignore','pipe','pipe']});
let busOut=''; bus.stdout.on('data',c=>busOut+=c); bus.stderr.on('data',c=>busOut+=c);
t.after(()=>{ spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop','acme'],{env,encoding:'utf8'}); if(bus.exitCode===null)bus.kill('SIGTERM'); });
track(bus.pid);
const wait=async (fn) => { for(let i=0;i<300;i++){ const v=fn(); if(v)return v; await new Promise(r=>setTimeout(r,100)); } throw Error(`startup timeout: ${busOut}`); };
await wait(()=>busOut.includes('\"org\":\"acme\"'));
await wait(()=>betaOut.includes('\"org\":\"beta\"'));
track(hostOf(busOut,'acme')); track(hostOf(betaOut,'beta'));
const hostLog=()=>readFileSync(join(org.root,'bus-host','host.log'),'utf8');
assert.match(hostLog(),/DEV LOCAL OWNER MODE: console owner local-dev accepted \(named instance test\)/);
assert.match(hostLog(),/launches disabled/);
@@ -143,8 +165,8 @@ test('dev owner resolves a decision through the real Console wrapper and bus', {
const launch=spawnSync(process.execPath,['-e',`const {spawn}=require('node:child_process');const p=spawn(process.execPath,[require('node:path').resolve('packages/webui/src/cli.mjs'),'serve','--port','0','--board','http://127.0.0.1:9','--business','acme','--send','--dev-local-owner'],{detached:true,stdio:['ignore',1,2]});console.log('PID '+p.pid);p.unref();`],{env,stdio:['ignore',fd,fd]});
closeSync(fd); assert.equal(launch.status,0);
const output=()=>readFileSync(log,'utf8');
const pid=Number((await wait(()=>/PID (\d+)/.exec(output()))) [1]);
t.after(()=>{ try { process.kill(pid,'SIGTERM'); } catch {} });
pid=Number((await wait(()=>/PID (\d+)/.exec(output()))) [1]);
track(pid);
const port=Number((await wait(()=>/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//.exec(output())))[1]);
assert.match(output(),/DEV LOCAL OWNER MODE: no sign-in, every local request acts as the owner \(named instance test\)/);
const post=async (path,body,extra={})=>{
@@ -167,7 +189,9 @@ test('dev owner resolves a decision through the real Console wrapper and bus', {
const stop = spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop','acme'],{env,encoding:'utf8'}); assert.equal(stop.status,0,stop.stderr); await stopped;
busOut=''; bus=spawn(process.execPath,['packages/cli/src/cli.mjs','bus','start','acme','--no-launch'],{env,stdio:['ignore','pipe','pipe']});
bus.stdout.on('data',c=>busOut+=c); bus.stderr.on('data',c=>busOut+=c);
track(bus.pid);
await wait(()=>busOut.includes('\"org\":\"acme\"'));
track(hostOf(busOut,'acme'));
const refused=await post(`/api/orgs/acme/decisions/${decision.id}/confirmation`,{choice:'yes'});
assert.equal(refused.status,403,JSON.stringify(refused)); assert.equal(refused.body.error,'human-required');
});
@@ -0,0 +1,397 @@
{
"business": {
"id": "acme",
"human": "jason",
"arbiters": {
"delivery": "pm",
"technical": "cto"
},
"projects": [
"stack"
],
"actions": [
"task.create",
"project.bootstrap",
"task.assign",
"task.schedule",
"task.update.assigned",
"task.close",
"task.reassign",
"task.scope.change",
"task.priority.change",
"git.push.working",
"git.push.protected",
"git.merge.protected",
"git.push.remote",
"host.sudo",
"review.request",
"review.verdict",
"record.write",
"message.send",
"message.external",
"role.launch",
"role.revoke",
"role.end",
"organization.change",
"instance.name",
"role.hire",
"role.fire",
"credential.mint",
"spend",
"deploy",
"data.delete",
"network.expose",
"account.create",
"policy.change",
"prd.approve",
"decision.resolve.technical",
"library.write"
],
"vars": {
"tracker.baseUrl": "http://127.0.0.1:3456",
"tracker.pollSeconds": 60,
"gitea.baseUrl": "https://git.example"
},
"instances": [
{
"instance": "pm",
"definition": "pm",
"holder": "sage",
"vars": {
"harness": "pi",
"model": "claude-opus-5-5"
},
"authority": {
"task.create": "within",
"project.bootstrap": "gated",
"task.assign": "within",
"task.schedule": "within",
"task.update.assigned": "within",
"task.close": "within",
"task.reassign": "within",
"task.scope.change": "cross",
"task.priority.change": "cross",
"git.push.working": "gated",
"git.push.protected": "gated",
"git.merge.protected": "gated",
"git.push.remote": "gated",
"host.sudo": "gated",
"review.request": "gated",
"review.verdict": "gated",
"record.write": "within",
"message.send": "within",
"message.external": "gated",
"role.launch": "gated",
"role.revoke": "gated",
"role.end": "gated",
"organization.change": "gated",
"instance.name": "gated",
"role.hire": "gated",
"role.fire": "gated",
"credential.mint": "gated",
"spend": "gated",
"deploy": "gated",
"data.delete": "gated",
"network.expose": "gated",
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"library.write": "gated"
},
"refused": null
},
{
"instance": "cto",
"definition": "cto",
"holder": "darkwing",
"vars": {
"harness": "claude-code"
},
"authority": {
"task.create": "gated",
"project.bootstrap": "gated",
"task.assign": "gated",
"task.schedule": "gated",
"task.update.assigned": "within",
"task.close": "gated",
"task.reassign": "gated",
"task.scope.change": "cross",
"task.priority.change": "gated",
"git.push.working": "gated",
"git.push.protected": "gated",
"git.merge.protected": "gated",
"git.push.remote": "gated",
"host.sudo": "gated",
"review.request": "within",
"review.verdict": "gated",
"record.write": "within",
"message.send": "within",
"message.external": "gated",
"role.launch": "gated",
"role.revoke": "gated",
"role.end": "gated",
"organization.change": "gated",
"instance.name": "gated",
"role.hire": "gated",
"role.fire": "gated",
"credential.mint": "gated",
"spend": "gated",
"deploy": "gated",
"data.delete": "gated",
"network.expose": "gated",
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "within",
"library.write": "gated"
},
"refused": null
},
{
"instance": "coder",
"definition": "coder",
"holder": null,
"vars": {
"harness": "pi",
"limits.network": "none"
},
"authority": {
"task.create": "gated",
"project.bootstrap": "gated",
"task.assign": "gated",
"task.schedule": "gated",
"task.update.assigned": "within",
"task.close": "gated",
"task.reassign": "gated",
"task.scope.change": "cross",
"task.priority.change": "gated",
"git.push.working": "within",
"git.push.protected": "gated",
"git.merge.protected": "gated",
"git.push.remote": "gated",
"host.sudo": "gated",
"review.request": "within",
"review.verdict": "gated",
"record.write": "within",
"message.send": "within",
"message.external": "gated",
"role.launch": "gated",
"role.revoke": "gated",
"role.end": "gated",
"organization.change": "gated",
"instance.name": "gated",
"role.hire": "gated",
"role.fire": "gated",
"credential.mint": "gated",
"spend": "gated",
"deploy": "gated",
"data.delete": "gated",
"network.expose": "gated",
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"library.write": "gated"
},
"refused": null
},
{
"instance": "reviewer",
"definition": "reviewer",
"holder": null,
"vars": {},
"authority": {
"task.create": "gated",
"project.bootstrap": "gated",
"task.assign": "gated",
"task.schedule": "gated",
"task.update.assigned": "within",
"task.close": "gated",
"task.reassign": "gated",
"task.scope.change": "gated",
"task.priority.change": "gated",
"git.push.working": "gated",
"git.push.protected": "gated",
"git.merge.protected": "gated",
"git.push.remote": "gated",
"host.sudo": "gated",
"review.request": "gated",
"review.verdict": "within",
"record.write": "within",
"message.send": "within",
"message.external": "gated",
"role.launch": "gated",
"role.revoke": "gated",
"role.end": "gated",
"organization.change": "gated",
"instance.name": "gated",
"role.hire": "gated",
"role.fire": "gated",
"credential.mint": "gated",
"spend": "gated",
"deploy": "gated",
"data.delete": "gated",
"network.expose": "gated",
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"library.write": "gated"
},
"refused": null
},
{
"instance": "chief",
"definition": "chief-of-staff",
"holder": null,
"vars": {},
"authority": {
"task.create": "gated",
"project.bootstrap": "gated",
"task.assign": "gated",
"task.schedule": "gated",
"task.update.assigned": "gated",
"task.close": "gated",
"task.reassign": "gated",
"task.scope.change": "gated",
"task.priority.change": "gated",
"git.push.working": "gated",
"git.push.protected": "gated",
"git.merge.protected": "gated",
"git.push.remote": "gated",
"host.sudo": "gated",
"review.request": "gated",
"review.verdict": "gated",
"record.write": "within",
"message.send": "within",
"message.external": "gated",
"role.launch": "within",
"role.revoke": "gated",
"role.end": "within",
"organization.change": "within",
"instance.name": "within",
"role.hire": "within",
"role.fire": "within",
"credential.mint": "gated",
"spend": "gated",
"deploy": "gated",
"data.delete": "gated",
"network.expose": "gated",
"account.create": "gated",
"policy.change": "gated",
"prd.approve": "gated",
"decision.resolve.technical": "gated",
"library.write": "gated"
},
"refused": null
}
],
"launch": {
"by": "chief",
"instances": [
"coder",
"reviewer"
],
"models": {
"claude-opus-5-5": {
"provider": "zai",
"model": "claude-opus-5-5"
},
"claude-sonnet-5-5": {
"provider": "zai",
"model": "claude-sonnet-5-5"
},
"glm-5.3-flash": {
"provider": "zai",
"model": "glm-5.3-flash"
}
},
"limits": {
"models": {
"zai": {
"claude-opus-5-5": 2,
"claude-sonnet-5-5": 4
}
}
},
"threadIdleSeconds": 900
},
"credentials": [
{
"service": "gitea",
"account": null,
"role": "pm",
"dateKind": "rotateBy",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "vikunja",
"account": "bot-acme-pm",
"role": "pm",
"dateKind": "expires",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "gitea",
"account": null,
"role": "cto",
"dateKind": "rotateBy",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "vikunja",
"account": "bot-acme-cto",
"role": "cto",
"dateKind": "expires",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "gitea",
"account": null,
"role": "coder",
"dateKind": "rotateBy",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "vikunja",
"account": "bot-acme-coder",
"role": "coder",
"dateKind": "expires",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "gitea",
"account": null,
"role": "reviewer",
"dateKind": "rotateBy",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "vikunja",
"account": "bot-acme-reviewer",
"role": "reviewer",
"dateKind": "expires",
"date": "2099-01-01",
"state": "valid"
},
{
"service": "vikunja",
"account": "bot-acme-sync",
"role": "tracker sync",
"dateKind": "expires",
"date": "2099-01-01",
"state": "valid"
}
],
"appearance": null
},
"at": "2026-10-10T12:00:00.000Z"
}
+2 -1
View File
@@ -99,7 +99,8 @@ test('Messages nav count polls on Tasks and Board and clears on a Board refusal;
const before=localReads.length;
messagesFailure='human-required';
await wait(`!!document.querySelector('#bus-view .banner.ref') && !${badge}`);
assert.match(await b.evaluate('document.querySelector("#bus-view h1").textContent'),/Bus refused the read/);
// A refused Queue read keeps Queue as its heading (E2E round 4); the other pages name the refusal.
assert.match(await b.evaluate('document.querySelector("#bus-view h1").textContent'),path==='queue'?/^Queue$/:/Bus refused the read/);
assert.equal(localReads.length,before,'a refused messages pre-read prevents the local read');
messagesFailure=null;
await wait(`document.querySelector('#bus-view h1')?.textContent===${JSON.stringify(title)} && ${section ? `document.querySelector('#settings-page-title')?.textContent===${JSON.stringify(section)} && ` : ''}${badge}?.textContent==='4'`);
+1 -1
View File
@@ -150,7 +150,7 @@ test('writes keep the origin and CSRF fencing', async t => {
const r = await c.call('POST', '/api/orgs', body, headers);
assert.equal(r.status, status, JSON.stringify(headers));
}
for (const path of ['/api/orgs/acme/setup/queue', '/api/orgs/acme/setup/bus', '/api/orgs/acme/admin']) {
for (const path of ['/api/orgs/acme/setup/queue', '/api/orgs/acme/setup/bus', '/api/orgs/acme/setup/records/work', '/api/orgs/acme/admin']) {
const r = await c.call('POST', path, {}, { 'sec-fetch-site': 'cross-site' });
assert.equal(r.status, 403); assert.equal(r.body.error, 'cross-site request refused');
}
@@ -0,0 +1,44 @@
// org-lan-bind: a browser on another machine opens the dev owner Console by an allowed LAN name
// (http://lan.test:PORT/, mapped to 127.0.0.1 for the test) and creates a project. That origin
// is not a secure context, so Chromium sends no Fetch Metadata and has no crypto.randomUUID.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const ID = '20000000-0000-4000-8000-000000000002';
test('an allowed LAN name loads the org page and creates a project', { timeout: 45000 }, async t => {
const projects = [], created = [];
const organizations = {
list: async () => [{ id: 'alpha', label: 'alpha', busHealthy: true }],
resolve: async id => id === 'alpha' ? {
busHealthy: true, send: async () => ({}),
reads: { business: async () => ({ business: { id, projects: ['work'] } }), busStatus: async () => ({ live: true }) },
bus: { inbox: async () => [], agents: async () => [], messages: async () => ({ messages: [] }), tasks: async () => [] },
chat: {
projects: async () => projects, threads: async () => [],
createProject: async args => { created.push(args); const row = { id: ID, ...args }; projects.push(row); return row; },
},
} : null,
};
const server = await startServer({ host: '0.0.0.0', port: 0, devAllowHosts: ['lan.test'], sso: createDevOwner(), organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
t.after(() => close(server));
const port = server.address().port;
const b = await browser({ machine: false, args: ['--host-resolver-rules=MAP lan.test 127.0.0.1'] }); t.after(() => b.close());
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const input = (selector, value) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
const click = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`);
await b.navigate(`http://lan.test:${port}/#/orgs/alpha/projects`);
assert.deepEqual(await b.evaluate('({ host: location.host, secure: isSecureContext, uuid: typeof crypto.randomUUID })'), { host: `lan.test:${port}`, secure: false, uuid: 'undefined' });
await wait('window.MosaicOrg?.id === "alpha" && !!document.querySelector("#chat-repo option[value=work]")');
await click('.project-create summary');
await input('#chat-project-name', 'Work'); await input('#chat-repo', 'work');
await click('#chat-project-form button[type=submit]');
await wait('/\\/projects\\/[0-9a-f-]{36}/i.test(location.hash)');
assert.equal(await b.evaluate('location.hash'), `#/orgs/alpha/projects/${ID}`);
assert.deepEqual(created, [{ name: 'Work', repo: 'work' }]);
assert.equal(await b.evaluate('location.host'), `lan.test:${port}`);
});
@@ -0,0 +1,123 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, T, stub } from './helpers/projects-review-fixture.mjs';
async function setup(t, path='/projects/'+P, width=1440) {
const f=await fixture(), b=await browser({machine:false});
t.after(async()=>{await b.close();await f.close();});
await b.call('Fetch.enable',{patterns:[{urlPattern:'*'}]});
b.on('Fetch.requestPaused',p=>void b.call(p.request.url.startsWith(f.base+'/')?'Fetch.continueRequest':'Fetch.failRequest',p.request.url.startsWith(f.base+'/')?{requestId:p.requestId}:{requestId:p.requestId,errorReason:'BlockedByClient'}).catch(()=>{}));
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:stub});
await b.viewport(width,900);
await b.navigate(f.base+'/#'+path);
const run=fn=>b.evaluate('('+fn.toString()+')()');
const wait=expr=>b.evaluate('(async()=>{for(let i=0;i<200;i++){if('+expr+')return;await new Promise(r=>setTimeout(r,25))}throw Error('+JSON.stringify(expr)+')})()');
const render=path=>{const canonical=path==='/projects'?'/chat':/^\/projects\/(threads|rooms)\//.test(path)?'/chat'+path.slice('/projects'.length):/^\/projects\/[^/]+$/.test(path)?'/chat/projects/'+path.slice('/projects/'.length):path;return b.evaluate('MosaicChat.render({path:'+JSON.stringify(canonical)+',container:document.querySelector("#bus-view"),canSend:true,navigated:false})');};
await wait('document.querySelector(".project-header")');
await run(async()=>{await new Promise(r=>setTimeout(r,150));});
return {f,b,run,wait,render};
}
test('probe: room recipient completion still works after an unchanged refresh',async t=>{
const {run,render,wait}=await setup(t,'/projects/rooms/'+P);
await wait('document.querySelector("#chat-room-body") && !document.querySelector("#chat-room-body").disabled');
await run(()=>{
window.roomField=document.querySelector('#chat-room-body');roomField.focus();roomField.value='@a';roomField.setSelectionRange(2,2);roomField.dispatchEvent(new Event('input',{bubbles:true}));
});
await wait('!document.querySelector("#chat-room-completion").hidden && !!document.querySelector("[data-room-complete=assistant]")');
await render('/projects/rooms/'+P);
const result=await run(()=>{
const menu=document.querySelector('#chat-room-completion');
const before={menuOpen:!menu.hidden,value:document.querySelector('#chat-room-body').value,same:roomField.isConnected};
document.querySelector('[data-room-complete=assistant]').click();
return {...before,after:document.querySelector('#chat-room-body').value};
});
console.log('ROOM_COMPLETION',JSON.stringify(result));
assert.equal(result.menuOpen,true);assert.equal(result.after,'@assistant ','the preserved completion menu must accept a recipient');
});
test('probe: invisible request revision does not collapse requirement details or drop focus',async t=>{
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements');
await wait('document.querySelector("[data-project-content] .project-columns section details summary")');
await run(()=>{
window.requirement=document.querySelector('[data-project-content] .project-columns section details');
requirement.open=true;window.requirementSummary=requirement.querySelector('summary');requirementSummary.focus();
window.beforeText=requirement.textContent;
reviewStates['request-1']={...reviewStates['request-1'],revision:2};
const now=Date.now();Date.now=()=>now+11000;
});
await render('/projects/'+P+'/requirements');
await run(async()=>{await new Promise(r=>setTimeout(r,200));});
const result=await run(()=>{
const detail=document.querySelector('[data-project-content] .project-columns section details');
return {oldConnected:requirement.isConnected,open:detail.open,focused:document.activeElement===detail.querySelector('summary'),textSame:detail.textContent===beforeText};
});
console.log('REQUIREMENT_REFRESH',JSON.stringify(result));
assert.equal(result.textSame,true);
assert.equal(result.open&&result.focused,true,'an unchanged requirement must keep its disclosure and focus');
});
test('probe: focused row survives a keyed move',async t=>{
const {run,render}=await setup(t,'/projects/'+P+'/conversations');
await run(()=>{
window.movingRow=document.querySelectorAll('[data-conversation-id]')[2];movingRow.focus();
fixtureThreads.unshift(fixtureThreads.pop());
});
await render('/projects/'+P+'/conversations');
const result=await run(()=>({connected:movingRow.isConnected,focused:document.activeElement===movingRow,active:document.activeElement.tagName,first:document.querySelector('[data-conversation-id]')===movingRow}));
console.log('FOCUSED_MOVE',JSON.stringify(result));
assert.equal(result.connected&&result.first,true);
assert.equal(result.focused,true,'moving a keyed conversation must keep its keyboard focus');
});
test('probe: stale project-content read cannot roll back newer request state',async t=>{
const {run,render,wait}=await setup(t);
await wait('document.querySelector(".project-work .project-chip")?.textContent==="Building"');
await run(()=>{
const now=Date.now();window.clockOffset=11000;Date.now=()=>now+clockOffset;
const old=window.fetch;window.holdOld=true;
window.fetch=async(url,init)=>{
const res=await old(url,init);
if(String(url)==='/api/requests/request-1'&&holdOld){holdOld=false;return new Promise(resolve=>window.releaseOld=()=>resolve(res));}
return res;
};
});
await render('/projects/'+P);
await wait('!!window.releaseOld');
await run(()=>{reviewStates['request-1']={...reviewStates['request-1'],phase:'review',revision:2,mode:'supervised'};clockOffset+=11000;});
await render('/projects/'+P);
await wait('document.querySelector(".project-work .project-chip")?.textContent==="In review"');
await run(async()=>{window.newerText=document.querySelector('[data-project-content]').textContent;releaseOld();await new Promise(r=>setTimeout(r,200));});
const result=await run(()=>({newer:newerText,final:document.querySelector('.project-work .project-chip').textContent,content:document.querySelector('[data-project-content]').textContent,context:document.querySelector('[data-project-context]').textContent}));
console.log('CONTENT_RACE',JSON.stringify(result));
assert.equal(result.final,'In review','old project content must not overwrite the newer request phase');
assert.equal(result.content,result.newer,'no project content may roll back after the old read');
assert.match(result.content,/Supervised/);
});
for (const changed of [false, true]) for (const method of ['click', 'keyboard']) {
test(`room completion ${method} works after ${changed ? 'message-only' : 'unchanged'} refresh`, async t => {
const {b,run,render,wait}=await setup(t,'/projects/rooms/'+P);
await wait('document.querySelector("#chat-room-body") && !document.querySelector("#chat-room-body").disabled');
await run(()=>{const f=document.querySelector('#chat-room-body');f.focus();f.value='@a';f.setSelectionRange(2,2);f.dispatchEvent(new Event('input',{bubbles:true}));});
if(changed) await run(()=>fixtureRoom.push({id:'50000000-0000-4000-8000-000000000004',ids:['50000000-0000-4000-8000-000000000004'],from_role:'assistant',to:['human'],body:'New message',at:'2026-11-06T10:26:00Z'}));
await render('/projects/rooms/'+P);
if(changed) await wait('document.querySelectorAll("[data-message]").length===4');
if(method==='click') await run(()=>document.querySelector('[data-room-complete=assistant]').click());
else await b.key('Enter');
assert.equal(await run(()=>document.querySelector('#chat-room-body').value),'@assistant ');
});
}
test('changed requirement content retains the disclosure and focused summary',async t=>{
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements');
await wait('document.querySelector("[data-project-content] .project-columns section details summary")');
await run(()=>{const d=document.querySelector('[data-project-content] .project-columns section details');d.open=true;d.querySelector('summary').focus();reviewDrafts['request-1'].requirements[0].text='Updated requirement text';const now=Date.now();Date.now=()=>now+11000;});
await render('/projects/'+P+'/requirements');
await wait('document.querySelector("[data-project-content]")?.textContent.includes("Updated requirement text")');
const result=await run(()=>{const d=document.querySelector('[data-project-content] .project-columns section details');return {open:d.open,focused:document.activeElement===d.querySelector('summary')}});
assert.deepEqual(result,{open:true,focused:true});
});
@@ -0,0 +1,116 @@
// Re-check findings on c0b2f7d86 (Sol, org-live-updates-recheck.md): a refresh that changes only an
// unselected request must still feed the retained selector, and requirement disclosure and focus
// follow the requirement identity when a row is inserted above it.
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, T, stub } from './helpers/projects-review-fixture.mjs';
async function setup(t, path='/projects/'+P, width=1440) {
const f=await fixture(), b=await browser({machine:false});
t.after(async()=>{await b.close();await f.close();});
await b.call('Fetch.enable',{patterns:[{urlPattern:'*'}]});
b.on('Fetch.requestPaused',p=>void b.call(p.request.url.startsWith(f.base+'/')?'Fetch.continueRequest':'Fetch.failRequest',p.request.url.startsWith(f.base+'/')?{requestId:p.requestId}:{requestId:p.requestId,errorReason:'BlockedByClient'}).catch(()=>{}));
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:stub});
await b.viewport(width,900);
await b.navigate(f.base+'/#'+path);
const run=fn=>b.evaluate('('+fn.toString()+')()');
const wait=expr=>b.evaluate('(async()=>{for(let i=0;i<200;i++){if('+expr+')return;await new Promise(r=>setTimeout(r,25))}throw Error('+JSON.stringify(expr)+')})()');
const render=path=>{const canonical=path==='/projects'?'/chat':/^\/projects\/(threads|rooms)\//.test(path)?'/chat'+path.slice('/projects'.length):/^\/projects\/[^/]+$/.test(path)?'/chat/projects/'+path.slice('/projects/'.length):path;return b.evaluate('MosaicChat.render({path:'+JSON.stringify(canonical)+',container:document.querySelector("#bus-view"),canSend:true,navigated:false})');};
await wait('document.querySelector(".project-header")');
await run(async()=>{await new Promise(r=>setTimeout(r,150));});
await run(()=>{const before=MosaicProjects;window.MosaicProjects={...before,mountContent:options=>{const pending=before.mountContent(options);window.lastContentProbe=pending;return pending;}}});
return {f,b,run,wait,render};
}
test('refreshing an unselected request updates its next selection',async t=>{
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements');
await wait('document.querySelector("#project-request-select")?.value==="request-1" && document.querySelector("[data-prd]")?.textContent');
await run(()=>{
window.originalRequestSelect=document.querySelector('#project-request-select');
const beforeSecondFetch=fetch;window.fetch=async(url,init)=>{const response=await beforeSecondFetch(url,init);if(String(url)==='/api/requests/request-2/draft')window.latestSecondPrd=(await response.clone().json()).prd;return response;};
reviewDrafts['request-2'].prd='# UPDATED SECOND REQUEST PRD';
reviewDrafts['request-2'].requirements[0].text='UPDATED SECOND REQUIREMENT';
reviewStates['request-2'].revision++;
reviewStates['request-2'].candidate.prd_version='v5';
reviewStates['request-2'].snapshot='snapshot-request-2-r2';
reviewDrafts['request-2'].snapshot={id:'snapshot-request-2-r2',revision:2};
const now=Date.now();Date.now=()=>now+11000;
});
await render('/projects/'+P+'/requirements');
await run(async()=>await window.lastContentProbe);
const result=await run(()=>{
const field=document.querySelector('#project-request-select');
field.value='request-2';field.dispatchEvent(new Event('change',{bubbles:true}));
return {observedPrd:latestSecondPrd,sameSelect:originalRequestSelect===field,selected:field.value,prd:document.querySelector('[data-prd]').textContent,updatedRequirement:document.querySelector('[data-project-content]').textContent.includes('UPDATED SECOND REQUIREMENT')};
});
assert.match(result.prd,/UPDATED SECOND REQUEST PRD/);
assert.equal(result.updatedRequirement,true);
});
test('prepending a requirement restores focus and disclosure to its surviving identity',async t=>{
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements');
await wait('document.querySelectorAll("[data-project-content] section details").length===6');
await run(()=>{
const detail=document.querySelectorAll('[data-project-content] section details')[2];detail.open=true;detail.querySelector('summary').focus();
window.oldSummary=detail.querySelector('summary').textContent;
const candidate=reviewStates['request-1'].candidate;
const newRequirement={id:'R0',title:'New first requirement',path:'docs/r0-new.md',blob:'b-new'};
candidate.requirements.unshift(newRequirement);
candidate.prd_version='v4';
reviewStates['request-1'].snapshot='snapshot-request-1-r2';
reviewDrafts['request-1'].snapshot={id:'snapshot-request-1-r2',revision:2};
reviewDrafts['request-1'].requirements.unshift({...newRequirement,text:'New text'});
reviewStates['request-1'].revision++;
const now=Date.now();Date.now=()=>now+11000;
});
await render('/projects/'+P+'/requirements');
await wait('document.querySelectorAll("[data-project-content] section details").length===7');
const result=await run(()=>({wanted:oldSummary,focused:document.activeElement.textContent,open:[...document.querySelectorAll('[data-project-content] section details')].filter(e=>e.open).map(e=>e.querySelector('summary').textContent)}));
assert.equal(result.focused,result.wanted);
assert.deepEqual(result.open,[result.wanted]);
});
// R3 check (Sol, org-live-updates-r3-check.md): requirement state is scoped to its request, and
// a closed keyed disclosure stays closed through a repaint.
async function refresh(run,render) {
await run(()=>{const now=Date.now();Date.now=()=>now+11000;});
await render('/projects/'+P+'/requirements');
await run(async()=>{await window.lastContentProbe;});
}
test('removing the selected request drops its requirement state instead of transferring it to another request with the same local ids',async t=>{
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements');
await wait('document.querySelector("#project-request-select")?.value==="request-1" && document.querySelectorAll("[data-project-content] section details").length===6');
await run(()=>{
const detail=document.querySelectorAll('[data-project-content] section details')[2];detail.open=true;detail.querySelector('summary').focus();
reviewDrafts['request-2'].prd='# DISTINCT SECOND REQUEST';
reviewStates['request-2'].candidate.requirements[2].title='Second request distinct R3';
reviewDrafts['request-2'].requirements[2].title='Second request distinct R3';
reviewDrafts['request-2'].requirements[2].text='Only the second request owns this requirement';
fixtureMessages.splice(0,fixtureMessages.length,...fixtureMessages.filter(m=>m.request!=='request-1'));
});
await refresh(run,render);
const result=await run(()=>({
prd:document.querySelector('[data-prd]').textContent,
source:document.querySelector('.project-source').textContent,
open:[...document.querySelectorAll('[data-project-content] section details')].filter(el=>el.open).map(el=>el.querySelector('summary').textContent),
focused:document.activeElement.matches('[data-project-content] section details summary')?document.activeElement.textContent:null
}));
assert.match(result.prd,/DISTINCT SECOND REQUEST/);assert.match(result.source,/request-2/);
assert.deepEqual(result.open,[]);assert.equal(result.focused,null);
});
test('closed desktop provenance remains closed through a requirement content change',async t=>{
const {run,render,wait}=await setup(t,'/projects/'+P+'/requirements',1440);
await wait('document.querySelectorAll("[data-project-content] section details").length===6');
await run(()=>{const el=document.querySelector('[data-project-content] aside details');el.open=false;el.querySelector('summary').focus();reviewDrafts['request-1'].requirements[0].text='Isolated provenance repaint';});
await refresh(run,render);
const result=await run(()=>({open:document.querySelector('[data-project-content] aside details').open,focused:document.activeElement===document.querySelector('[data-project-content] aside summary'),requirementsOpen:[...document.querySelectorAll('[data-project-content] section details')].filter(el=>el.open).length}));
assert.equal(result.focused,true);assert.equal(result.requirementsOpen,0);assert.equal(result.open,false);
});
@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setup, P, T } from './helpers/w15-conversations.mjs';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
// A periodic paint must not replace controls or messages that a person is using.
test('project and conversation stay still over refreshes and retain existing messages', async t => {
const { b, wait } = await setup(t);
await b.evaluate(`(() => {
const original = window.fetch;
window.extraMessages = []; window.extraThreads = [];
window.fetch = async (url, options) => {
const response = await original(url, options);
if (String(url).startsWith('/api/chat/threads') && !String(url).includes('/messages') && !String(url).includes('/sessions') && !String(url).includes('/shares') && !String(url).includes('shared=true') && response.ok) {
const page = await response.json();
return new Response(JSON.stringify({...page, rows: [...page.rows, ...window.extraThreads]}), {headers: {'content-type':'application/json'}});
}
if (!String(url).includes('/threads/') || !String(url).includes('/messages') || !response.ok) return response;
const page = await response.json();
return new Response(JSON.stringify({...page, rows: [...page.rows, ...window.extraMessages]}), {headers: {'content-type':'application/json'}});
};
})()`);
await b.evaluate(`location.hash='#/projects/${P}/conversations'`);
await wait(`document.querySelector('[data-conversation-id]')`);
await b.evaluate(`(() => {
const row = document.querySelector('[data-conversation-id]');
row.focus(); window.savedProject = {node:row, rect:row.getBoundingClientRect().toJSON(), scroll:window.scrollY};
})()`);
await new Promise(resolve => setTimeout(resolve, 21500));
const project = await b.evaluate(`({connected:savedProject.node.isConnected, same:document.querySelector('[data-conversation-id]')===savedProject.node, rect:savedProject.node.getBoundingClientRect().toJSON(), before:savedProject.rect, focused:document.activeElement===savedProject.node, scroll:window.scrollY, beforeScroll:savedProject.scroll})`);
assert.ok(project.connected && project.same, 'project row survives unchanged refreshes');
assert.deepEqual(project.rect, project.before);
assert.equal(project.focused, true);
assert.equal(project.scroll, project.beforeScroll);
await b.evaluate(`extraThreads.push({id:'20000000-0000-4000-8000-000000000004',project:'${P}',name:'New conversation',instance:'alice'}); document.querySelector('#refresh').click()`);
await wait(`document.querySelector('[data-conversation-id="20000000-0000-4000-8000-000000000004"]')`);
assert.equal(await b.evaluate('savedProject.node.isConnected && document.activeElement===savedProject.node'), true, 'new conversation keeps the existing row and focus');
await b.evaluate(`location.hash='#/projects/threads/${T}'`);
await wait(`document.querySelector('[data-message]') && document.querySelector('#chat-body')`);
await b.evaluate(`(() => {
const message = document.querySelector('[data-message]'), field = document.querySelector('#chat-body');
field.value='draft'; field.dispatchEvent(new Event('input',{bubbles:true})); field.focus(); field.setSelectionRange(2,2);
window.savedChat = {node:message, rect:message.getBoundingClientRect().toJSON(), scroll:document.querySelector('.chat-log').scrollTop, field};
})()`);
await new Promise(resolve => setTimeout(resolve, 21500));
const chat = await b.evaluate(`({connected:savedChat.node.isConnected, same:document.querySelector('[data-message]')===savedChat.node, rect:savedChat.node.getBoundingClientRect().toJSON(), before:savedChat.rect, focused:document.activeElement===savedChat.field, caret:savedChat.field.selectionStart, scroll:document.querySelector('.chat-log').scrollTop, beforeScroll:savedChat.scroll})`);
assert.ok(chat.connected && chat.same, 'message survives unchanged refreshes');
assert.deepEqual(chat.rect, chat.before);
assert.equal(chat.focused, true);
assert.equal(chat.caret, 2);
assert.equal(chat.scroll, chat.beforeScroll);
await b.evaluate(`extraMessages.push({id:'40000000-0000-4000-8000-000000000002',from_role:'human',body:'A new message',at:'2026-11-05T12:01:00Z'}); document.querySelector('#refresh').click()`);
await wait(`document.querySelector('[data-message="40000000-0000-4000-8000-000000000002"]')`);
assert.equal(await b.evaluate('savedChat.node.isConnected'), true, 'new message does not replace the old message');
assert.equal(await b.evaluate('savedChat.field.isConnected && document.activeElement===savedChat.field && savedChat.field.selectionStart===2'), true, 'new message keeps the composer and caret');
});
test('unchanged board reads keep the session row and focused reply control', async t => {
const f = await fixture(); t.after(() => f.close());
const b = await browser(); t.after(() => b.close());
await b.navigate(f.base);
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return;await new Promise(r=>setTimeout(r,30));}throw Error('timed out');})()`);
await wait(`document.querySelector('[data-open="proj/agent1"]')`);
await b.evaluate(`document.querySelector('[data-open="proj/agent1"]').click();document.querySelector('#reply').focus();window.boardRow=document.querySelector('tr[data-row="proj/agent1"]');window.boardAgeRow=document.querySelector('tr[data-row="proj/agent2"]');window.boardReply=document.querySelector('#reply')`);
for (let i=0; i<2; i++) {
await b.evaluate(`document.querySelector('#refresh').click()`);
await wait(`!document.querySelector('#refresh').disabled`);
}
assert.equal(await b.evaluate(`boardRow.isConnected && boardReply.isConnected && document.activeElement===boardReply`), true);
await b.evaluate(`(() => { const old=window.fetch; window.boardAge=120; window.fetch=async (url,options) => {
const response=await old(url,options); if(String(url)!=='/api/board' || !response.ok) return response;
const data=await response.json(); for(const row of data.sessions) row.ageSeconds=window.boardAge;
return new Response(JSON.stringify(data),{headers:{'content-type':'application/json'}});
}; document.querySelector('#refresh').click(); })()`);
await wait(`!document.querySelector('#refresh').disabled`);
assert.match(await b.evaluate(`boardAgeRow.textContent`), /2m ago/);
assert.equal(await b.evaluate(`boardRow.isConnected && boardReply.isConnected && document.activeElement===boardReply`), true);
});
test('conversation status has room for a temporary unavailable message', async t => {
const { b, render, wait } = await setup(t);
await render(`/projects/threads/${T}`);
await wait(`document.querySelector('[data-conversation-request-status]')`);
const position = await b.evaluate(`(() => {
const log=document.querySelector('.chat-log'), line=document.querySelector('[data-conversation-request-status]');
line.textContent='';
const before=log.getBoundingClientRect().top, height=line.getBoundingClientRect().height;
line.textContent='Request status unavailable.';
return {before,after:log.getBoundingClientRect().top,height,filledHeight:line.getBoundingClientRect().height};
})()`);
assert.equal(position.after, position.before);
assert.ok(position.height >= position.filledHeight, 'the empty status line reserves its height');
});
@@ -0,0 +1,86 @@
// Walkthrough defect 3 (Jason, 7350): once organizations exist, Quick Setup was reachable only through
// Settings > Organizations > type an ID > New organization. "New organization" is now offered in the header menu,
// in the organization picker and in the no-organization empty state. Each opens that form with the ID field focused.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { createOrgAdmin } from '../src/org-admin.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
// An owner Console with organization setup, holding the given organizations (the org-switch-browser fixture).
async function consoleWith(t, ids) {
const makeDoc = id => ({ businessVersion: 1, id, human: 'local-dev-owner', arbiters: { delivery: 'chief', technical: 'chief' }, roles: { chief: { definition: 'chief-of-staff' } }, projects: { work: {} }, appearance: { theme: 'harbor', mode: 'dark' } });
const docs = new Map(ids.map(id => [id, makeDoc(id)]));
const missing = () => { throw Object.assign(Error('not set up'), { code: 'org-unavailable' }); };
const service = {
orgPresets: async () => [{ name: 'fixture', label: 'Test model', description: 'Canned replies', available: { ok: true }, thinking: { levels: ['off'], default: 'off' } }],
templateRender: async id => makeDoc(id), validateOrg: async () => ({ ok: true, errors: [] }),
showOrg: async (ctx, id) => ({ id, doc: docs.get(id), revision: 1, root: `/data/${id}`, principal: ctx.principal }),
notificationShow: missing, queueShow: missing, projectRepoShow: missing,
};
const registry = { listOrgs: async () => [...docs.keys()].map(id => ({ id, state: 'active' })) };
const organizations = {
list: async () => [...docs.keys()].map(id => ({ id, label: id, busHealthy: true })),
resolve: async id => docs.has(id) ? { busHealthy: true, send: async () => ({}), reads: { business: async () => ({ business: { id, projects: ['work'], appearance: docs.get(id).appearance } }), busStatus: async () => ({ live: true }) }, bus: {} } : null,
};
const orgAdmin = createOrgAdmin({ service, registry, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, orgAdmin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = (expression, label = expression) => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error('Timed out: '+${JSON.stringify(label)}+'\\n'+document.body.innerText.slice(0,800))})()`);
const shown = selector => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});return !!e && getComputedStyle(e).visibility !== 'hidden' && e.getClientRects().length > 0})()`);
// The form is open, untouched, with the ID field focused, and the ?new=1 flag is gone so the link works again.
const formFocused = async (prefix, where) => {
await wait(`document.activeElement?.id === 'org-new-id'`, `${where}: ID field focused`);
assert.equal(await b.evaluate('location.hash'), `${prefix}/settings/organizations`, `${where}: the flag is dropped`);
assert.equal(await b.evaluate(`document.querySelector('#org-new-id').value`), '', `${where}: the form is not prefilled`);
assert.equal(await shown('#org-new-form [data-write]'), true, `${where}: New organization is shown`);
};
return { base: `http://127.0.0.1:${server.address().port}/`, b, wait, shown, formFocused };
}
for (const [width, height] of [[1400, 900], [390, 844]]) {
test(`the header menu offers New organization once organizations exist, at ${width}px`, { timeout: 60000 }, async t => {
const { base, b, wait, shown, formFocused } = await consoleWith(t, ['alpha']);
await b.viewport(width, height);
// From the dashboard, then again from the Organizations page itself.
for (const route of ['#/orgs/alpha/', '#/orgs/alpha/settings/organizations']) {
await b.navigate(`${base}?r=${encodeURIComponent(route)}${route}`);
await wait(`document.querySelector('#avatar-menu a#org-new-link')`, `${route}: menu entry`);
await b.evaluate(`document.querySelector('#avatar-button').click()`);
assert.equal(await shown('#org-new-link'), true, `${route}: the entry is visible in the open menu`);
assert.equal(await b.evaluate(`document.querySelector('#org-new-link').textContent`), 'New organization');
assert.equal(await b.evaluate(`document.querySelector('#org-new-link').getAttribute('role')`), 'menuitem');
await b.evaluate(`document.querySelector('#org-new-link').click()`);
await formFocused('#/orgs/alpha', route);
assert.equal(await b.evaluate(`document.querySelector('#avatar-menu').hidden`), true, `${route}: the menu closes`);
}
});
}
test('the organization picker offers New organization', { timeout: 60000 }, async t => {
const { base, b, wait, formFocused } = await consoleWith(t, ['alpha', 'beta']);
await b.viewport(1400, 900);
await b.navigate(`${base}#/orgs/alpha/settings/business`);
const link = `document.querySelector('section[aria-labelledby="org-picker-title"] a[href="#/settings/organizations?new=1"]')`;
await wait(`document.querySelector('#org-picker') && ${link}`, 'picker link');
assert.equal(await b.evaluate(`${link}.textContent`), 'New organization');
await b.evaluate(`${link}.click()`);
await formFocused('#/orgs/alpha', 'picker');
});
test('the no-organization empty state offers New organization next to its Settings link', { timeout: 60000 }, async t => {
const { base, b, wait, formFocused } = await consoleWith(t, []);
await b.viewport(1400, 900);
await b.navigate(`${base}#/`);
const button = `document.querySelector('#held a.btn[href="#/settings/organizations?new=1"]')`;
await wait(button, 'empty state button');
const held = await b.evaluate(`document.querySelector('#held').textContent`);
assert.match(held, /No organizations yet/);
assert.equal(await b.evaluate(`!!document.querySelector('#held a[href="#/settings/organizations"]')`), true, 'the Settings link stays');
assert.equal(await b.evaluate(`${button}.textContent`), 'New organization');
await b.evaluate(`${button}.click()`);
await formFocused('#', 'empty state');
});
@@ -52,9 +52,9 @@ for (const scenario of ['bus-refused', 'project-refused', 'project-unknown', 'su
await wait('document.querySelector("#org-quick-open") && !document.querySelector("#org-paths").hidden');
if (scenario === 'project-refused' || scenario === 'project-unknown') await b.evaluate(`window.__projectPosts=0;const real=window.fetch;window.fetch=async(url,init={})=>{if(String(url)==='/api/orgs/alpha/chat/projects' && init.method==='POST'){window.__projectPosts++;${scenario === 'project-refused' ? "return new Response(JSON.stringify({error:'not-configured',message:'Project refused',sent:false}),{status:503,headers:{'content-type':'application/json'}});" : "await real(url,init);return new Response(JSON.stringify({error:'outcome-unknown',message:'Outcome unknown',sent:'unknown'}),{status:503,headers:{'content-type':'application/json'}});"}}return real(url,init)}`);
await b.evaluate(`document.querySelector('#org-quick-open').click();document.querySelector('#org-risk-accept').click();document.querySelector('#org-quick-run').click()`);
const finalStep = scenario === 'bus-refused' ? 3 : 4, finalState = scenario === 'success' ? 'Done' : 'Stopped';
const finalStep = scenario === 'bus-refused' ? 3 : scenario === 'success' ? 5 : 4, finalState = scenario === 'success' ? 'Done' : 'Stopped';
await wait(`document.querySelector('[data-quick-step="${finalStep}"] .org-pill')?.textContent === '${finalState}'`);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-quick-step] .org-pill")].map(e=>e.textContent)'), ['Done', 'Done', 'Done', scenario === 'bus-refused' ? 'Stopped' : 'Done', scenario === 'bus-refused' ? 'Not started' : finalState]);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-quick-step] .org-pill")].map(e=>e.textContent)'), ['Done', 'Done', 'Done', scenario === 'bus-refused' ? 'Stopped' : 'Done', scenario === 'bus-refused' ? 'Not started' : finalState, scenario === 'success' ? 'Done' : 'Not started']);
if (scenario === 'bus-refused') {
assert.match(await b.evaluate(`document.querySelector('[data-quick-step="3"]').textContent`), /bus refused/);
assert.equal(await b.evaluate('document.activeElement.dataset.quickStep'), '3');
@@ -63,6 +63,12 @@ for (const scenario of ['bus-refused', 'project-refused', 'project-unknown', 'su
assert.equal(starts, 1);
assert.ok(existsSync(join(dataRoot, 'org-runtime/alpha/checkout/.git')));
assert.ok(existsSync(join(dataRoot, 'org-runtime/alpha/projects/work/.git')));
if (scenario === 'success') {
assert.equal(await b.evaluate(`document.querySelector('[data-quick-step="5"] code').textContent`), 'mosaic project init alpha work');
assert.ok(existsSync(join(dataRoot, 'org-runtime/alpha/projects/alpha/work/spec/north-star.md')));
const readback = await b.evaluate(`fetch('/api/orgs/alpha/setup/records/work').then(r=>r.json())`);
assert.equal(readback.state, 'done'); assert.equal(readback.verb, 'mosaic project init alpha work');
}
if (scenario !== 'bus-refused') {
assert.equal(await b.evaluate(`document.querySelector('[data-quick-step="4"] code').textContent`), 'mosaic project create work --repo work --business alpha');
if (scenario !== 'success') {
+27 -4
View File
@@ -2,13 +2,15 @@
// instance whose fixture and Console are already running (see BUILD.md).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { browser } from './browser.mjs';
const log = join(homedir(), 'orgdev-qconsole.log');
test('Quick Setup starts the real fixture and a browser message gets its canned reply', { timeout: 150000, skip: !existsSync(log) && 'scratch fixture and Console are not running' }, async t => {
for (const scenario of ['fresh', 'recover', 'cli']) test(`Quick Setup real exchange and records: ${scenario}`, { timeout: 150000, skip: !existsSync(log) && 'scratch fixture and Console are not running' }, async t => {
const url = /Mosaic Console: (http:\/\/127\.0\.0\.1:(\d+)\/)/.exec(readFileSync(log, 'utf8'));
assert.ok(url, 'scratch Console advertised a URL');
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url[2])), 'Console must use an allowed port');
@@ -31,17 +33,36 @@ test('Quick Setup starts the real fixture and a browser message gets its canned
await click('#org-quick-open');
await wait('document.querySelector("#org-quick") && !document.querySelector("#org-quick").hidden');
for (const selector of ['#org-project', '#org-project-remote', '#org-risk-accept', '#org-quick-run']) assert.equal(await shown(selector), true, selector);
if (scenario !== 'fresh') await b.evaluate(`(() => { const real=window.fetch; let first=true; window.fetch=(url,opts={}) => { if(first && String(url).includes('/setup/records/') && opts.method==='POST') { first=false; return Promise.resolve(new Response(JSON.stringify({error:'test-stop',message:'Stopped for records recovery test'}),{status:409,headers:{'content-type':'application/json'}})); } return real(url,opts); }; })()`);
await click('#org-risk-accept'); await click('#org-quick-run');
await wait(`document.querySelector('[data-quick-step="4"] .org-pill')?.textContent === 'Done' || [...document.querySelectorAll('[data-quick-step] .org-pill')].some(e=>e.textContent==='Stopped')`, 90000);
await wait(`document.querySelector('[data-quick-step="5"] .org-pill')?.textContent === 'Done' || [...document.querySelectorAll('[data-quick-step] .org-pill')].some(e=>e.textContent==='Stopped')`, 90000);
console.log('setup reached a terminal step');
const states = await b.evaluate('[...document.querySelectorAll("[data-quick-step] .org-pill")].map(e=>e.textContent)');
assert.deepEqual(states, ['Done', 'Done', 'Done', 'Done', 'Done'], await b.evaluate('document.querySelector("#org-quick-steps").innerText'));
assert.deepEqual(states, ['Done', 'Done', 'Done', 'Done', 'Done', scenario === 'fresh' ? 'Done' : 'Stopped'], await b.evaluate('document.querySelector("#org-quick-steps").innerText'));
assert.equal(await shown('#org-quick-open-org'), true, 'the finished organization can be opened');
await click('#org-quick-open-org'); console.log('opening organization');
await wait(`window.MosaicOrg?.id === ${JSON.stringify(id)} && document.querySelector('.project-card')?.textContent.includes('Repository: work')`, 30000);
await click('.project-card');
await wait('location.hash.match(/\\/projects\\/[0-9a-f-]{36}/i)');
console.log('project created');
if (scenario === 'recover') {
await wait(`document.querySelector('[data-record-init]')`);
await click('[data-record-init]');
await wait(`!document.querySelector('[data-record-init]') && document.querySelector('[data-project-content]')?.textContent.includes('North star')`, 30000);
} else if (scenario === 'cli') {
const config = readdirSync(join(homedir(), '.config')).find(name => /^mosaic-[a-z][a-z0-9]{1,15}$/.test(name));
assert.ok(config);
const uid = process.getuid();
await promisify(execFile)('/mnt/storage/scratch/mosaic-lead/uidev/org-release/devstack.sh', ['exec', '--', 'scripts/mosaic', 'project', 'init', id, 'work'], { env: {
PATH: '/mnt/storage/scratch/tmp/q2KNlM/node-v26.12.1-linux-x64/bin:/opt/claude-code/bin:/usr/local/bin:/usr/bin:/bin', HOME: homedir(), TMPDIR: process.env.TMPDIR, LANG: 'C.UTF-8', MOSAIC_CONFIG: join(homedir(), '.config', config, 'config.json'), XDG_RUNTIME_DIR: `/run/user/${uid}`, DBUS_SESSION_BUS_ADDRESS: `unix:path=/run/user/${uid}/bus`,
} });
await b.evaluate(`document.querySelector('#refresh').click()`);
}
await wait(`document.querySelector('[data-project-content]') && !document.querySelector('[data-project-content]').textContent.includes('Reading')`);
assert.doesNotMatch(await b.evaluate(`document.querySelector('[data-project-content]').textContent`), /no project records|no records yet/i);
const projectFiles = await b.evaluate(`fetch('/api/orgs/${id}/project-files?project=work').then(async r => ({ status:r.status, body:await r.json() }))`);
assert.equal(projectFiles.status, 200, JSON.stringify(projectFiles));
assert.ok(projectFiles.body.files.some(f => f.path === 'spec/north-star.md'));
const projectRoute = await b.evaluate('location.hash');
await b.evaluate(`location.hash=${JSON.stringify(projectRoute + '/conversations')}`);
await wait('!!document.querySelector("#chat-thread-form")');
@@ -51,4 +72,6 @@ test('Quick Setup starts the real fixture and a browser message gets its canned
await input('#chat-body', 'Reply with fixture word'); await click('#chat-send-form button[type=submit]');
await wait('document.querySelector(".chat-log")?.textContent.includes("PINEAPPLE")', 90000);
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'), /PINEAPPLE/); console.log('fixture reply received');
await wait(`document.querySelector('[data-conversation-request-status]') && (document.querySelector('.project-tabs a[href$="/tasks"] .project-count') || document.querySelector('[data-conversation-request-status]').textContent)`, 30000);
assert.doesNotMatch(await b.evaluate(`document.querySelector('[data-conversation-request-status]').textContent`), /unavailable/i);
});
@@ -0,0 +1,141 @@
// Walkthrough defect 1 (Jason, 7350 Quick Setup): an organization conversation showed "Request status unavailable."
// although the message went through. The organization options built in cli.mjs carried no request read, so
// /api/orgs/<id>/config never offered requests and the project page counted that as a failed read.
// This runs the real cli.mjs serve --dev-local-owner against a real organization record. Only the bus
// transports cli.mjs builds are replaced (loader hook), so no bus or human proof is involved.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdirSync, readFileSync, renameSync, chmodSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { tmp, fixture } from '../../cli/tests/helpers.mjs';
import { createOrg } from '../../business/src/org-service.mjs';
import { orgPaths } from '../../cli/src/config.mjs';
import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs';
import { browser } from './browser.mjs';
const P = '10000000-0000-4000-8000-0000000000a1', T = '20000000-0000-4000-8000-0000000000a1', S = '30000000-0000-4000-8000-0000000000a1';
// The correlation id a sent message carries. The org's request intake has no request under it.
const REQUEST = 'c0ffee00-0000-4000-8000-0000000000a1';
async function consoleFor(t) {
const root = tmp(t, 'o-'), f = fixture(root), home = join(root, 'home'), configDir = join(home, '.config', 'mosaic-test');
mkdirSync(join(home, '.config'), { recursive: true }); renameSync(join(root, 'config'), configDir);
chmodSync(configDir, 0o700); chmodSync(join(configDir, 'businesses'), 0o700);
writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'test' }));
const doc = structuredClone(f.doc); doc.id = 'alpha'; doc.human = DEV_OWNER.sub; doc.projects.stack.root = '/__mosaic_org__/alpha/projects/stack';
const org = await createOrg({ configDir, dataRoot: f.dataRoot, instance: 'test', principal: DEV_OWNER }, 'alpha', doc);
const log = join(root, 'calls.jsonl'); writeFileSync(log, '');
const busURL = new URL('../src/bus.mjs', import.meta.url).href, orgBusURL = new URL('../../cli/src/org-bus.mjs', import.meta.url).href;
// Each transport answers from fixed rows and logs (kind, business, socket, verb). Everything else in bus.mjs is real.
const busShim = join(root, 'bus-shim.mjs'), orgBusShim = join(root, 'org-bus-shim.mjs'), hook = join(root, 'hook.mjs');
writeFileSync(busShim, `import { appendFileSync } from 'node:fs';
import { BusReadError } from ${JSON.stringify(busURL)};
export * from ${JSON.stringify(busURL)};
const P=${JSON.stringify(P)}, T=${JSON.stringify(T)}, S=${JSON.stringify(S)}, REQUEST=${JSON.stringify(REQUEST)};
// The bus answers list verbs with bare arrays; the Console wraps them in rows.
const human = (verb, args) => ({
plugins: { plugins: { channel: ['cli', 'webui'] } },
'project.list': [{ id: P, name: 'work', repo: null }],
'thread.list': args?.shared ? [] : [{ id: T, project: P, name: 'init', instance: 'assistant' }],
'session.list': [{ id: S, name: 'Main', default: true, state: 'active', revision: 1 }],
'thread.messages': [
{ id: '40000000-0000-4000-8000-0000000000a1', from_role: 'human', body: 'hello world!', request: REQUEST, at: '2026-11-08T10:00:00Z' },
{ id: '40000000-0000-4000-8000-0000000000a2', from_role: 'assistant', body: 'PINEAPPLE', at: '2026-11-08T10:00:05Z' },
],
}[verb] ?? []);
const record = (kind, o, verb, args) => appendFileSync(${JSON.stringify(log)}, JSON.stringify({ kind, business: o.business ?? null, socket: o.socket ?? null, verb, args }) + '\\n');
export const busCaller = o => async (verb, args) => { record('human', o, verb, args); return human(verb, args); };
export const consoleCall = o => async (verb, args) => {
record('console', o, verb, args);
if (verb === 'request.get.console') throw new BusReadError('request-not-found', 'no request under this id');
throw new BusReadError('unavailable', 'fixture: ' + verb);
};
`);
writeFileSync(orgBusShim, `export * from ${JSON.stringify(orgBusURL)};\nexport const busStatus = async () => ({ healthy: true });\n`);
writeFileSync(hook, `import { registerHooks } from 'node:module';
registerHooks({ resolve(specifier, context, next) {
if (context.parentURL?.endsWith('/webui/src/cli.mjs')) {
if (specifier === './bus.mjs') return { url: ${JSON.stringify(pathToFileURL(busShim).href)}, shortCircuit: true };
if (specifier === '../../cli/src/org-bus.mjs') return { url: ${JSON.stringify(pathToFileURL(orgBusShim).href)}, shortCircuit: true };
}
return next(specifier, context);
} });
`);
const env = { ...f.env, HOME: home, MOSAIC_CONFIG: join(configDir, 'config.json'), NODE_OPTIONS: `--import=${pathToFileURL(hook).href}` };
const child = spawn(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--dev-local-owner'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
let out = ''; child.stdout.on('data', c => out += c); child.stderr.on('data', c => out += c);
t.after(() => { if (child.exitCode === null) child.kill('SIGTERM'); });
for (let i = 0; i < 300 && !/Mosaic Console: (http:\/\/127\.0\.0\.1:\d+\/)/.test(out); i++) {
if (child.exitCode !== null) break;
await new Promise(r => setTimeout(r, 50));
}
const base = /Mosaic Console: (http:\/\/127\.0\.0\.1:\d+\/)/.exec(out)?.[1];
assert.ok(base, `the Console did not start:\n${out}`);
const calls = () => readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
// A failed wait reports the Console output and the last transport calls with the page text.
const diagnostics = () => `Console output:\n${out.slice(-1500)}\nLast calls:\n${readFileSync(log, 'utf8').trim().split('\n').slice(-12).join('\n')}`;
return { base, calls, org, diagnostics };
}
async function page(t, base, diagnostics) {
const b = await browser({ machine: false });
t.after(() => b.close());
await b.viewport(1440, 900);
const wait = async (expression, label = expression) => {
for (let i = 0; i < 400; i++) {
try { if (await b.evaluate(`Boolean(${expression})`)) return; } catch { /* navigation */ }
await new Promise(r => setTimeout(r, 50));
}
throw Error(`Timed out: ${label}\n${await b.evaluate('document.body.innerText.slice(0, 2000)').catch(() => 'page unavailable')}\n${diagnostics()}`);
};
return { b, wait };
}
test('an organization offers its request status, and a conversation whose message has no request says nothing is wrong', { timeout: 60000 }, async t => {
const { base, calls, org, diagnostics } = await consoleFor(t);
const { b, wait } = await page(t, base, diagnostics);
await b.navigate(`${base}#/orgs/alpha/projects/threads/${T}`);
await wait(`document.querySelector('.chat-log')?.textContent.includes('PINEAPPLE')`);
const config = await b.evaluate(`fetch('/api/orgs/alpha/config').then(r => r.json())`);
assert.equal(config.requests, true, `the organization config must offer request reads: ${JSON.stringify(config)}`);
// The page settles when the request read answered: the Tasks tab gets its count, or the status line says it failed.
const settled = `document.querySelector('.project-tabs a[href$="/tasks"] .project-count') || document.querySelector('[data-conversation-request-status]')?.textContent`;
await wait(settled, 'request status settled');
assert.equal(await b.evaluate(`document.querySelector('[data-conversation-request-status]').textContent`), '');
assert.doesNotMatch(await b.evaluate(`document.querySelector('main').innerText`), /Request status unavailable|Status unavailable/);
const reads = calls().filter(c => c.kind === 'console');
assert.ok(reads.length > 0, 'the request read reached the console transport');
for (const read of reads) {
assert.equal(read.verb, 'request.get.console');
assert.deepEqual(read.args, { request: REQUEST });
assert.equal(read.business, 'alpha', 'the read goes to the selected organization');
assert.equal(read.socket, orgPaths(org.root).socket, 'the read uses the organization bus socket');
}
});
test('an organization project overview reports no active request instead of unavailable', { timeout: 60000 }, async t => {
const { base, diagnostics } = await consoleFor(t);
const { b, wait } = await page(t, base, diagnostics);
await b.navigate(`${base}#/orgs/alpha/projects`);
await wait(`document.querySelector('[data-project-status="${P}"] .project-card-status-line')`);
assert.match(await b.evaluate(`document.querySelector('[data-project-status="${P}"]').textContent`), /^No active requests · 1 conversation/);
// The overview renders after its reads settle, so the metrics are final once they exist.
await b.navigate(`${base}?v=2#/orgs/alpha/projects/${P}`);
await wait(`document.querySelector('.project-metrics')`, 'project metrics');
const metrics = await b.evaluate(`[...document.querySelectorAll('.project-metrics a')].slice(0, 2).map(a => a.innerText.replace(/\\s+/g, ' ').trim())`);
assert.deepEqual(metrics, ['Requests in progress 0 No active requests', 'Needs your input 0 Nothing waiting']);
assert.doesNotMatch(await b.evaluate(`document.querySelector('main').innerText`), /Status unavailable|Request status unavailable|Request tasks unavailable/);
});
test('organization role instances reflow inside the viewport at 390 pixels', { timeout: 60000 }, async t => {
const { base, diagnostics } = await consoleFor(t);
const { b, wait } = await page(t, base, diagnostics);
await b.viewport(390, 844);
await b.navigate(`${base}#/orgs/alpha/settings/business`);
await wait(`document.querySelector('.s1-roles tbody tr')`, 'role instances');
const box = await b.evaluate(`(()=>{const table=document.querySelector('.s1-roles'),wrap=table.closest('.table-wrap'),r=wrap.getBoundingClientRect();return {right:r.right,tableRight:table.getBoundingClientRect().right,document:document.documentElement.scrollWidth,viewport:innerWidth,overflow:getComputedStyle(wrap).overflowX,client:wrap.clientWidth,scroll:wrap.scrollWidth}})()`);
assert.ok(box.right <= box.viewport && box.document <= box.viewport, JSON.stringify(box));
assert.ok(box.tableRight <= box.viewport, JSON.stringify(box)); assert.ok(box.scroll <= box.client, JSON.stringify(box));
});
@@ -0,0 +1,67 @@
// The organization selector on `cli.mjs serve` (rename design r2, "Selector scopes and parser
// matrix"): --org and --business are one option in serve's scope. serve.mjs and bus.mjs are
// stand-ins loaded through a hook, so nothing listens and no bus is called; the bus stand-in
// records the organization the Console would bind.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmp, fixture } from '../../cli/tests/helpers.mjs';
const CLI = new URL('../src/cli.mjs', import.meta.url).pathname;
function serve(t, args) {
const root = tmp(t, 'sel-'), f = fixture(root), log = join(root, 'bound.jsonl'), hook = join(root, 'hooks.mjs');
writeFileSync(log, '');
const serveSource = "export const DEFAULT_BOARD='http://127.0.0.1:9';export const previewOptions=()=>null;export const proofOptions=()=>null;export async function startServer(){return {address:()=>({port:0}),close:()=>{}};}";
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({business:o.business})+'\\n');return async()=>[];};export const organizationConsole=()=>{throw Error('organizationConsole outside dev-owner')};export const busBusiness=()=>{throw Error('busBusiness outside --sso')};export const userCaller=()=>{throw Error('userCaller outside --sso')};export const consoleCall=()=>{throw Error('consoleCall outside --sso')};`;
writeFileSync(hook, `import {registerHooks} from 'node:module';registerHooks({load(url,context,next){
if(url.endsWith('/webui/src/serve.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(serveSource)}};
if(url.endsWith('/webui/src/bus.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(busSource)}};
return next(url,context);
}});`);
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...args], { env: { ...f.env, NODE_OPTIONS: `--import=${hook}` }, encoding: 'utf8', timeout: 10000 });
return { ...r, bound: readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l).business) };
}
const accepted = (r, name) => {
assert.equal(r.status, 0, `${name}: ${r.stderr}`);
assert.deepEqual(r.bound, ['acme'], name);
assert.match(r.stdout, /^Bus: acme$/m, name);
};
const refused = (r, pattern, name) => {
assert.equal(r.status, 2, `${name}: ${r.stdout}${r.stderr}`);
assert.match(r.stderr, pattern, name);
assert.deepEqual(r.bound, [], `${name} bound nothing`);
};
test('serve takes --org or --business, alone or matching in either order', t => {
for (const args of [['--business', 'acme'], ['--org', 'acme'], ['--business', 'acme', '--org', 'acme'], ['--org', 'acme', '--business', 'acme']]) accepted(serve(t, args), args.join(' '));
});
test('serve refuses unequal, repeated, missing, flag-shaped and invalid selectors before any read', t => {
for (const [args, pattern] of [
[['--business', 'acme', '--org', 'beta'], /^refused: --business acme and --org beta conflict/],
[['--org', 'acme', '--business', 'beta'], /^refused: --org acme and --business beta conflict/],
[['--business', 'acme', '--business', 'acme'], /^refused: --business given twice/],
[['--org', 'acme', '--org', 'acme'], /^refused: --org given twice/],
[['--org'], /^refused: --org needs one value/],
[['--business'], /^refused: --business needs one value/],
[['--org', ''], /^refused: --org needs one value/],
[['--org', '--send'], /^refused: business must be a business id/],
[['--business', '-x'], /^refused: business must be a business id/],
[['--org', 'Acme'], /^refused: business must be a business id/],
[['--org', 'acme/../x'], /^refused: business must be a business id/],
]) refused(serve(t, args), pattern, args.join(' '));
});
test('a selector spelling taken as another option\'s value is that option\'s text', t => {
const r = serve(t, ['--board', '--org', '--business', 'acme']);
accepted(r, 'board');
assert.match(r.stdout, /^Board: --org$/m);
// Without a selector the Console keeps the running host's business.
const none = serve(t, []);
assert.equal(none.status, 0, none.stderr);
assert.deepEqual(none.bound, [null]);
});
@@ -252,3 +252,17 @@ test('organization creation draft survives Stay and is discarded on switch', asy
await b.evaluate(`document.querySelector('[data-discard]').click()`).catch(() => {});
await wait('window.MosaicOrg?.id === "beta"');
});
test('a stopped organization bus has truthful chat guidance', async t => {
const organizations = {
list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: false }],
resolve: async () => ({ busHealthy: false, reads: { business: async () => ({ business: { id: 'alpha', projects: [] } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } }),
};
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, send: async () => ({}), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/projects`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.chat-area')?.innerText.includes('The bus is stopped'))return;await new Promise(r=>setTimeout(r,30))}throw Error('stopped-bus guidance missing')})()`);
const text = await b.evaluate(`document.querySelector('.chat-area').innerText`);
assert.match(text, /bus is stopped.*Start the bus/i);
assert.doesNotMatch(text, /This Console only reads/);
});
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { w15Fixture } from './w15-fixture.mjs';
test('project tree returns after a failed refresh followed by the same data', async t => {
const { f, b, wait } = await w15Fixture(t);
await b.navigate(f.base + '/#/projects');
await wait(`document.querySelector('.project-tree-list a')`);
const result = await b.evaluate(`(async()=>{
const container=document.querySelector('.project-tree-list').parentElement;
const link=container.querySelector('.project-tree-list a'); link.focus(); const href=link.getAttribute('href');
const originalFetch=window.fetch, originalNow=Date.now;
Date.now=()=>originalNow()+20000;
window.fetch=(url,options)=>String(url)==='/api/chat/threads' ? Promise.reject(Error('offline')) : originalFetch(url,options);
await MosaicProjectTree.mount({container,path:'/projects'});
const unavailable=!!container.querySelector('[role=status],[role=alert]') && container.textContent.includes('Projects unavailable');
window.fetch=originalFetch;
await MosaicProjectTree.mount({container,path:'/projects'});
Date.now=originalNow;
return {unavailable,recovered:!!container.querySelector('.project-tree-list a'),focused:document.activeElement.getAttribute('href')===href};
})()`);
assert.deepEqual(result, {unavailable:true,recovered:true,focused:true});
});
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, stub } from './helpers/projects-review-fixture.mjs';
test('project work reserves a line for a wait reason', async t => {
const f = await fixture(), b = await browser({machine:false});
t.after(async () => { await b.close(); await f.close(); });
await b.call('Page.addScriptToEvaluateOnNewDocument', {source:stub});
await b.viewport(1440, 1000);
await b.navigate(f.base + '/#/projects/' + P);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.project-work'))return;await new Promise(r=>setTimeout(r,30))}throw Error('No project work')})()`);
const positions = await b.evaluate(`(()=>{
const line=document.querySelector('.project-work-wait'), bottom=document.querySelector('.project-work-bottom');
if(!line)return null;
const before=bottom.getBoundingClientRect().top, height=line.getBoundingClientRect().height;
line.textContent='Waiting: decision';
return {before,after:bottom.getBoundingClientRect().top,height,filled:line.getBoundingClientRect().height};
})()`);
assert.ok(positions, 'the wait line is present while empty');
assert.equal(positions.after, positions.before);
assert.ok(positions.height >= positions.filled);
});
+4 -1
View File
@@ -30,10 +30,13 @@ function tmp(t, prefix) {
// A canonical checkout (a .git directory) on branch refactor, with this
// tree's files, the uncommitted ones included, and a RELEASE of its own.
// A deploy tree links node_modules; .gitignore's node_modules/ matches only a
// directory, so the link is listed as an untracked file. The copy never takes
// node_modules from git (npm ci makes it), so the fixture leaves the link out.
function fixtureRepo(t) {
const dir = tmp(t, 'webui-copy-');
const repo = join(dir, 'repo');
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'tools/unslop-hook', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'tools/unslop-hook', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation', ':(exclude,glob)**/node_modules'], { encoding: 'utf8' }).split('\0').filter(Boolean);
for (const f of files) if (existsSync(join(REPO, f))) cpSync(join(REPO, f), join(repo, f));
writeFileSync(join(repo, 'RELEASE'), '0.0.0-fixture\n');
writeFileSync(join(repo,'package.json'),JSON.stringify({name:'copy-fixture',version:'1.0.0'}));
@@ -39,6 +39,13 @@ async function setup(t, readonly = false) {
await wait(`document.querySelector('#processing-state')?.textContent.includes(${JSON.stringify(readonly ? 'halted' : 'enabled')})`);
return { b, wait };
}
test('unchanged processing reads retain the status text node', async t => {
const { b, wait } = await setup(t);
await b.evaluate(`window.processingText=document.querySelector('#processing-state').firstChild;window.readsBefore=window.processingRequests.length;window.dispatchEvent(new Event('focus'))`);
await wait('window.processingRequests.length >= window.readsBefore + 2');
await b.evaluate(`new Promise(resolve=>setTimeout(resolve,50))`);
assert.equal(await b.evaluate(`processingText.isConnected && document.querySelector('#processing-state').firstChild===processingText`), true);
});
test('processing browser: keyboard Stop, confirmed Resume, unknown outcome and persistent banner', async t => {
const { b, wait } = await setup(t);
await b.evaluate(`document.querySelector('#processing-halt').focus(),true`); await b.key('Enter');
@@ -0,0 +1,46 @@
// Walkthrough defect 2 (Jason, 7350): the conversation breadcrumb read "Projects / work / / Conversations / init".
// No label was missing. Every project crumb link had min-width:44px, so a short project name such as "work" was
// padded with blank space after its text and the run of separators looked like an empty crumb.
// The crumbs must be evenly spaced whatever the name length, and each link keeps its 44px target.
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, T, stub } from './helpers/projects-review-fixture.mjs';
const ROUTES = [[`/projects/${P}`, 'Overview'], [`/projects/${P}/conversations`, 'Conversations'], [`/projects/threads/${T}`, 'Build offline timer']];
// For each crumb link: the space from its text to the next visible separator's text (null when the phone layout
// hides what follows), and how many pixels wide its hit area is along the text's centre line.
const MEASURE = `(() => {
const box = document.querySelector('#breadcrumbs');
const text = e => { const n = document.createTreeWalker(e, NodeFilter.SHOW_TEXT).nextNode(), r = document.createRange(); r.selectNodeContents(n); return r.getBoundingClientRect(); };
return [...box.querySelectorAll(':scope > a')].map(a => {
let sep = a.nextElementSibling; if (sep?.classList.contains('project-crumb-current')) sep = sep.firstElementChild;
const t = text(a), next = sep && text(sep), y = t.top + t.height / 2;
let target = 0; for (let x = Math.floor(t.left) - 30; x <= t.right + 30; x++) if (document.elementFromPoint(x, y)?.closest('a') === a) target++;
return { label: a.textContent.trim(), gap: next?.width ? Math.round(next.left - t.right) : null, target };
});
})()`;
for (const [width, height] of [[1440, 900], [390, 844]]) {
test(`project crumbs are evenly spaced for a short project name at ${width}px`, { timeout: 60000 }, async t => {
const f = await fixture(), b = await browser({ machine: false });
t.after(async () => { await b.close(); await f.close(); });
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub + `;fixtureProjects[0].name='work';` });
await b.viewport(width, height);
let n = 0;
for (const [route, leaf] of ROUTES) {
await b.navigate(`${f.base}/?r=${n++}#${route}`);
const ready = `document.querySelector('#breadcrumbs .project-crumb-current [aria-current="page"]')?.textContent === ${JSON.stringify(leaf)}`;
await b.evaluate(`(async () => { for (let i = 0; i < 300; i++) { if (${ready}) return document.fonts.ready; await new Promise(r => setTimeout(r, 30)); } throw Error('Timed out: crumbs for ' + ${JSON.stringify(route)} + ': ' + document.querySelector('#breadcrumbs')?.innerText); })()`);
const crumbs = await b.evaluate(MEASURE);
assert.equal(crumbs[1]?.label, 'work', `${route}: the project crumb names the project`);
const reference = crumbs[0].gap;
for (const crumb of crumbs) {
if (crumb.gap !== null) assert.ok(Math.abs(crumb.gap - reference) <= 1, `${route}: "${crumb.label}" is followed by ${crumb.gap}px, "Projects" by ${reference}px`);
assert.ok(crumb.target >= 44, `${route}: "${crumb.label}" has a ${crumb.target}px target, not 44px`);
}
}
});
}
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:net';
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { businessDoc, REPO, REPO_ROLES, writeJson } from '../../business/tests/helpers.mjs';
import { consoleReads } from '../src/reads.mjs';
import { startServer } from '../src/serve.mjs';
import { humanCall, consoleCall } from '../src/bus.mjs';
const fixed = Date.parse('2026-10-10T12:00:00.000Z');
const golden = new URL('./fixtures/rename-a/business-response.json', import.meta.url);
test('v1 /api/business response preserves its complete JSON shape and redaction', async t => {
const root = mkdtempSync(join(tmpdir(), 'wire-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
const doc = businessDoc(root);
writeJson(join(configDir, 'businesses/acme.json'), doc);
const system = { configVersion: 1, environment: 'development', dataRoot, execution: { backend: 'docker', provider: 'zai', model: 'glm-5.3-flash', adapter: 'mock' } };
const reads = consoleReads({ root: REPO, configDir, rolesDir: REPO_ROLES, system, business: 'acme', now: () => fixed });
const server = await startServer({ port: 0, reads });
t.after(() => server.close());
const response = await fetch(`http://127.0.0.1:${server.address().port}/api/business`);
assert.equal(response.status, 200);
const body = await response.text();
assert.doesNotMatch(body, /placeholder-not-a-token|secret|"file"|"botId"/);
assert.deepEqual(JSON.parse(body), JSON.parse(readFileSync(golden, 'utf8')));
});
test('v1 Console human and owner reads send their exact business-bound IPC envelopes', async t => {
const root = mkdtempSync(join(tmpdir(), 'ipc-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const socket = join(root, 's');
const received = [];
const server = createServer(conn => {
let input = '';
conn.on('data', chunk => {
input += chunk;
if (!input.includes('\n')) return;
received.push(JSON.parse(input));
conn.end('{"ok":true,"result":{"answer":"fixture"}}\n');
});
});
await new Promise((resolve, reject) => server.listen(socket, resolve).once('error', reject));
t.after(() => server.close());
const env = { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR };
const human = humanCall({ socket, business: 'acme', env });
assert.deepEqual(await human('instances', {}), { answer: 'fixture' });
assert.deepEqual(await human('limits', {}), { answer: 'fixture' });
const owner = consoleCall({ socket, dataRoot: root, business: 'acme', env });
assert.deepEqual(await owner('request.draft.console', { request: 'r1' }, { subject: { provider: 'local', iss: 'fixture', sub: 'owner' } }), { answer: 'fixture' });
for (const [i, verb] of ['instances', 'limits'].entries()) {
const { human: proof, ...rest } = received[i];
assert.deepEqual(rest, { verb, args: {} });
assert.deepEqual(Object.keys(proof), ['business', 'pid', 'startTime', 'nonce']);
assert.equal(proof.business, 'acme');
assert.ok(Number.isInteger(proof.pid));
assert.match(String(proof.startTime), /^\d+$/);
assert.match(proof.nonce, /^[a-f0-9]{64}$/);
}
const { console: proof, ...rest } = received[2];
assert.deepEqual(rest, { verb: 'request.draft.console', args: { request: 'r1' } });
assert.deepEqual(Object.keys(proof), ['business', 'provider', 'iss', 'sub', 'pid', 'startTime', 'nonce']);
assert.deepEqual([proof.business, proof.provider, proof.iss, proof.sub], ['acme', 'local', 'fixture', 'owner']);
assert.ok(Number.isInteger(proof.pid));
assert.match(String(proof.startTime), /^\d+$/);
assert.match(proof.nonce, /^[a-f0-9]{64}$/);
});
@@ -13,7 +13,8 @@ test('independent overview read error matrix',async t=>{
async function check(name,s,expected,route=`/projects/${P}`){await t.test(name,async()=>{
await b.evaluate(`window.__reviewScenario=${JSON.stringify(s)};document.dispatchEvent(new CustomEvent('mosaic-project-room-changed'));`);
await render(route);
await wait(`document.querySelector('[data-project-content]') && !document.querySelector('[data-project-content]').textContent.startsWith('Reading')`);
// A reread keeps the earlier paint until its result arrives (live updates), so wait for the expected text, not for 'Reading' to go.
await wait(`document.querySelector('[data-project-content]') && ${expected}.test(document.querySelector('[data-project-content]').textContent)`).catch(()=>{});
const text=await b.evaluate(`document.querySelector('[data-project-content]').textContent`);
assert.match(text,expected);
});}
@@ -72,7 +72,7 @@ test('sidebar child labels truncate and mobile business role names remain whole'
await b.viewport(390,900); await b.evaluate(`window.fetch=((old)=>(url,init)=>String(url)==='/api/business'?Promise.resolve(new Response(JSON.stringify({business:{id:'demo',instances:[{instance:'assistant',definition:'assistant'}]}}),{headers:{'content-type':'application/json'}})):old(url,init))(window.fetch);location.hash='#/settings/business'`);
await wait(`document.querySelector('.s1-roles tbody th')`);
const role=await b.evaluate(`(()=>{const e=document.querySelector('.s1-roles tbody th'),s=getComputedStyle(e),r=e.getBoundingClientRect();return {wrap:s.overflowWrap,word:s.wordBreak,width:r.width,scroll:e.scrollWidth}})()`);
assert.equal(role.wrap,'normal'); assert.equal(role.word,'normal'); assert.ok(role.width>=role.scroll,JSON.stringify(role));
assert.equal(role.wrap,'anywhere'); assert.equal(role.word,'normal'); assert.ok(role.width>=role.scroll,JSON.stringify(role));
});
const staleFixture = `;{const old=window.fetch;window.instancesFail=false;window.fetch=(url,init)=>{const p=String(url),json=(v,status=200)=>Promise.resolve(new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}}));if(p==='/api/bus/agents')return json({rows:[{role:'chief',holder_run:'r-old-chief-123456',harness:'pi',at:'2026-01-01T00:00:00Z',state:'idle'}]});if(p==='/api/bus/thread.claims')return json({rows:[{thread:'${T}',holder_run:'r-old-chief-123456',harness:'pi',at:'2026-01-01T00:00:00Z'}]});if(p==='/api/bus/instances')return window.instancesFail?json({error:'unavailable'},503):json({rows:[{instances:{chief:{running:['r-new-chief-654321']}}}]});return old(url,init)}}`;
@@ -105,3 +105,61 @@ test('desktop agent message matches avatar and card geometry',async t=>{
const x=await b.evaluate(`(()=>{const m=document.querySelector('.chat-message-agent'),a=m.querySelector('.chat-avatar'),name=m.querySelector('strong'),bubble=m.querySelector('.chat-body').getBoundingClientRect(),card=document.querySelector('.project-request-item .request-mount').getBoundingClientRect(),s=getComputedStyle(a);return {font:s.fontSize,weight:s.fontWeight,name:name.getBoundingClientRect().left,left:bubble.left,right:bubble.right,width:bubble.width,cardRight:card.right}})()`);
assert.equal(x.font,'14px');assert.equal(x.weight,'700');assert.ok(Math.abs(x.name-296)<1,JSON.stringify(x));assert.ok(Math.abs(x.left-296)<1&&Math.abs(x.width-744)<1&&Math.abs(x.right-x.cardRight)<1,JSON.stringify(x));
});
test('role instances reflow within the mobile panel', async t => {
const {b,wait}=await setup(t,'/settings/business');
await b.viewport(390,844);
await b.evaluate(`window.fetch=((old)=>(url,init)=>String(url)==='/api/business'?Promise.resolve(new Response(JSON.stringify({business:{id:'demo',instances:[{instance:'assistant',definition:'assistant'}]}}),{headers:{'content-type':'application/json'}})):old(url,init))(window.fetch);document.querySelector('#refresh').click()`);
await wait(`document.querySelector('.s1-roles tbody th')`);
const x=await b.evaluate(`(()=>{const t=document.querySelector('.s1-roles'),w=t.closest('.table-wrap'),r=w.getBoundingClientRect();return {right:r.right,tableRight:t.getBoundingClientRect().right,viewport:innerWidth,overflow:getComputedStyle(w).overflowX,scroll:w.scrollWidth,client:w.clientWidth,document:document.documentElement.scrollWidth}})()`);
assert.ok(x.right<=x.viewport,JSON.stringify(x));
assert.match(x.overflow,/auto|scroll/); assert.ok(x.tableRight<=x.viewport && x.scroll<=x.client,JSON.stringify(x)); assert.ok(x.document<=x.viewport,JSON.stringify(x));
});
test('an archived organization read disables a retained conversation draft', async t => {
const {b,wait}=await setup(t,'/projects/threads/'+T);
await wait(`document.querySelector('#chat-body') && !document.querySelector('#chat-body').disabled`);
await b.evaluate(`document.querySelector('#chat-body').value='Keep this draft';document.querySelector('#chat-body').dispatchEvent(new Event('input',{bubbles:true}));window.fetch=((old)=>(url,init)=>String(url)==='/api/chat/projects'?Promise.resolve(new Response(JSON.stringify({error:'org-archived'}),{status:409,headers:{'content-type':'application/json'}})):old(url,init))(window.fetch);document.querySelector('#refresh').click()`);
await wait(`document.querySelector('.chat-error')`);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').disabled`),true);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`),'Keep this draft');
assert.match(await b.evaluate(`document.querySelector('.chat-area').innerText`),/organization.*archived/i);
});
test('a room-not-open read disables a retained draft with the room reason', async t => {
const {b,wait}=await setup(t,'/projects/threads/'+T);
await wait(`document.querySelector('#chat-body') && !document.querySelector('#chat-body').disabled`);
await b.evaluate(`document.querySelector('#chat-body').value='Keep this draft';document.querySelector('#chat-body').dispatchEvent(new Event('input',{bubbles:true}));window.fetch=((old)=>(url,init)=>String(url)==='/api/chat/projects'?Promise.resolve(new Response(JSON.stringify({error:'room-not-open'}),{status:409,headers:{'content-type':'application/json'}})):old(url,init))(window.fetch);document.querySelector('#refresh').click()`);
await wait(`document.querySelector('.chat-error')`);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').disabled`),true);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`),'Keep this draft');
const text=await b.evaluate(`document.querySelector('.chat-area').innerText`);
assert.match(text,/room.*not open/i); assert.doesNotMatch(text,/organization is archived/i);
});
test('project read error banner does not overlap New project at desktop width', async t => {
const {b,wait}=await setup(t,'/projects',`;reviewFail['/api/chat/projects']=true;`);
await wait(`document.querySelector('.chat-error') && document.querySelector('.project-create summary')`);
const x=await b.evaluate(`(()=>{const a=document.querySelector('.chat-error').getBoundingClientRect(),b=document.querySelector('.project-create summary').getBoundingClientRect();return {overlap:a.left<b.right&&a.right>b.left&&a.top<b.bottom&&a.bottom>b.top}})()`);
assert.equal(x.overlap,false);
});
test('a refused queue read keeps Queue as the page heading', async t => {
const extra=`;{const old=window.fetch;window.fetch=(url,init)=>String(url)==='/api/queue'?Promise.resolve(new Response(JSON.stringify({error:'queue-refused',message:'fixture refusal'}),{status:503,headers:{'content-type':'application/json'}})):old(url,init)}`;
const {b,wait}=await setup(t,'/queue',extra);
await wait(`document.querySelector('#bus-view [role=alert]')`);
assert.equal(await b.evaluate(`document.querySelector('#bus-view h1').textContent`),'Queue');
assert.match(await b.evaluate(`document.querySelector('#bus-view [role=alert]').innerText`),/queue refused the read/i);
});
test('sidebar sprite is requested again after a failed read and recovery', async t => {
const {b,wait}=await setup(t,'/projects');
await wait(`document.querySelector('#sections use[href*="icons.svg"]')`);
const before = await b.evaluate(`document.querySelector('#sections use[href*="icons.svg"]').getAttribute('href')`);
await b.evaluate(`reviewFail['/api/chat/projects']=true;document.querySelector('#refresh').click()`);
await wait(`document.querySelector('.chat-error')`);
await b.evaluate(`reviewFail['/api/chat/projects']=false;document.querySelector('#refresh').click()`);
await wait(`!document.querySelector('.chat-error') && document.querySelector('#sections use[href*="?retry="]')`);
const after = await b.evaluate(`document.querySelector('#sections use[href*="icons.svg"]').getAttribute('href')`);
assert.notEqual(after,before);
});
+46
View File
@@ -258,6 +258,52 @@ test('a value the unit cannot carry is refused, for every flag', async (t) => {
}
});
// Rename design r2, "Selector scopes and parser matrix": --org and --business are one selector in
// the script's flags, and the unit always carries the canonical --business.
test('render takes --org or --business, alone or matching in either order, and writes --business', async (t) => {
const dir = tmp(t);
const want = `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7332 --business acme`;
for (const args of [['--business', 'acme'], ['--org', 'acme'], ['--business', 'acme', '--org', 'acme'], ['--org', 'acme', '--business', 'acme']]) {
const r = await run(dir, ['render', ...args]);
assert.equal(r.status, 0, `${args.join(' ')}: ${r.stderr}`);
assert.equal(execStart(r.stdout), want, args.join(' '));
}
// A selector spelling another flag consumes is that flag's value, so the board check refuses it.
const board = await run(dir, ['render', '--board', '--org', '--business', 'acme']);
assert.equal(board.status, 1);
assert.match(board.stderr, /board/);
});
test('render, install, uninstall and status refuse a bad selector before anything is read or written', async (t) => {
const dir = tmp(t);
const unitDir = join(dir, 'units');
const usageText = (await run(dir, [])).stderr;
for (const [args, status, stderr] of [
[['--business', 'acme', '--org', 'beta'], 4, 'webui-service: --business acme and --org beta conflict\n'],
[['--org', 'acme', '--business', 'beta'], 4, 'webui-service: --org acme and --business beta conflict\n'],
[['--business', 'acme', '--business', 'acme'], 4, 'webui-service: --business given twice\n'],
[['--org', 'acme', '--org', 'acme'], 4, 'webui-service: --org given twice\n'],
[['--org'], 4, usageText],
[['--business'], 4, usageText],
[['--org', ''], 4, usageText],
[['--org', '--send'], 1, 'webui-service: business must be a business id: --send\n'],
[['--business', '-x'], 1, 'webui-service: business must be a business id: -x\n'],
[['--org', 'Acme'], 1, 'webui-service: business must be a business id: Acme\n'],
[['--org', 'a'.repeat(65)], 1, `webui-service: business must be a business id: ${'a'.repeat(65)}\n`],
[['--org', 'acme/../x'], 1, 'webui-service: business must be a business id: acme/../x\n'],
]) {
for (const verb of [['render'], ['install', ...INSTALL, '--dir', unitDir, '--no-reload'], ['uninstall', '--dir', unitDir, '--no-reload'], ['status', '--dir', unitDir]]) {
const r = await run(dir, [...verb, ...args]);
const label = `${verb[0]} ${args.join(' ')}`;
assert.equal(r.status, status, `${label}: ${r.stderr}`);
assert.equal(r.stdout, '', label);
assert.equal(r.stderr, stderr, label);
}
}
assert.equal(existsSync(unitDir), false, 'nothing was installed');
assert.equal(existsSync(join(dir, '.quiet', 'calls.txt')), false, 'systemctl was never called');
});
test('the rendered unit names none of the human proof agent markers and sets no agent variable', async (t) => {
const dir = tmp(t);
const source = readFileSync(join(REPO, 'packages', 'bus', 'src', 'human.mjs'), 'utf8');
+3 -2
View File
@@ -60,10 +60,11 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
// to the same Settings route) and row 148's spec and docs save. Row 165 (#1662) adds the role
// instance add or change to /api/instances, row 65 the routing table's save to /api/routes,
// and the provider switch writes only the model-provider plugin setting.
// The owner's Initialize project records button (E2E round 4) posts an empty body to the org setup records route.
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? [];
assert.deepEqual(writes, ["fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 10);
assert.deepEqual(writes, ["fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });", "fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:harnessSettings.revision,plugins:{harness:selected}}),cache:'no-store',redirect:'manual'});", "fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 11);
assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ['url', '`/api/decisions/${enc(a.id)}/confirmation`', '`/api/decisions/${enc(a.id)}/resolve`']);
// Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal.
// Row 176 (#1674): the tester name form adds a second /api/name caller.
+4 -1
View File
@@ -30,7 +30,10 @@ test('tester-port validates range tunnel main port and requires configured bus b
assert.equal(r.status, 2); assert.match(r.stderr, /tester-socket needs the bus configured/);
});
test('CLI tester listener binds a private Unix socket and spawns TESTER_CLI for every Console bus read', { timeout: 30000 }, async t => {
// P19 #876: about 20 Console reads and chat calls in a row, each one TESTER_CLI round trip of two
// node processes (the shim re-executes itself to carry its nonce). That is about 3 s idle and
// 10 s at load 20 on 16 cores; 30 s left too little room on a busy machine, so the guard is 60 s.
test('CLI tester listener binds a private Unix socket and spawns TESTER_CLI for every Console bus read', { timeout: 60000 }, async t => {
const f = await testerFixture(t), port = testerSocketPath(f.f.dataRoot), log = join(f.root, 'spawn.jsonl');
await new Promise(r=>f.web.close(r));
const hook = join(f.root, 'spawn-hook.mjs');
@@ -12,13 +12,26 @@ const remoteScenario = {
const roomScenario = { [`/api/chat/rooms/${ids.project}/messages?limit=100`]: { rows: [], after: null, hasMore: false } };
const input = `const el=document.querySelector('#project-remote-url'); el.value='https://git.example/owner/unsaved'; el.dispatchEvent(new Event('input',{bubbles:true}));`;
const refresh = `document.querySelector('#refresh').click()`;
const replaced = `new Promise((resolve,reject)=>{const deadline=Date.now()+16000;const tick=()=>{if(document.querySelector('#bus-view .chat-area')!==window.__oldPaint && document.querySelector('#bus-view .chat-area'))resolve(true);else if(Date.now()>deadline)reject(Error('No repaint'));else setTimeout(tick,25)};tick()})`;
// Observe the actual chat render promise, including its completed read and paint.
const trackRefresh = `window.__pollRenders=0;{const original=window.MosaicChat;window.MosaicChat={...original,render:async (...args)=>{const result=await original.render(...args);window.__pollRenders++;return result}}}`;
const refreshed = `new Promise((resolve,reject)=>{const deadline=Date.now()+16000;const tick=()=>{if(window.__pollRenders)resolve(true);else if(Date.now()>deadline)reject(Error('No completed refresh'));else setTimeout(tick,25)};tick()})`;
test('remote draft survives a real polling repaint and resets on project change', { timeout: 40000 }, async t => {
test('refresh wait remains pending until the held project read and render finish', { timeout: 30000 }, async t => {
const { f, b, wait } = await w15Fixture(t, { scenario: remoteScenario });
await b.navigate(f.base + '/' + settings); await wait('!!document.querySelector("#project-remote-url")');
await b.evaluate(`${input} window.__oldPaint=document.querySelector('#bus-view .chat-area')`);
await b.evaluate(replaced);
await b.evaluate(`window.__fetch=fetch;window.fetch=async (url,init)=>{const response=await __fetch(url,init);if(String(url)==='/api/chat/projects'){window.__held=true;await new Promise(resolve=>window.__release=resolve)}return response}; ${trackRefresh} ${refresh}; window.__settled=false;void (${refreshed}).then(()=>window.__settled=true);`);
await wait('!!window.__held');
await b.evaluate(`new Promise(resolve=>setTimeout(resolve,200))`);
assert.equal(await b.evaluate('window.__settled'),false);
await b.evaluate('__release()');
await wait('window.__settled');
});
test('remote draft survives a real poll and resets on project change', { timeout: 40000 }, async t => {
const { f, b, wait } = await w15Fixture(t, { scenario: remoteScenario });
await b.navigate(f.base + '/' + settings); await wait('!!document.querySelector("#project-remote-url")');
await b.evaluate(`${input} ${trackRefresh}`);
await b.evaluate(refreshed);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url')?.value`), 'https://git.example/owner/unsaved');
await b.evaluate(`location.hash='#/projects/${ids.empty}/settings'`); await wait(`document.querySelector('.project-remote')?.textContent.includes('no linked repository')`);
await b.evaluate(`location.hash=${JSON.stringify(settings)}`); await wait(`!!document.querySelector('#project-remote-url')`);
@@ -29,7 +42,7 @@ test('remote pending save and success survive shell refresh', { timeout: 30000 }
const { f, b, wait } = await w15Fixture(t, { scenario: remoteScenario });
await b.navigate(f.base + '/' + settings); await wait('!!document.querySelector("#project-remote-url")');
await b.evaluate(`window.__fetch=fetch; window.fetch=async (url,init={})=>{if(init.method==='POST' && String(url).includes('/api/project-remotes/')) return new Promise(resolve=>window.__finish=()=>resolve(new Response(JSON.stringify({remote:'https://git.example/owner/unsaved'}),{status:200,headers:{'content-type':'application/json'}})));return __fetch(url,init)}; ${input} document.querySelector('.project-remote form').requestSubmit();`);
await wait('!!window.__finish'); await b.evaluate(`window.__oldPaint=document.querySelector('#bus-view .chat-area'); ${refresh}`); await b.evaluate(replaced);
await wait('!!window.__finish'); await b.evaluate(`${trackRefresh} ${refresh}`); await b.evaluate(refreshed);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url')?.disabled`), true);
await b.evaluate('__finish()'); await wait(`document.querySelector('.project-remote')?.textContent.includes('Configured, not verified')`);
assert.match(await b.evaluate(`document.querySelector('.project-remote').textContent`), /https:\/\/git.example\/owner\/unsaved/);
@@ -40,7 +53,7 @@ test('remote unknown outcome and reconciliation survive shell refresh', { timeou
await b.navigate(f.base + '/' + settings); await wait('!!document.querySelector("#project-remote-url")');
await b.evaluate(`window.__fetch=fetch; window.fetch=async (url,init={})=>{if(init.method==='POST' && String(url).includes('/api/project-remotes/')) throw Error('lost');return __fetch(url,init)}; ${input} document.querySelector('.project-remote form').requestSubmit();`);
await wait(`document.querySelector('.project-remote')?.textContent.includes('Save outcome is unknown')`);
await b.evaluate(`window.__oldPaint=document.querySelector('#bus-view .chat-area'); ${refresh}`); await b.evaluate(replaced);
await b.evaluate(`${trackRefresh} ${refresh}`); await b.evaluate(refreshed);
assert.match(await b.evaluate(`document.querySelector('.project-remote').textContent`), /Save outcome is unknown/);
assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), true);
await b.evaluate(`document.querySelector('.project-remote button[type=button]').click()`); await wait(`document.querySelector('.project-remote').textContent.includes('Review your entry')`);
+20 -4
View File
@@ -32,6 +32,12 @@
# Exit 0 when every F check passes, 1 when one fails, 2 on usage or a refusal (a bad args file, or
# --root misused). A P check never changes the exit.
#
# --org ID is the same option as --business ID, in three scopes: this script's own arguments, the args
# file's unit arguments before --, and its serve arguments after it. In each scope a spelling may appear
# once, and the other spelling must carry the same value; the args file needs a selector in both of its
# scopes, and all three must name the same business. The talk and precheck calls still pass --business,
# which every copy takes.
#
# Test seam, for packages/cli/tests/case1-accept.test.mjs only. Root takes none:
# --root DIR prefixes the args file, /etc, /proc, the stack's home, the host check and node, and
# reads the account database from DIR/etc/passwd and DIR/etc/group instead of getent
@@ -39,10 +45,15 @@ set -uo pipefail
usage() { echo "usage: case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N" >&2; exit 2; }
refuse() { echo "case1-accept: refusing: $*" >&2; exit 2; }
# selector.sh defines functions only, from this script's own root-owned checkout; parsing runs no program.
case "${BASH_SOURCE[0]}" in */*) SELECTOR_SH="${BASH_SOURCE[0]%/*}/selector.sh" ;; *) SELECTOR_SH=./selector.sh ;; esac
. "$SELECTOR_SH" || refuse "cannot read $SELECTOR_SH"
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]; }
invocation_refused() { case "$1" in missing|flag|invalid) usage ;; *) refuse "$2" ;; esac; }
BUSINESS="" ARGS_FILE="" CLAUDE_PIN="" NODE_PIN="" ROOT=""
while [ $# -gt 0 ]; do
case "$1" in
--business) [ $# -ge 2 ] && [[ "$2" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage; BUSINESS="$2"; shift 2 ;;
--business|--org) selector_take invocation id invocation_refused business_id_ok "$1" "${@:2:1}"; BUSINESS="$2"; shift 2 ;;
--args) [ $# -ge 2 ] && [[ "$2" =~ ^/[A-Za-z0-9/._+-]+$ ]] || usage; ARGS_FILE="$2"; shift 2 ;;
--claude-pin) [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; CLAUDE_PIN="$2"; shift 2 ;;
--node-pin) [ $# -ge 2 ] && [[ "$2" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage; NODE_PIN="$2"; shift 2 ;;
@@ -67,14 +78,18 @@ st="$(stat -c '%u %a' -- "$f")" || refuse "cannot stat $ARGS_FILE"
[ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) ||
refuse "$ARGS_FILE must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })"
mapfile -t ARGS < "$f" || refuse "cannot read $ARGS_FILE"
unit_refused() { refuse "$ARGS_FILE before --: $2"; }
serve_refused() { refuse "$ARGS_FILE after --: $2"; }
S_USER="" S_HOME="" A_BUSINESS="" NODE="" COPY_ID="" CLAUDE="" PROFILE="" PORT="" TESTER_SOCKET=0
copies=0 past=""
copies=0 past="" served=""
for ((i = 0; i < ${#ARGS[@]}; i++)); do
a="${ARGS[i]}" v="${ARGS[i + 1]:-}"
if [ -n "$past" ]; then
case "$a" in
--port) PORT="$v"; i=$((i + 1)) ;;
--business) [ "$v" = "$BUSINESS" ] || refuse "serve business differs"; i=$((i + 1)) ;;
--business|--org)
selector_take serve id serve_refused business_id_ok "$a" "$v"
[ "$v" = "$BUSINESS" ] || refuse "serve business differs"; served=1; i=$((i + 1)) ;;
--tester-socket) TESTER_SOCKET=$((TESTER_SOCKET + 1)) ;;
--tester-port) refuse "case1-accept.sh retires tester TCP; use --tester-socket" ;;
*) refuse "unknown serve flag $a" ;;
@@ -86,7 +101,7 @@ for ((i = 0; i < ${#ARGS[@]}; i++)); do
--pm) ;;
--user) S_USER="$v"; i=$((i + 1)) ;;
--home) S_HOME="$v"; i=$((i + 1)) ;;
--business) A_BUSINESS="$v"; i=$((i + 1)) ;;
--business|--org) selector_take unit id unit_refused business_id_ok "$a" "$v"; A_BUSINESS="$v"; i=$((i + 1)) ;;
--node) NODE="$v"; i=$((i + 1)) ;;
--claude) CLAUDE="$v"; i=$((i + 1)) ;;
--profile) PROFILE="$v"; i=$((i + 1)) ;;
@@ -95,6 +110,7 @@ for ((i = 0; i < ${#ARGS[@]}; i++)); do
esac
done
[ "$copies" = 1 ] || refuse "$ARGS_FILE needs exactly one --copy before --, not $copies"
[ -n "$served" ] || refuse "$ARGS_FILE needs --business or --org after --"
[[ "$S_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] && [[ "$S_HOME" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$A_BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] &&
[[ "$NODE" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] && [[ "$CLAUDE" =~ ^/[A-Za-z0-9/._+@-]+$ ]] ||
refuse "$ARGS_FILE lacks a plain --user, --home, --business, --node, --claude or --copy"
+25 -2
View File
@@ -5,6 +5,10 @@
# case2-install.sh --business ID --business-file FILE --commit SHA
# [--profile factory|library] [--personas DIR] [--home DIR]
#
# --org ID is the same option as --business ID, and --org-file FILE the same
# as --business-file FILE. They are two families: each spelling once, and a
# second spelling of a family must carry the same text.
#
# Steps, in order: refuse root; scripts/bootstrap.sh; stack-copy.sh copy
# SHA, whose npm ci installs the pinned harness; for library, the persona
# import's install from the staging DIR; the business file at 0600; for
@@ -39,11 +43,30 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STACK_COPY="${CASE2_STACK_COPY:-$REPO/scripts/stack-copy.sh}"
usage() { echo 'usage: case2-install.sh --business ID --business-file FILE --commit SHA [--profile factory|library] [--personas DIR] [--home DIR]' >&2; exit 4; }
# shellcheck source=scripts/selector.sh
. "$REPO/scripts/selector.sh" || { echo "case2-install: cannot read $REPO/scripts/selector.sh" >&2; exit 4; }
# The selectors (rename design r2): the id family and the file family are separate scopes. A
# missing value is a usage error; a flag-shaped or invalid id is the business-id refusal below; a
# flag-shaped file, a repeated spelling and a conflict say why. All exit 4, as before.
id_refused() {
case "$1" in
missing) usage ;;
flag|invalid) echo "case2-install: --business must be a business id: $BUSINESS" >&2; exit 4 ;;
*) echo "case2-install: $2" >&2; exit 4 ;;
esac
}
file_refused() {
case "$1" in
missing) usage ;;
*) echo "case2-install: $2" >&2; exit 4 ;;
esac
}
business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]]; }
PROFILE=library BUSINESS='' BFILE='' COMMIT='' PERSONAS='' HOME_ARG="${HOME:-}"
while [ $# -gt 0 ]; do
case "$1" in
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
--business-file) [ $# -ge 2 ] || usage; BFILE="$2"; shift 2 ;;
--business|--org) BUSINESS="${2-}"; selector_take id id id_refused business_id_ok "$1" "${@:2:1}"; shift 2 ;;
--business-file|--org-file) selector_take file file file_refused - "$1" "${@:2:1}"; BFILE="$2"; shift 2 ;;
--commit) [ $# -ge 2 ] || usage; COMMIT="$2"; shift 2 ;;
--profile) [ $# -ge 2 ] || usage; PROFILE="$2"; shift 2 ;;
--personas) [ $# -ge 2 ] || usage; PERSONAS="$2"; shift 2 ;;
+15 -1
View File
@@ -5,6 +5,7 @@
// case2-persona-import.mjs stage --hermes-root DIR --pair PROFILE=NAME --pair PROFILE=NAME
// (--staging DIR | --dry-run)
// case2-persona-import.mjs install --staging DIR --config-dir DIR --business ID
// (--org ID is the same option)
//
// stage runs as whoever can read her Hermes home. Per profile it reads SOUL.md, the persona Hermes
// loads (agent/prompt_builder.py:1829-1855 at df5700ebe), and config.yaml for the per-channel prompt
@@ -31,6 +32,7 @@ import { createHash } from "node:crypto";
import { join } from "node:path";
import { parse as parseYaml } from "yaml";
import { PERSONA_CHARS, personaTextFault } from "../packages/harness/src/bundle.mjs";
import { selectorScope } from "../packages/business/src/selector.mjs";
// The tool names registered in hermes-agent's tools/*.py at df5700ebe (literal `name="..."` in
// registry.register calls; the MCP and plugin tools get their names at run time), plus Hermes and
@@ -67,9 +69,21 @@ class Usage extends Error {}
function args(argv) {
const out = { pairs: [] };
// --org and --business are one selector in these options (rename design r2). A missing value or
// one starting "--" is a usage error, as before; an id outside this script's grammar is its
// refusal, now while parsing; a repeated or conflicting selector is a usage error.
let given;
const selector = selectorScope({
valid: (id) => BUSINESS_ID.test(id),
refuse: (kind, why) => {
if (kind === "invalid" || (kind === "flag" && !given.startsWith("--"))) throw new Refusal(`business ${given} isn't a business id`);
throw new Usage(why);
},
});
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
if (flag === "--dry-run") { out.dryRun = true; continue; }
if (selector.owns(flag)) { given = argv[i + 1]; selector.take(flag, given); i++; continue; }
const value = argv[i + 1];
if (value === undefined || value.startsWith("--")) throw new Usage(`${flag} needs a value`);
i++;
@@ -77,9 +91,9 @@ function args(argv) {
else if (flag === "--hermes-root") out.hermesRoot = value;
else if (flag === "--staging") out.staging = value;
else if (flag === "--config-dir") out.configDir = value;
else if (flag === "--business") out.business = value;
else throw new Usage(`unknown option ${flag}`);
}
if (selector.given) out.business = selector.value;
return out;
}
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more