Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c6556ab1e | ||
|
|
f4bfd3a744 | ||
|
|
d35447005a | ||
|
|
2109458b3f | ||
|
|
1616d1dd6f | ||
|
|
7a97cfc4f3 | ||
|
|
03fe321b12 | ||
|
|
483eccb3dd | ||
|
|
a8f9f09573 | ||
|
|
0a73279d62 | ||
|
|
73df1a687f | ||
|
|
999d173fd4 | ||
|
|
e98909d907 | ||
|
|
1b6cac2bdf |
No files matched your search
@@ -6339,3 +6339,9 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
|
||||
2026-11-07 UTC | Organizations S1 — implementation evidence | Organization-root bus and launcher paths, guarded lifecycle and Console launch/stop were implemented locally. Focused Node 26 tests and host regressions passed; the fresh-organization notifier choice, shared-proxy S2 integration, and remaining instance-root consumers are not complete. Exact commands, inventory, failures and boundaries: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s1.md. No push or deployment. Gitea issue creation was excluded by the no-network and no-external-communication limits.
|
||||
|
||||
2026-11-09 UTC | Unified workspace (#1873) — direction recorded | Before: the 11-07 rulings had one Console switch between organizations from Settings, tasks only in each org queue, and an agent bound to one organization (REQ-USER-1). After: Jason's 11-09 direction and answers recorded in docs/plans/2026-11-09_unified-workspace-owner-direction.md (all orgs visible at once, per-org task stores with a Vikunja option and an aggregated Kanban, root/org/project agents with one cross-org assistant, system type plug-ins, self-service restart and model switch). PRD 1.20 draft 1 written for his exact-text approval; the PRD itself is unchanged. The unpushed 11-07 rulings record (ad8140065) lands with it.
|
||||
|
||||
2026-11-09 UTC | Organizations friction round, landings #1876-#1881 (#1873) | Before: refactor 00ef0bd30. A Console launched by a relative path showed Request status unavailable, the overview printed the north star as raw markdown, packages/business and the business command spellings were the only names, Quick Setup ended with no route to a north star, an archived org could not be restored from a fresh page, and a dead bus after setup had no browser control. After, each through one Codex Sol xhigh review round plus a lead or builder fix round: #1876 re-execs a relative Console launch with its absolute script path (build 744753d7e, reproduced 121/0; review APPROVE 0/0/1). #1877 F1 renders the north star as sanitized markdown with a short preview (review APPROVE 0/0/1; live retest 4/0 twice). #1878 rename stage B moves packages/business to packages/organization and adds the org command spellings, keeping the business ones and their messages (builder 6828/6, see report; review CHANGES 0/1/0, fixed in e19db4009). #1879 F2 adds the onboarding interview after Quick Setup and on the project page, with human-only north-star approval (`mosaic project north-star --approve`) and no new agent grant (builder 411/0; review CHANGES 0/1/1: a refused start left its button disabled, fixed in 5937a977f along with the P3 stale approval capability). #1880 lands the U0 unified workspace samples, approved by Jason with the four-tile logo kept; the module names stay undecided at his request. #1881 E2E round 5 fixes restore from a fresh page, show an owner bus Start whenever the org list reads the bus down, say room changes are unavailable on org Consoles and recover the composer (builder 258/0; review CHANGES 0/1/0, 231/0: Settings kept Done after the bus died because only opening the org reread health). Lead fix fdd6658a6: every org list read is announced and the open setup redraws on a flip, and app.js reads the list before bus-status so a failed bus-status read cannot skip it; regression red on 8a8edc44b, 18 touched webui files 199/0 (Node 26.12.1, one file at a time). uiorg redeployed at fdd6658a6, 19:53Z. Open follow-ups: a down bus offers both the F3 banner Restart and the Settings Start, focus can move when setup redraws, and a Console stop also stops the org buses it started. Reports: /mnt/storage/scratch/mosaic-lead/reports/org-{reqget-2,f1,rename-b,f2,u0-design,e2e5}-*.md.
|
||||
|
||||
2026-11-09 UTC, organizations F3 (#1873, PR #1884, a8f9f0957). The WebUI restarts the Console (supervised Consoles exit and `mosaic console start` relaunches them), restarts an organization's bus from the bus-down banner with a done receipt, and sends the owner's routing, agent, instance, name, assistant, limits and model proxy writes to the selected organization's bus. Routing apply lists how a change reaches each saved agent and offers Restart NAME now. Console restarts, bus restarts and routing, agent and instance changes append to `<dataRoot>/console/actions.jsonl`. Builder plus two refactor merges: combined list 354/0 on 999d173fd. Sol review CHANGES 1/0/1. P1: on a `--dev-local-owner` Console a local agent can send the right Origin and Fetch Metadata headers and reach these controls. The pinned startup warning and the accepted no-auth dev flags already cover this, but the README claimed no agent reaches them. Lead decision: correct the README and file the cooperative local-agent peer refusal as #1883, without building it in this round. P3: the instances route wrote no trail line. Fixed in 0a73279d6; the regression test fails on 999d173fd and passes on the fix. After the refactor merge, 25 touched or README-reading files 195/0 (Node 26.12.1, one file at a time, env -i). uiorg redeployed at a8f9f0957, 22:45Z; live config offers consoleRestart and org settingsSend. Correction to this session's process: after the test run completed I replied "No response requested" and stopped until Jason asked. Open follow-ups: defect l (a bus restart after adding a credentialed agent can refuse to start), the bus restart refusal code, a stale-lock Recover control, one control for banner Restart and Settings Start, #1883. Report: /mnt/storage/scratch/mosaic-lead/reports/org-f3-review.md.
|
||||
|
||||
2026-11-09 UTC, PRD 1.20 and defect l (#1873, #1886; PRs #1887 03fe321b1 and #1888 7a97cfc4f). PRD 1.20: Jason approved draft 2 at 22:57Z ("The file looks fine other than that"; the Montage module name stays deferred). The PRD now has 118 requirements, with REQ-WS-1..8, REQ-AGENT-1..3, REQ-SYS-1..2, REQ-ACCT-1..2 and REQ-SETUP-1..2 added and REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3 amended. Builder Codex Terra xhigh declined to commit because stack-copy failed 20/1 (node-gyp missing from the test Node's npm); that failure is pre-existing on 483eccb3d, so the lead committed. Sol review APPROVE 0 (word-for-word diff against draft 2, catalog order, both requirements tests). Defect l: before, an agent that declared a store credential made the next bus start refuse, because Credentials refuses a missing store root and only issue() created it. After: start creates the root 0700 when any reference names the store; a root that exists with loose modes or as a symlink still refuses. Two new credential-verbs tests fail on the parent. Bus suite 171 files one at a time, 1713/0 (Node 26.12.1, env -i). Sol review APPROVE 0. uiorg redeployed at 7a97cfc4f, 23:15Z. Follow-up: the stack-copy node-gyp test environment. Reports: /mnt/storage/scratch/mosaic-lead/reports/{prd120-land,prd120-review,defectl-review}.md.
|
||||
@@ -2121,3 +2121,5 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-11-07 UTC | Codex implementation sub-agent | Organizations S1 bus and Console lifecycle on org/s1 | Scoped implementation and scratch-HOME test evidence; fresh notifier choice, S2 proxy integration and remaining path consumers require integration work. Inventory, commands and limits: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s1.md. Queue refused linked worktree; no push or deployment.
|
||||
2026-11-07 UTC | Claude implementation sub-agent | Organizations S6 WebUI create and guided setup on org/s6 | Settings > Organizations: template form with JSON preview, C2 create/update with if-revision, setup cards for checkout, repositories and bus; webui suite 974/975 (inherited dev-owner test failure). Report and screenshots: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s6.md. No push, merge or deployment.
|
||||
2026-11-07 UTC | Codex implementation sub-agent | Organizations release fix round 1 on ui-org-r1 | Local candidate for independent review; verification and findings recorded in /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-fix-r1.md. Queue refused linked worktree; no push, merge or deployment.
|
||||
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | Organizations F3 fix round and landing (#1873) | PR #1884 merged a8f9f0957; review P3 fixed, P1 README corrected with follow-up #1883; 195/0 focused suite; uiorg redeployed 22:45Z.
|
||||
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | PRD 1.20 landing and defect l (#1873, #1886) | PR #1887 merged 03fe321b1, PR #1888 merged 7a97cfc4f; both Sol reviews APPROVE 0; bus suite 1713/0; uiorg redeployed 23:15Z; U1, U2 and U4a builds dispatched.
|
||||
+4
-1
@@ -311,7 +311,8 @@ scripts/mosaic decide <decision> <option> [--note <text>] [--yes]
|
||||
scripts/mosaic trail <task|decision> [--json]
|
||||
scripts/mosaic bus start <business> [--pm | --no-launch] [--dev-local-owner] | stop | status [--json]
|
||||
scripts/mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
|
||||
scripts/mosaic console stop | status
|
||||
scripts/mosaic console stop | status | restart
|
||||
scripts/mosaic org reload <org>
|
||||
scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>]
|
||||
scripts/mosaic project create <name> [--repo <repo>] | list [--json]
|
||||
scripts/mosaic threads [--project <project>] [--json] [--tester]
|
||||
@@ -332,6 +333,8 @@ For local development on a named instance only, `mosaic bus start BUSINESS --dev
|
||||
|
||||
`mosaic console start --dev-local-owner --dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens that dev owner Console to other machines, for UI testing from another device on a trusted LAN. It has no sign-in: anyone who can reach ADDR:PORT acts as the owner. Start, and `mosaic console status` while that Console runs, print `WARNING: dev owner Console listens on ADDR:PORT with no sign-in; anyone who can reach it acts as the owner.` on stderr. ADDR is `0.0.0.0` or an IPv4 address, and a specific address must also be one of the names. NAME is a host name or IPv4 address without a port; give several as one comma-separated value. The Console answers only `NAME:<bound port>` and the loopback names, and takes a write only when Origin is `http://` plus that same Host. A browser on such an http origin sends no Fetch Metadata, so for an allowed NAME a write with that exact Origin and no `Sec-Fetch-Site` counts as same-origin; a `Sec-Fetch-Site` the browser did send is still checked. Both flags are refused without `--dev-local-owner`, and `--dev-insecure-bind` is refused without a `--dev-allow-host`. SSO, preview and tester Consoles stay on loopback. The Console state file records `bind` and `allowHosts`. `mosaic console status` prints the state as JSON (`bind` is `127.0.0.1` for a loopback Console) and never moves a stale state file.
|
||||
|
||||
`mosaic console restart` and `mosaic org reload ORG` are the Console's Settings controls (F3) from a terminal (U1, #1873). Each sends one request to the running Console named in the instance's console state, as the page sends it (the Console's own Host and Origin, `Sec-Fetch-Site: same-origin`, a JSON `{}` body), so the Console applies the same owner, `--send` and origin checks and writes the same action trail, `<dataRoot>/console/actions.jsonl`. Both refuse inside an agent run (exit 3) before reading anything, and refuse with exit 3 when no Console runs. `console restart` works only for a Console started in the foreground by `mosaic console start` (otherwise the Console answers `not-supervised`); it waits up to 30 seconds for the relaunched Console to answer and prints `{"outcome":"restarted","from":OLD,"boot":NEW}`. `org reload ORG` restarts a running bus or starts a stopped one and prints the Console's done receipt. A request that left but got no answer is reported as outcome unknown (exit 1) and is never retried; check `mosaic console status`, `mosaic bus status ORG` and the trail. `mosaic bus restart ORG` remains the trail-less host verb.
|
||||
|
||||
The instance processing switch is separate from per-business launches off/on.
|
||||
`mosaic halt` durably stops managed processing across every business in the
|
||||
selected configuration's data root. The bus stays up for reads and human
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# PRD version 1.20, draft 2: unified workspace
|
||||
|
||||
- Status: draft, not approved. Jason approves the exact text before it
|
||||
enters `docs/prd/mosaic-stack.md` as version 1.20.
|
||||
- Status: approved as version 1.20 on 2026-11-09; landed in docs/prd/mosaic-stack.md
|
||||
- Source: `docs/plans/2026-11-09_unified-workspace-owner-direction.md`,
|
||||
Jason's direction and answers of 2026-11-09.
|
||||
- Drafted by Mos (lead).
|
||||
|
||||
+126
-20
@@ -1,12 +1,11 @@
|
||||
# PRD: Mosaic Stack
|
||||
|
||||
- Status: approved, version 1.19. Jason approved the exact text in Q289.
|
||||
- Version 1.19 adds disk-space governance, launch-capacity holds,
|
||||
human-controlled retention rules, and the protected `data.delete.policy`
|
||||
route. It preserves per-batch `data.delete` hard waits. Jason approved
|
||||
the exact text at `e5400456` under REQ-WAIT-2: "1.18: yes" (DECISIONS
|
||||
Q289, relayed by Mos in mos-2300). Q288 approved the recurring-rule principle.
|
||||
- Prior approval history, through version 1.18: Jason approved draft 0.4 unchanged as
|
||||
- Status: approved, version 1.20. Jason approved draft 2 on 2026-11-09.
|
||||
- Version 1.20 adds the unified workspace: every organization visible at once,
|
||||
cross-organization views, three levels of agent configuration, per-org task
|
||||
stores behind the task tracker kind, system type plug-ins and self-service
|
||||
operation. It amends REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3.
|
||||
- Prior approval history, through version 1.19: Jason approved draft 0.4 unchanged as
|
||||
1.0 on 2026-10-05 (lead decision 61). They approved version 1.1, the
|
||||
Organization requirements, in decision 03130d08-ddce-480d-a601-e59f64744fc3
|
||||
on 2026-10-14. They approved version 1.2, the chief of staff, the
|
||||
@@ -64,7 +63,18 @@
|
||||
the approved row 255 design. The stack prints the providers entry and
|
||||
never writes config.json. What the requirement allows or forbids is
|
||||
unchanged (mos-1671, sage-4418). Doc-only, so it takes the 1.11 path:
|
||||
one seat drafts, a second reviews, Sage lands.
|
||||
one seat drafts, a second reviews, Sage lands. Version 1.19 adds
|
||||
disk-space governance, launch-capacity holds, human-controlled retention
|
||||
rules, and the protected `data.delete.policy` route. It preserves per-batch
|
||||
`data.delete` hard waits. Jason approved the exact text at `e5400456` under
|
||||
REQ-WAIT-2: "1.18: yes" (DECISIONS Q289, relayed by Mos in mos-2300).
|
||||
Q288 approved the recurring-rule principle. Jason approved the exact text in Q289.
|
||||
- Version 1.20 source: Mos asked Jason to approve draft 2 in
|
||||
`docs/plans/2026-11-09_prd-1-20-draft.md`, with the Montage name deferred.
|
||||
Jason replied on 2026-11-09 at 22:57Z: "The file looks fine other than that."
|
||||
The exception is the module name, which he had deferred at 19:26Z: "I don't
|
||||
like these module names either. It isn't clicking for me. That may need to be
|
||||
a later decision."
|
||||
- Version 1.19 source, Jason's DECISIONS Q287, verbatim:
|
||||
|
||||
> All of the learnings from this drive space issue need to be integrated into Mosaic Stack. We need watchers, cleanups, enforcement tooling and agents to check for space waste, etc.
|
||||
@@ -475,9 +485,11 @@ Added in version 1.2:
|
||||
names the roles and how many of each. The chief of staff launches them
|
||||
through role.launch, within the agent limits (REQ-LIMIT-1). (Version
|
||||
1.5.)
|
||||
- **REQ-ASSIST-1.** The assistant is the user's main agent. Each user has
|
||||
one assistant role per organization. It talks with the user, and it
|
||||
passes work for the organization to the chief of staff. It doesn't
|
||||
- **REQ-ASSIST-1.** The assistant is the user's main agent. Each user has one
|
||||
assistant across all of the organizations they
|
||||
belong to. It knows the user's organizations, projects and sessions and
|
||||
keeps one shared memory. It talks with the user and passes each
|
||||
organization's work to that organization's chief of staff. It doesn't
|
||||
direct other agents, create or assign tasks, or launch sessions.
|
||||
- **REQ-ASSIST-2.** The broker limits the assistant's messages, not its
|
||||
contract alone:
|
||||
@@ -522,6 +534,24 @@ Added in version 1.7:
|
||||
- Every hire and fire is logged with the chief's instance, its run and
|
||||
the reason.
|
||||
|
||||
### Agent levels
|
||||
|
||||
Added in version 1.20.
|
||||
|
||||
- **REQ-AGENT-1.** Agents are configured at three levels: the user's root,
|
||||
the organization and the project. A project's available agents are the
|
||||
union of the three.
|
||||
- **REQ-AGENT-2.** The user can see and change the configuration of agents,
|
||||
organizations, projects and sessions, and can override any setting at any
|
||||
time, through the write path (REQ-ROLE-3).
|
||||
- **REQ-AGENT-3.** The chief of staff hires and fires agents to meet the
|
||||
user's needs. At the organization and project levels it acts under
|
||||
REQ-COS-3. At the root level its hire or fire changes the user's root
|
||||
configuration, which every organization of that user sees, so it reaches
|
||||
outside the chief's own organization. A root-level hire or fire is a
|
||||
decision the user approves in the inbox (REQ-DEC-2) before it takes
|
||||
effect. The user can also change root-level agents directly.
|
||||
|
||||
### Variables
|
||||
|
||||
- **REQ-VAR-1.** Variables come in four layers: system, business, project
|
||||
@@ -879,8 +909,15 @@ consumed ordinary decision as standing permission.
|
||||
|
||||
### Tasks
|
||||
|
||||
- **REQ-TASK-1.** Tasks live in Vikunja, reached through `/api/v2` with a
|
||||
bot token for each role. Each task cites a requirement id. Each task
|
||||
- **REQ-TASK-1.** Each organization stores its own tasks, through the task tracker
|
||||
plug-in kind (REQ-PLUG-1). The default module is a store built into Mosaic
|
||||
Stack. Vikunja is an optional module, and when an organization selects it
|
||||
the 1.19 rules for Vikunja (bot tokens per role, one writing role per field,
|
||||
REQ-TASK-2 polling) apply to that organization. Every task tracker module
|
||||
supports create, read, update, archive and delete, ordering and grouping.
|
||||
Archiving or removing an organization removes its tasks without touching
|
||||
another organization's.
|
||||
Each task cites a requirement id. Each task
|
||||
field has one writing role, listed in the slice 1 brief's field table,
|
||||
and the assigned role writes the task's state. Since version 1.19, the
|
||||
scoped disk-service principal in REQ-DISK-12 may initialize incident
|
||||
@@ -1031,10 +1068,40 @@ Added in version 1.7:
|
||||
- The decision event records via=console and the (iss, sub) pair.
|
||||
- Without a signed-in session, the Console resolves nothing and shows
|
||||
the `mosaic decide` line instead.
|
||||
- **REQ-WEB-3.** The Console has controls to restart the bus and to
|
||||
reload the organization file. Each control is a gated, trailed action.
|
||||
A reload applies `launch.instances` changes to running sessions without
|
||||
a manual `systemctl restart`. (Version 1.4, decision 021af0c8.)
|
||||
- **REQ-WEB-3.** The Console has controls to restart itself, to restart an
|
||||
organization's bus, to reload the organization file, and to change the model
|
||||
an agent instance runs on and apply it. Each control is a gated, trailed
|
||||
action that a human takes. No agent can take it. A reload applies
|
||||
`launch.instances` changes to running sessions without a manual
|
||||
`systemctl restart`. On a dev instance (`--dev-local-owner`) the controls
|
||||
need no sign-in.
|
||||
|
||||
### Workspace
|
||||
|
||||
Added in version 1.20.
|
||||
|
||||
- **REQ-WS-1.** The WebUI shows every organization the user belongs to at
|
||||
all times. A collapsible Organizations item in the sidebar lists them.
|
||||
There is no organization switcher.
|
||||
- **REQ-WS-2.** The Console reads across the buses of the user's
|
||||
organizations to build shared views. Each organization keeps its own bus
|
||||
as the tenant boundary (REQ-ORG-1). A shared view shows only
|
||||
organizations the user belongs to.
|
||||
- **REQ-WS-3.** The main dashboard sums activity, work and metrics across
|
||||
all of the user's organizations and projects.
|
||||
- **REQ-WS-4.** Each organization has a dashboard with its docs, every
|
||||
configurable setting, its KPIs and metrics, and its active agent
|
||||
sessions.
|
||||
- **REQ-WS-5.** The projects list covers every organization. It sorts and
|
||||
filters by organization, status, name and activity. Each project shows
|
||||
its owning organization by icon, color and avatar.
|
||||
- **REQ-WS-6.** One inbox holds every item that needs the user, from every
|
||||
organization, project and session.
|
||||
- **REQ-WS-7.** One view shows activity across all organizations and
|
||||
projects. Organizations and projects can be open side by side.
|
||||
- **REQ-WS-8.** The tasks view aggregates every organization's tasks, with
|
||||
Kanban and list layouts, filtered by organization and project. It works
|
||||
the same for every task tracker module.
|
||||
|
||||
### Launching
|
||||
|
||||
@@ -1173,6 +1240,17 @@ Added in version 1.3 (Q155).
|
||||
changes it, not in one rewrite (Q155). Until it converts, Technical
|
||||
considerations lists where it is hard-coded.
|
||||
|
||||
### System types
|
||||
|
||||
Added in version 1.20.
|
||||
|
||||
- **REQ-SYS-1.** System types are a plug-in kind. Its modules include
|
||||
coding, creative writing, marketing, Mosaic Montage (the OpenMontage
|
||||
fork) and business management. An organization enables a list of them.
|
||||
- **REQ-SYS-2.** A project may use any system type its organization
|
||||
enables, and none is required. An agent working in a project gets the
|
||||
toolsets of the system types that project uses.
|
||||
|
||||
### Meta-harness
|
||||
|
||||
- **REQ-HARN-1.** The meta-harness generates each session's launch bundle
|
||||
@@ -1329,9 +1407,13 @@ Added in version 1.1 (decision 03130d08).
|
||||
- **REQ-ORG-2.** The data model is install, then organization, then
|
||||
projects, then threads, so v1 doesn't lock in one human per
|
||||
organization.
|
||||
- **REQ-USER-1.** Users and role-based access control exist at install
|
||||
and organization level. An agent belongs to one organization and
|
||||
reaches only its allowed projects and threads.
|
||||
- **REQ-USER-1.** Users and role-based access control exist at install and
|
||||
organization level. An agent other than the assistant works in one
|
||||
organization at a time. Its memory, files and credentials are scoped to that
|
||||
organization, even when its definition comes from the user's root
|
||||
configuration. It reaches only that organization's allowed projects and
|
||||
threads. The assistant is the exception in REQ-ASSIST-1, and it reaches only
|
||||
organizations its user belongs to.
|
||||
- **REQ-USER-2.** Only the user who owns a thread reads it or writes to
|
||||
it, plus that user's assistant and the chief of staff's replies on work
|
||||
the thread sent. This fills in REQ-USER-1's "threads" for the chat
|
||||
@@ -1462,6 +1544,15 @@ design `docs/plans/2026-10-21_t1-sessions.md`, 2c843d190 on `huey/t1-sessions-de
|
||||
with `processing-halted` and changes nothing. A clear never sets or
|
||||
clears the kill switch. (Design D8, test T6.)
|
||||
|
||||
### Accounts
|
||||
|
||||
Added in version 1.20.
|
||||
|
||||
- **REQ-ACCT-1.** A deployed system requires user sign-in (OIDC, Q203). A
|
||||
user creates organizations and belongs to any number of them.
|
||||
- **REQ-ACCT-2.** An organization's owner invites another user into it.
|
||||
The invited user joins with the grant the owner chose (REQ-USER-3).
|
||||
|
||||
### Creative library
|
||||
|
||||
Added in version 1.12 (Q176 Case 2, Q211). Drafted from the Case 2
|
||||
@@ -1788,6 +1879,17 @@ Added in version 1.11 (Q187, Q189, Q211):
|
||||
monitored coverage. Install and upgrade enable no deletion policies
|
||||
(REQ-DISK-1, REQ-DISK-4, REQ-DISK-10).
|
||||
|
||||
### Setup
|
||||
|
||||
Added in version 1.20.
|
||||
|
||||
- **REQ-SETUP-1.** Quick Setup leaves its project with its records
|
||||
initialized.
|
||||
- **REQ-SETUP-2.** After Quick Setup, the assistant offers an onboarding
|
||||
interview. It asks for the project's outcome and limits and drafts the
|
||||
north star and a first PRD. The user approves each through the existing
|
||||
approval path.
|
||||
|
||||
## Acceptance criteria and success measures
|
||||
|
||||
v1 is done when Jason gives one-sentence requests to the assistant, in a
|
||||
@@ -2367,6 +2469,10 @@ gate that slice.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- The final name for the Mosaic Montage module is deferred to a later owner
|
||||
decision. Mosaic Montage remains a working name; the existing four-tile
|
||||
logo stays. (Version 1.20.)
|
||||
|
||||
- On each v1 instance, the stack runs as that instance's own OS user,
|
||||
without sudo (Q181). A managed role session runs in a sandbox
|
||||
(REQ-HARN-2): it writes only its workspace and run directory, sees no
|
||||
|
||||
@@ -639,3 +639,19 @@ CREATE TRIGGER request_snapshot_events_no_replace BEFORE INSERT ON request_snaps
|
||||
CREATE TRIGGER request_project_events_no_update BEFORE UPDATE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
|
||||
CREATE TRIGGER request_project_events_no_delete BEFORE DELETE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
|
||||
CREATE TRIGGER request_project_events_no_replace BEFORE INSERT ON request_project_events WHEN EXISTS (SELECT 1 FROM request_project_events WHERE seq=NEW.seq OR (business=NEW.business AND project=NEW.project AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
|
||||
-- Per-project selections are a human-authored append-only settings trail.
|
||||
CREATE TABLE project_system_types (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
project TEXT NOT NULL REFERENCES projects(id),
|
||||
types TEXT NOT NULL,
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX project_system_types_project ON project_system_types (business, project, seq);
|
||||
CREATE TRIGGER project_system_types_no_update BEFORE UPDATE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
|
||||
CREATE TRIGGER project_system_types_no_delete BEFORE DELETE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
|
||||
CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_types WHEN EXISTS (SELECT 1 FROM project_system_types WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
|
||||
CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types
|
||||
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END;
|
||||
@@ -165,7 +165,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
|
||||
'thread.share', 'thread.unshare', 'thread.shares']);
|
||||
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
|
||||
// refuses it human-required before its own checks, and these refuse every other cap.
|
||||
export const CONSOLE_VERBS = Object.freeze(['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
|
||||
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
|
||||
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
|
||||
@@ -5560,6 +5560,28 @@ export class Broker {
|
||||
return result;
|
||||
}).catch(e => { throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request); });
|
||||
}
|
||||
if (request.verb === 'system-types.console') {
|
||||
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
|
||||
keys(args, ['project']);
|
||||
if (!this.#organization) fail('not-configured');
|
||||
const enabled = this.#organization(s.business, 'system-types.current', {}, s.human);
|
||||
if (!Array.isArray(enabled)) fail('not-configured');
|
||||
let project = null;
|
||||
if (args.project !== undefined) {
|
||||
const id = identifier(args.project);
|
||||
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', id, s.business)) fail('project-not-found');
|
||||
const row = this.#store.get('SELECT types FROM project_system_types WHERE business=? AND project=? ORDER BY seq DESC LIMIT 1', s.business, id);
|
||||
const selected = row ? JSON.parse(row.types) : [];
|
||||
project = { project: id, selected, active: selected.filter(x => enabled.includes(x)), inactive: selected.filter(x => !enabled.includes(x)) };
|
||||
}
|
||||
return Promise.resolve(this.#organization(s.business, 'system-types.installed', {}, s.human))
|
||||
.then(installed => {
|
||||
if (!Array.isArray(installed)) fail('not-configured');
|
||||
const result = { installed, enabled, project };
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
if (request.verb === 'project.remote.get.console' || request.verb === 'project.remote.set.console') {
|
||||
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
|
||||
if (!this.#organization) fail('not-configured');
|
||||
@@ -5666,7 +5688,7 @@ export class Broker {
|
||||
if (["limits", "plugins", "instances", "routes", "organization.revision"].includes(request.verb)) keys(args, []);
|
||||
else if (request.verb === "plugins.set") {
|
||||
keys(args, ["revision", "plugins", "usage"], ["revision", "plugins"]);
|
||||
if (s.tester && object(args.plugins) && Object.hasOwn(args.plugins, 'model-provider')) fail('human-required');
|
||||
if (s.tester && object(args.plugins) && (Object.hasOwn(args.plugins, 'model-provider') || Object.hasOwn(args.plugins, 'system-type'))) fail('human-required');
|
||||
}
|
||||
// Row 165 (#1662): instances.set is human-only and outside ORGANIZATION_KEYS, so
|
||||
// organization.set and the chief of staff's tool never reach it (sage-0918).
|
||||
@@ -7566,6 +7588,25 @@ export class Broker {
|
||||
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null))
|
||||
.map((p) => (p.state === 'archive' ? { id: p.id, at: p.at, name: p.name, repo: p.repo, archived: true } : { id: p.id, at: p.at, name: p.name, repo: p.repo }));
|
||||
}
|
||||
case 'project.system-types':
|
||||
case 'project.system-types.set': {
|
||||
this.#human(s);
|
||||
const write = verb === 'project.system-types.set';
|
||||
keys(a, write ? ['project', 'systemTypes'] : ['project'], write ? ['project', 'systemTypes'] : ['project']);
|
||||
const project = identifier(a.project);
|
||||
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', project, s.business)) fail('project-not-found');
|
||||
// When configured, read the trusted file each time; a stale bus snapshot must not reactivate a disabled type.
|
||||
const enabled = this.#organization ? this.#organization(s.business, 'system-types.current', {}, s.human) : (b.plugins?.['system-type'] ?? []);
|
||||
if (!Array.isArray(enabled)) fail('not-configured');
|
||||
if (write) {
|
||||
if (!Array.isArray(a.systemTypes) || a.systemTypes.some(x => typeof x !== 'string') || new Set(a.systemTypes).size !== a.systemTypes.length) fail('invalid-request');
|
||||
if (a.systemTypes.some(x => !enabled.includes(x))) fail('system-type-not-enabled');
|
||||
this.#store.run('INSERT INTO project_system_types(at,business,project,types,by) VALUES(?,?,?,?,?)', this.#now(), s.business, project, JSON.stringify(a.systemTypes), this.#owner(s));
|
||||
}
|
||||
const row = this.#store.get('SELECT types FROM project_system_types WHERE business=? AND project=? ORDER BY seq DESC LIMIT 1', s.business, project);
|
||||
const selected = row ? JSON.parse(row.types) : [];
|
||||
return { project, selected, active: selected.filter(x => enabled.includes(x)), inactive: selected.filter(x => !enabled.includes(x)) };
|
||||
}
|
||||
// #797 MVP3 (owner report 2026-11-06): the owner's reversible retirement of a project. The
|
||||
// human gate is project.create's minus the tester carve-out: tester, user and agent sessions
|
||||
// are refused here. Each verb appends one project_archives row (the audit trail) and deletes
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Per-project selections are a human-authored append-only settings trail.
|
||||
CREATE TABLE project_system_types (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
project TEXT NOT NULL REFERENCES projects(id),
|
||||
types TEXT NOT NULL,
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX project_system_types_project ON project_system_types (business, project, seq);
|
||||
CREATE TRIGGER project_system_types_no_update BEFORE UPDATE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
|
||||
CREATE TRIGGER project_system_types_no_delete BEFORE DELETE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
|
||||
CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_types WHEN EXISTS (SELECT 1 FROM project_system_types WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
|
||||
CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types
|
||||
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
|
||||
import { createRequirementCatalog } from './requirement-catalog.mjs';
|
||||
import { readPrdMaterial } from '../../project/src/prd.mjs';
|
||||
import { lstatSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { lstatSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from './store.mjs';
|
||||
@@ -22,6 +22,7 @@ import { readSessions, sessionsFile, stopSession } from '../../seat/src/session.
|
||||
import { compactionCount, storeDir } from '../../seat/src/compactions.mjs';
|
||||
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
|
||||
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
|
||||
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
|
||||
import { pluginSettings } from '../../organization/src/plugin-settings.mjs';
|
||||
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
|
||||
import { ProxyPage } from './proxy-page.mjs';
|
||||
@@ -360,6 +361,11 @@ export async function startBroker({
|
||||
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
|
||||
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
|
||||
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
|
||||
// #1886 (F3 defect l): a store reference with no file yet reads as no key (design D3a), but Credentials
|
||||
// refuses a missing store root, and only issue() made it. Start makes it the same way issue() does when
|
||||
// any reference names the store. A root that exists with loose modes or as a symlink still refuses.
|
||||
if (storeRoot && Object.values(references).some(refs => Object.values(refs ?? {}).some(ref => ref?.store === true)))
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||||
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held,
|
||||
modelAgents: proxy ? Object.entries(businesses).flatMap(([id, b]) => Object.keys(b.roles).map(role => id + '/' + role)) : [] });
|
||||
if (proxy) {
|
||||
@@ -746,6 +752,8 @@ export async function startBroker({
|
||||
known.set(id, canonicalJson(next.projects));
|
||||
return getProjectRemote(writer, id, args.project);
|
||||
}
|
||||
if (verb === 'system-types.installed') return installedSystemTypes();
|
||||
if (verb === 'system-types.current') return settings.current(id).plugins?.['system-type'] ?? [];
|
||||
if(verb==='limits')return withTokens(id,settings.read(id));
|
||||
if(verb==='plugins')return {...plugins.read(id), available:{harness:levels.names ?? []}};
|
||||
// Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
|
||||
|
||||
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||
export const SCHEMA_SHA256 = '1cec88b0b697c648bcab18b45f86d85526aa1701a7619046367e16c38476aee8';
|
||||
export const SCHEMA_VERSION = '3t';
|
||||
export const SCHEMA_SHA256 = '6f6f0c0fd471b7895648a71aa9f320a769b145da792c4152ee8282006eff30c7';
|
||||
export const SCHEMA_VERSION = '3u';
|
||||
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
|
||||
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
|
||||
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
|
||||
@@ -112,6 +112,9 @@ const migrations = [{
|
||||
// and its guards; no existing row changes and nothing is deleted.
|
||||
from: '3s', to: '3t',
|
||||
sql: readFileSync(new URL('./migrations/3s-3t.sql', import.meta.url), 'utf8'),
|
||||
}, {
|
||||
from: '3t', to: '3u',
|
||||
sql: readFileSync(new URL('./migrations/3t-3u.sql', import.meta.url), 'utf8'),
|
||||
}];
|
||||
const timedTables = [
|
||||
'events',
|
||||
@@ -138,6 +141,7 @@ const timedTables = [
|
||||
'request_snapshot_events',
|
||||
'request_project_events',
|
||||
'project_archives',
|
||||
'project_system_types',
|
||||
];
|
||||
const hex = (value) => createHash('sha256').update(value).digest('hex');
|
||||
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
|
||||
|
||||
@@ -19,7 +19,7 @@ const here = (p) => new URL(p, import.meta.url);
|
||||
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
|
||||
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
|
||||
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
function source3h(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -94,6 +94,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
|
||||
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
|
||||
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
|
||||
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
@@ -101,7 +102,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
|
||||
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
|
||||
@@ -151,7 +152,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
// Still a whole 3h store: the real step migrates it.
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
});
|
||||
|
||||
@@ -163,14 +164,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
|
||||
schema: ddl3h,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
|
||||
assert.throws(() => new Store3h(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
|
||||
});
|
||||
@@ -35,7 +35,9 @@ const baselines = {
|
||||
// Row 239 (#1745): outcome-unknown-revoked joins OPEN (duckworth-0165 C1), and STATUS gets
|
||||
// revision-conflict-revoked 409 and outcome-unknown-revoked 503; no proof or transport change.
|
||||
// #1839: artifact Console replies alone may use 2 MiB; the proof and other limits are unchanged.
|
||||
"packages/webui/src/bus.mjs": "34fa64ffa17886d86243d7712fdb6f632c22b96412b642d785ddbc05fbd79692",
|
||||
// #1873 U1: humanCall, busCaller, userCall and userCaller take an optional signal and absolute
|
||||
// deadline; userCall shares transport(). Nonces, argv and limits are unchanged.
|
||||
"packages/webui/src/bus.mjs": "3ef8064dd7911f9eee4475b52c74a1d551583a065474b1bcee968cd45f722747",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -616,7 +616,7 @@ async function database3c(t) {
|
||||
let source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.notEqual(pinned, source);
|
||||
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
|
||||
@@ -640,7 +640,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
|
||||
const b = new Broker({ store, businesses: demo(), organization: null });
|
||||
|
||||
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
|
||||
const original = readFileSync(source);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
||||
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
|
||||
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
|
||||
const sha = createHash('sha256').update(futureDDL).digest('hex');
|
||||
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
|
||||
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3u';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3t', to: '3u', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3v';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3u', to: '3v', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql'])
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql'])
|
||||
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
|
||||
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
||||
const future = new FutureStore(root, { migrateSchema: true });
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
|
||||
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
|
||||
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
|
||||
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.match(migrated.stdout, /bus schema 3t ready/);
|
||||
assert.match(migrated.stdout, /bus schema 3u ready/);
|
||||
new Store(root).close();
|
||||
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 2);
|
||||
|
||||
@@ -89,7 +89,7 @@ function setup(t) {
|
||||
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
|
||||
// no unsuffixed verb is ever a console verb.
|
||||
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
|
||||
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
|
||||
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// scratch root only.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
@@ -18,7 +18,7 @@ const OTHER = 'fake_other_token_5555555555';
|
||||
const DATE = '2099-01-01';
|
||||
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
|
||||
async function setup(t) {
|
||||
async function setup(t, { storeRoot = true } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
@@ -26,7 +26,7 @@ async function setup(t) {
|
||||
doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE };
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
|
||||
if (storeRoot) mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
|
||||
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
|
||||
// The bus's resolved business carries the same references; a store one names its derived file.
|
||||
const demo = {
|
||||
@@ -161,3 +161,29 @@ test('T5: issue, rotate, revoke and delete are live, rewrite the business file a
|
||||
for (const path of walk(c.root))
|
||||
for (const token of [TOKEN, ROTATED, OTHER]) assert.equal(readFileSync(path).includes(token), false, path);
|
||||
});
|
||||
|
||||
test('#1886: a store reference with no store root yet starts the bus, makes the root private and issues', async (t) => {
|
||||
const c = await setup(t, { storeRoot: false });
|
||||
const dir = join(c.root, 'secrets', 'agents');
|
||||
assert.equal(statSync(dir).mode & 0o777, 0o700);
|
||||
assert.equal(statSync(join(c.root, 'secrets')).mode & 0o077, 0);
|
||||
assert.deepEqual(c.status('coder'), { gitea: 'not-issued', vikunja: 'valid' });
|
||||
const issued = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN + '\n', date: DATE });
|
||||
assert.deepEqual(issued, { agent: 'coder', verb: 'issue', service: 'gitea', date: DATE, state: 'valid' });
|
||||
assert.equal(statSync(c.store('coder', 'gitea')).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
test('#1886: a store root that exists with loose modes still refuses start', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-cred-loose-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o750 });
|
||||
chmodSync(join(root, 'secrets', 'agents'), 0o750);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: {},
|
||||
roles: { coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
|
||||
};
|
||||
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/);
|
||||
});
|
||||
@@ -36,11 +36,11 @@ function source3j(t) {
|
||||
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
|
||||
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
|
||||
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
function rows(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives')").all()
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types')").all()
|
||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
|
||||
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
|
||||
} finally { db.close(); }
|
||||
@@ -84,6 +84,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
|
||||
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
|
||||
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
|
||||
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -105,7 +106,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
|
||||
const { root, path } = source3j(t), before = rows(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
assert.deepEqual(migrated3l(rows(path)), before);
|
||||
const now = master(path);
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
||||
@@ -131,7 +132,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
});
|
||||
|
||||
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
@@ -142,14 +143,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
|
||||
schema: ddl3j,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
|
||||
assert.throws(() => new Store3j(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1',
|
||||
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
|
||||
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
|
||||
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
function source3i(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -42,7 +42,7 @@ function source3i(t) {
|
||||
function rows(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives')").all()
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types')").all()
|
||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
|
||||
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
|
||||
} finally { db.close(); }
|
||||
@@ -87,6 +87,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
|
||||
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
|
||||
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
|
||||
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -110,7 +111,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
|
||||
const { root, path } = source3i(t), before = rows(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
assert.deepEqual(migrated3l(rows(path)), before);
|
||||
const now = master(path).filter(r => !LATER.includes(r.name));
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
||||
@@ -135,7 +136,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
});
|
||||
|
||||
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
@@ -146,14 +147,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
|
||||
schema: ddl3i,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
|
||||
assert.throws(() => new Store3i(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
|
||||
|
||||
@@ -198,6 +198,7 @@ test('3p through 3s migration preserves existing request rows and the append-onl
|
||||
const path = join(root, 'bus/bus.sqlite'), ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
|
||||
// 3s-3t (#797) adds project_archives; the 3p fixture predates it and the replayed chain recreates it.
|
||||
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(",'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced'", '');
|
||||
const db = new DatabaseSync(path); db.exec(ddl);
|
||||
const at = '2026-11-03T00:00:00.000Z';
|
||||
@@ -213,7 +214,7 @@ test('3p through 3s migration preserves existing request rows and the append-onl
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true }); t.after(() => store.close());
|
||||
assert.deepEqual(store.all('SELECT * FROM request_events'), before);
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')", at);
|
||||
assert.throws(() => store.run('DELETE FROM request_events'), /append-only/);
|
||||
assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/);
|
||||
|
||||
@@ -143,7 +143,7 @@ const here = (p) => new URL(p, import.meta.url);
|
||||
const migrationSQL = readFileSync(here('../src/migrations/3s-3t.sql'), 'utf8');
|
||||
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
|
||||
assert.equal(currentDDL.split(migrationSQL).length, 2, 'schema.sql holds the 3s→3t step exactly once');
|
||||
const ddl3s = currentDDL.replace(migrationSQL, '');
|
||||
const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), '');
|
||||
|
||||
function source3s(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'project-archive-3s-'));
|
||||
@@ -178,7 +178,7 @@ test('a 3s store migrates to 3t: project_archives is new and empty, every row st
|
||||
assert.deepEqual(master(path), was, 'the refused open changed nothing');
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(store.get("SELECT id FROM events").id, 'preserved');
|
||||
assert.equal(store.get("SELECT name FROM projects").name, 'kept');
|
||||
assert.deepEqual(store.all('SELECT seq,at,business,project,op,by FROM project_archives'), []);
|
||||
@@ -211,7 +211,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3s).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3s';")
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3s';")
|
||||
.replace(/\}, \{\n {2}\/\/ #797 MVP3[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3s';") && edited.includes('3r-3s') && !edited.includes('3s-3t'), 'the 3s engine has no 3s→3t step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -221,7 +221,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
|
||||
const Store3s = (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
assert.throws(() => new Store3s(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3s(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
assert.deepEqual(master(path), was);
|
||||
// The same engine opens a fresh 3s store, so the refusal is the version's and not a broken copy.
|
||||
const { root: old } = source3s(t);
|
||||
|
||||
@@ -90,7 +90,7 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
|
||||
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
|
||||
|
||||
test('the console verb list includes the artifact read and existing owner controls', () => {
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
|
||||
});
|
||||
|
||||
@@ -302,7 +302,7 @@ test('a cancel is refused when the owner-to-PM edge closed, with no withdrawn de
|
||||
});
|
||||
|
||||
test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => {
|
||||
assert.equal(SCHEMA_VERSION, '3t');
|
||||
assert.equal(SCHEMA_VERSION, '3u');
|
||||
const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
||||
const path = join(root, 'bus/bus.sqlite'), AT = '2026-11-03T00:00:00.000Z';
|
||||
@@ -311,7 +311,7 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
|
||||
// the replayed chain recreates it, so the step's text is removed here like the kinds are.
|
||||
const step3t = readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8');
|
||||
// 3p-3q shipped the PRD kinds and mode.changed; 3q-3r adds the three Task 3 kinds after them.
|
||||
const ddl3q = current.replace(step3t, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
|
||||
const ddl3q = current.replace(step3t, '').replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
|
||||
assert.notEqual(ddl3q, current, 'the fixture is the narrower schema');
|
||||
const migration = readFileSync(new URL('../src/migrations/3q-3r.sql', import.meta.url), 'utf8');
|
||||
assert.ok(migration.includes("'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'"), 'the step carries every 3q kind plus its own');
|
||||
@@ -328,7 +328,7 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n');
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n');
|
||||
const store = new Store(root);
|
||||
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes, including the 3q kinds');
|
||||
const fresh = new DatabaseSync(':memory:'); fresh.exec(current);
|
||||
|
||||
@@ -230,6 +230,7 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
|
||||
// 3s-3t (#797) adds project_archives; the 3r fixture predates it and the replayed chain recreates it.
|
||||
db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
|
||||
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(",'advanced'", ''));
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3r');
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
|
||||
@@ -250,7 +251,7 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
|
||||
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before);
|
||||
assert.deepEqual(store.all("SELECT name,sql FROM sqlite_master WHERE type='trigger' AND tbl_name IN ('request_events','request_effects') ORDER BY name"), guards);
|
||||
assert.equal(store.get("SELECT name FROM sqlite_master WHERE type='index' AND name='request_events_request'").name, 'request_events_request');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',16,'advanced',?,'{}')", at);
|
||||
assert.equal(store.get("SELECT max(seq) n FROM request_events").n, 42);
|
||||
assert.throws(() => store.run("UPDATE request_events SET kind='opened'"), /append-only/);
|
||||
|
||||
@@ -278,6 +278,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
|
||||
// 3s-3t (#797) adds project_archives after the request tables; the pre-3t fixtures below
|
||||
// predate it and the replayed chain recreates it, so the step's text comes out here too.
|
||||
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
|
||||
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
|
||||
const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8');
|
||||
assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length);
|
||||
@@ -290,7 +291,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
|
||||
db.close(); chmodSync(path, 0o600); const before = readFileSync(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before);
|
||||
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n'); assert.equal(SCHEMA_VERSION, '3t');
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n'); assert.equal(SCHEMA_VERSION, '3u');
|
||||
const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved');
|
||||
for (const table of ['request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0);
|
||||
store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0);
|
||||
@@ -313,6 +314,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
|
||||
// The 3o schema is the current one with the narrower kind list.
|
||||
const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
|
||||
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
|
||||
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
|
||||
const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z';
|
||||
const db = new DatabaseSync(path); db.exec(ddl3o);
|
||||
@@ -329,7 +331,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n');
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n');
|
||||
const store = new Store(root);
|
||||
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes');
|
||||
assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects);
|
||||
|
||||
@@ -45,7 +45,7 @@ test('requirements returns the committed list to a holding role session, and no
|
||||
const pm = agent('pm');
|
||||
const list = call(b, pm, 'requirements');
|
||||
assert.deepEqual(list, loadRequirements());
|
||||
assert.equal(list.requirements.length, 101);
|
||||
assert.equal(list.requirements.length, 118);
|
||||
assert.deepEqual(call(b, agent('coder'), 'requirements'), list);
|
||||
assert.throws(() => call(b, human, 'requirements'), /agent-required/);
|
||||
assert.throws(() => call(b, reader, 'requirements'), /read-only/);
|
||||
|
||||
@@ -527,7 +527,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
|
||||
// #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards.
|
||||
// Task 4 (3p to 3q), Task 3 (3q to 3r) and Task 6b (3r to 3s) widen request_events.kind in place.
|
||||
// #797 MVP3 (3s to 3t) adds project_archives and its five guards; project-archive.test.mjs tests them.
|
||||
assert.equal(count('table') - 1, 31);
|
||||
assert.equal(count('trigger'), 119);
|
||||
// 3t to 3u adds project_system_types and four guards.
|
||||
assert.equal(count('table') - 1, 32);
|
||||
assert.equal(count('trigger'), 123);
|
||||
assert.equal(count('view'), 5);
|
||||
});
|
||||
@@ -530,9 +530,9 @@ test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are
|
||||
const before = tables(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
const after = tables(path);
|
||||
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives']) {
|
||||
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types']) {
|
||||
assert.deepEqual(after[name], [], name);
|
||||
delete after[name];
|
||||
}
|
||||
@@ -571,7 +571,7 @@ async function engine3m(root) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3m';")
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3m';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -588,7 +588,7 @@ test('T4 schema: the 3m engine refuses a 3n store, migrating or not, and leaves
|
||||
const Store3m = await engine3m(root);
|
||||
assert.throws(() => new Store3m(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(tables(path), before);
|
||||
// The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
|
||||
|
||||
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
|
||||
const { root, path } = source3k(t);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
// The migrated schema is the fresh one, object for object.
|
||||
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
|
||||
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
||||
@@ -430,7 +430,7 @@ async function engine3k(root) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
|
||||
const Store3k = await engine3k(root);
|
||||
assert.throws(() => new Store3k(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
|
||||
|
||||
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
|
||||
|
||||
test('project selections require enabled types, retain inactive selections, and only the owner writes', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'sys-'));
|
||||
const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: [], crossRole: [] } } }, projects: {}, plugins: { 'system-type': ['coding'] }, vars: { 'bus.tester': true } };
|
||||
const broker = new Broker({ store, businesses: { demo: business } });
|
||||
const owner = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const tester = broker.bindTester({ business: 'demo' });
|
||||
const agent = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
const call = (verb, args = {}, cap = owner) => broker.request(cap, { verb, args });
|
||||
const project = call('project.create', { name: 'example' }).id;
|
||||
assert.deepEqual(call('project.system-types', { project }), { project, selected: [], active: [], inactive: [] });
|
||||
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['montage'] }), /system-type-not-enabled/);
|
||||
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['coding'] }, tester), /tester-refused/);
|
||||
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['coding'] }, agent), /human-required/);
|
||||
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['coding', 'coding'] }), /invalid-request/);
|
||||
call('project.system-types.set', { project, systemTypes: ['coding'] });
|
||||
assert.deepEqual(call('project.system-types', { project }).active, ['coding']);
|
||||
broker.reloadBusiness('demo', { ...business, plugins: { 'system-type': [] } });
|
||||
assert.deepEqual(call('project.system-types', { project }), { project, selected: ['coding'], active: [], inactive: ['coding'] });
|
||||
assert.deepEqual(store.all('SELECT by FROM project_system_types').map(r => r.by), ['jason']);
|
||||
assert.throws(() => store.run("UPDATE project_system_types SET types='[]'"), /append-only/);
|
||||
assert.throws(() => store.run('DELETE FROM project_system_types'), /append-only/);
|
||||
assert.throws(() => store.run("INSERT INTO project_system_types(at,business,project,types,by) VALUES(?,?,?,?,?)", new Date().toISOString(), 'other', project, '[]', 'jason'), /belongs to its project/);
|
||||
});
|
||||
|
||||
test('console owner can read system types but cannot write project selections', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'sys-'));
|
||||
const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: [], crossRole: [] } } }, projects: {}, plugins: { 'system-type': ['coding'] }, vars: { 'bus.tester': true } };
|
||||
let discoveries = 0, enabled = ['coding'];
|
||||
const broker = new Broker({ store, businesses: { demo: business }, organization: (_id, verb) => { if (verb === 'system-types.current') return enabled; if (verb === 'system-types.installed') { discoveries++; return installedSystemTypes(); } } });
|
||||
const owner = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const consoleOwner = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
|
||||
const tester = broker.bindTester({ business: 'demo' });
|
||||
assert.throws(() => broker.request(tester, { verb: 'plugins.set', args: { revision: 'a'.repeat(64), plugins: { 'system-type': ['coding'] } } }), /human-required/);
|
||||
const project = broker.request(owner, { verb: 'project.create', args: { name: 'example' } }).id;
|
||||
const result = await broker.request(consoleOwner, { verb: 'system-types.console', args: { project } });
|
||||
assert.deepEqual(result.enabled, ['coding']);
|
||||
assert.equal(result.installed.length, 5);
|
||||
assert.equal(discoveries, 1);
|
||||
broker.request(owner, { verb: 'project.system-types.set', args: { project, systemTypes: ['coding'] } });
|
||||
enabled = [];
|
||||
assert.deepEqual(broker.request(owner, { verb: 'project.system-types', args: { project } }).inactive, ['coding']);
|
||||
assert.throws(() => broker.request(owner, { verb: 'project.system-types.set', args: { project, systemTypes: ['coding'] } }), /system-type-not-enabled/);
|
||||
assert.deepEqual(result.project, { project, selected: [], active: [], inactive: [] });
|
||||
assert.throws(() => broker.request(consoleOwner, { verb: 'project.system-types.set', args: { project, systemTypes: ['coding'] } }), /human-required/);
|
||||
});
|
||||
|
||||
test('3t store migrates offline without altering existing project rows', async t => {
|
||||
const { DatabaseSync } = await import('node:sqlite');
|
||||
const { readFileSync, mkdirSync, chmodSync } = await import('node:fs');
|
||||
const { schemaDigest } = await import('../src/store.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'sys-migration-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
||||
const dbpath = join(root, 'bus/bus.sqlite');
|
||||
const db = new DatabaseSync(dbpath);
|
||||
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||
const step = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
|
||||
assert.equal(ddl.split(step).length, 2);
|
||||
db.exec(ddl.replace(step, ''));
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3t');
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', '2026-01-01T00:00:00.000Z');
|
||||
db.prepare('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)').run('old', '2026-01-01T00:00:00.000Z', 'demo', 'old', null, 'jason');
|
||||
db.close(); chmodSync(dbpath, 0o600);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const migrated = new Store(root, { migrateSchema: true }); t.after(() => migrated.close());
|
||||
assert.equal(migrated.get('SELECT name FROM projects WHERE id=?', 'old').name, 'old');
|
||||
assert.equal(migrated.get('SELECT count(*) AS n FROM project_system_types').n, 0);
|
||||
assert.equal(migrated.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
});
|
||||
|
||||
test('runtime reads the current organization file for project selections and console discovery', async t => {
|
||||
const { mkdirSync } = await import('node:fs');
|
||||
const { startBroker } = await import('../src/runtime.mjs');
|
||||
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'sys-runtime-'));
|
||||
const rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { 'system-type': ['coding'] };
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: business }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const consoleOwner = bus.broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
|
||||
const call = (verb, args = {}, cap = human) => bus.broker.request(cap, { verb, args });
|
||||
const project = call('project.create', { name: 'example' }).id;
|
||||
call('project.system-types.set', { project, systemTypes: ['coding'] });
|
||||
const view = await call('system-types.console', { project }, consoleOwner);
|
||||
assert.deepEqual(view.enabled, ['coding']);
|
||||
assert.deepEqual(view.project.active, ['coding']);
|
||||
assert.equal(view.installed.length, 5);
|
||||
const plugins = call('plugins');
|
||||
call('plugins.set', { revision: plugins.revision, plugins: { 'system-type': [] } });
|
||||
assert.deepEqual(call('project.system-types', { project }).inactive, ['coding']);
|
||||
});
|
||||
@@ -49,7 +49,7 @@ async function database3d(t) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
|
||||
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
|
||||
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
|
||||
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
|
||||
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3t ready', ''].join('\n'));
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3u ready', ''].join('\n'));
|
||||
});
|
||||
@@ -403,12 +403,12 @@ test('a 3l store migrates to 3m: thread_shares is new and empty, and every other
|
||||
const before = tables(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
const after = tables(path);
|
||||
assert.deepEqual(after.thread_shares, []);
|
||||
delete after.thread_shares;
|
||||
// Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too.
|
||||
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives']) {
|
||||
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types']) {
|
||||
assert.deepEqual(after[name], [], name);
|
||||
delete after[name];
|
||||
}
|
||||
@@ -439,7 +439,7 @@ async function engine3l(root) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3l';")
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3l';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -456,7 +456,7 @@ test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it wa
|
||||
const Store3l = await engine3l(root);
|
||||
assert.throws(() => new Store3l(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3t');
|
||||
assert.equal(version(path), '3u');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(tables(path), before);
|
||||
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
|
||||
|
||||
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
|
||||
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
|
||||
|
||||
@@ -1289,3 +1289,12 @@ first attempt. Completed moves are not rolled back. A held lifecycle lock can
|
||||
mean a bus start is still in progress; if a process crashed and left its lock,
|
||||
inspect it before removing it and resuming. Moves use rename and therefore
|
||||
require the source and deleted folder to be on the same filesystem.
|
||||
|
||||
System types are installed plug-ins. `mosaic org system-types installed` prints
|
||||
module metadata; `mosaic org system-types enabled ID` prints an organization's enabled
|
||||
ids. The owner changes the enabled list through the existing organization file
|
||||
update or `mosaic plugins set`. `mosaic project system-types PROJECT --org ID`
|
||||
shows selected, active and inactive ids. The owner replaces the selection with
|
||||
`mosaic project system-types-set PROJECT --types coding,montage --org ID`; an
|
||||
empty `--types ''` clears it. Disabled selections stay recorded but become
|
||||
inactive. Project settings changes are append-only and require human proof.
|
||||
@@ -74,7 +74,7 @@ export function readAccounting({ dataRoot,business,start,end }) {
|
||||
const db = new DatabaseSync(join(dataRoot,'bus/bus.sqlite'),{readOnly:true});
|
||||
try {
|
||||
db.exec('BEGIN');
|
||||
if (!['3s','3t'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
|
||||
if (!['3s','3t','3u'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
|
||||
const store = { all:(sql,...args) => db.prepare(sql).all(...args) };
|
||||
const records = turnRecords(store,business);
|
||||
const raw = store.all("SELECT * FROM usage_records WHERE business=? AND source<>'@proxy/cliproxy-queue' ORDER BY seq",business);
|
||||
|
||||
@@ -79,7 +79,7 @@ test('accounting reads a real store at the current bus schema version',t=>{
|
||||
const records=readAccounting({dataRoot:f.dataRoot,business:f.c.business,start:f.c.start,end:f.c.end});
|
||||
assert.equal(records.length,1);assert.equal(records[0].input,1);
|
||||
});
|
||||
for(const version of ['3s','3r','3u',null]) test(`accounting ${version==='3s'?'accepts':'refuses'} schema ${version??'missing'}`,t=>{
|
||||
for(const version of ['3s','3r','3v',null]) test(`accounting ${version==='3s'?'accepts':'refuses'} schema ${version??'missing'}`,t=>{
|
||||
const f=setup(t,100);
|
||||
// Only this disposable fixture is altered; the production meta table is append-only.
|
||||
f.store.run('DROP TRIGGER meta_no_update');f.store.run('DROP TRIGGER meta_no_delete');
|
||||
|
||||
@@ -43,7 +43,7 @@ import { SeatError } from "../../seat/src/seat.mjs";
|
||||
import { OrgError, resolveOrg, withOrgLifecycle } from "../../organization/src/org-registry.mjs";
|
||||
import { orgSelector } from "./org-selector.mjs";
|
||||
import { busStart, busStatus, busStop, busRestart, busRecover } from "./org-bus.mjs";
|
||||
import { consoleStart, consoleStatus, consoleStop } from "./org-console.mjs";
|
||||
import { consoleRestart, consoleStart, consoleStatus, consoleStop } from "./org-console.mjs";
|
||||
|
||||
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
|
||||
import { orgCommand, ORG_USAGE } from "./org.mjs";
|
||||
@@ -91,6 +91,7 @@ export const USAGE = `usage:
|
||||
mosaic bus status <id> [--json]
|
||||
mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
|
||||
mosaic console stop|status
|
||||
mosaic console restart
|
||||
${DEV_USAGE}
|
||||
${RUN_USAGE}`;
|
||||
|
||||
@@ -571,6 +572,14 @@ async function consoleCommand(rest, io, deps) {
|
||||
io.stdout.write(`${JSON.stringify(consoleStatus(organizationContext(io, deps), { warn: s => io.stderr.write(s) }))}\n`);
|
||||
return;
|
||||
}
|
||||
// U1 (#1873): the F3 Settings control from a terminal. The running Console applies its own owner
|
||||
// and trail checks (org-console.mjs consoleRestart); an agent run is refused before any read.
|
||||
if (sub === "restart") {
|
||||
if (args.length) throw usage("console restart takes no arguments");
|
||||
refuseInsideAgent(io.env);
|
||||
io.stdout.write(`${JSON.stringify(await consoleRestart(organizationContext(io, deps), deps.consoleRequest ?? {}))}\n`);
|
||||
return;
|
||||
}
|
||||
if (sub !== "start") throw usage();
|
||||
const parsed = parse(args, { flags: ["--dev-local-owner", "--detach"], values: ["--port", "--dev-insecure-bind", "--dev-allow-host"] });
|
||||
if (parsed.positional.length) throw usage("console start takes no positional arguments");
|
||||
@@ -583,7 +592,13 @@ async function consoleCommand(rest, io, deps) {
|
||||
const receipt = await consoleStart(organizationContext(io, deps), { devLocalOwner: devOwner, detach: parsed.flags.has("--detach"), port: parsed.values["--port"] ?? "auto", ...(lan ? { bind: lan.bind, allowHosts: lan.allowHosts } : {}), warn: s => io.stderr.write(s) });
|
||||
io.stdout.write(`${JSON.stringify(receipt)}\n`);
|
||||
if (!parsed.flags.has("--detach")) {
|
||||
const code = await receipt.done;
|
||||
// F3: this process supervises the Console. A stop signal (the unit's SIGTERM, Ctrl-C) goes to
|
||||
// the Console and ends supervision cleanly; a restart the Console asks for is relaunched here.
|
||||
const stop = () => receipt.stop();
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, stop);
|
||||
let code;
|
||||
try { code = await receipt.done; }
|
||||
finally { for (const signal of ["SIGINT", "SIGTERM"]) process.off(signal, stop); }
|
||||
if (code !== 0) throw new CliError("console stopped unexpectedly", code);
|
||||
}
|
||||
}
|
||||
@@ -712,7 +727,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
||||
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
|
||||
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
|
||||
if (verb === "project") return ["create", "list", "archive", "unarchive"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
|
||||
if (verb === "project") return ["create", "list", "archive", "unarchive", "system-types", "system-types-set"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
|
||||
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
|
||||
if (verb === "session") return sessionCommand(rest, io, (p) => channelCallFor(p, io, deps));
|
||||
if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
|
||||
|
||||
@@ -43,12 +43,17 @@ function checkedState(org) {
|
||||
if (s.host && !lock) fail('bus host lock is missing');
|
||||
return s;
|
||||
}
|
||||
function answer(path) {
|
||||
export function probeBusSocket(path, { signal, deadline } = {}) {
|
||||
return new Promise(resolve => {
|
||||
const remaining = deadline === undefined ? 1000 : Math.min(1000, deadline - Date.now());
|
||||
if (signal?.aborted || remaining <= 0) return resolve(false);
|
||||
let done = false, data = '';
|
||||
const socket = connect(path);
|
||||
const finish = value => { if (done) return; done = true; socket.destroy(); resolve(value); };
|
||||
socket.setTimeout(1000, () => finish(false));
|
||||
const abort = () => finish(false);
|
||||
const finish = value => { if (done) return; done = true; signal?.removeEventListener('abort', abort); socket.destroy(); resolve(value); };
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) return abort();
|
||||
socket.setTimeout(remaining, () => finish(false));
|
||||
socket.on('error', () => finish(false));
|
||||
socket.on('connect', () => socket.write('{}\n'));
|
||||
socket.on('data', chunk => {
|
||||
@@ -62,9 +67,9 @@ function answer(path) {
|
||||
socket.on('end', () => finish(false));
|
||||
});
|
||||
}
|
||||
export async function busStatus(ctx, id) {
|
||||
export async function busStatus(ctx, id, bound) {
|
||||
const org = await target(ctx, id), state = checkedState(org);
|
||||
const healthy = Boolean(state.host?.live && state.socket && await answer(org.paths.socket));
|
||||
const healthy = Boolean(state.host?.live && state.socket && await probeBusSocket(org.paths.socket, bound));
|
||||
return { org: id, instance: ctx.instance, root: org.root, socket: org.paths.socket, healthy, pid: state.host?.pid ?? null, boot: readBootStatus(org.root) };
|
||||
}
|
||||
export async function busStop(ctx, id) {
|
||||
@@ -123,7 +128,7 @@ export async function busStart(ctx, id, { detach = false, devLocalOwner = false,
|
||||
while (Date.now() < deadline && !exited) {
|
||||
// During boot the host holds its lock before its state is published.
|
||||
const state = readHostState(org.root);
|
||||
if (ready && state?.live && state.pid === child.pid && state.business === id && await answer(org.paths.socket)) {
|
||||
if (ready && state?.live && state.pid === child.pid && state.business === id && await probeBusSocket(org.paths.socket)) {
|
||||
child.disconnect();
|
||||
if (detach) child.unref();
|
||||
const receipt = { org: id, pid: child.pid, socket: org.paths.socket, root: org.root };
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { processState } from '../../seat/src/process-state.mjs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -64,29 +65,22 @@ function takeLock(ctx) {
|
||||
let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); }
|
||||
return () => { closeSync(fd); rmSync(path, { force: true }); };
|
||||
}
|
||||
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
|
||||
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
|
||||
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
|
||||
const value = port === 'auto' ? 0 : Number(port);
|
||||
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
|
||||
let lan;
|
||||
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
|
||||
const release = takeLock(ctx);
|
||||
let child = null, done = null;
|
||||
// The exit code a supervised Console uses to ask for a relaunch (EX_TEMPFAIL). Only the Console's
|
||||
// own restart route (POST /api/console/restart, a human action) exits with it.
|
||||
export const RESTART_EXIT = 75;
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
let lastStamp = 0;
|
||||
// One Console process: spawn it, wait for the URL it prints, check the address and port.
|
||||
async function launch(ctx, args, lan, detach) {
|
||||
lastStamp = Math.max(Date.now(), lastStamp + 1);
|
||||
const log = join(directory(ctx), `console-${process.pid}-${lastStamp}.log`);
|
||||
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
let child;
|
||||
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
|
||||
finally { closeSync(fd); }
|
||||
const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
|
||||
let exited = false; done.then(() => { exited = true; });
|
||||
try {
|
||||
const prior = state(ctx);
|
||||
if (prior && !removeStale(ctx, prior, warn)) fail('console state exists; stop or inspect the console first');
|
||||
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
|
||||
if (org) await resolveOrg(ctx, org.id);
|
||||
// The pre-S5 Console still accepts a business preselection.
|
||||
const args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []),
|
||||
...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : [])];
|
||||
const log = join(directory(ctx), `console-${process.pid}-${Date.now()}.log`);
|
||||
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
|
||||
finally { closeSync(fd); }
|
||||
done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
|
||||
let exited = false; done.then(() => { exited = true; });
|
||||
const deadline = Date.now() + 30000;
|
||||
while (Date.now() < deadline && !exited) {
|
||||
const text = readFileSync(log, 'utf8');
|
||||
@@ -97,22 +91,99 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false,
|
||||
if (forbidden.has(Number(match[3]))) fail('console selected a forbidden port');
|
||||
const startTime = startTimeOf(child.pid);
|
||||
if (!startTime) fail('console exited during startup');
|
||||
writeFileSync(stateFile(ctx), JSON.stringify({ pid: child.pid, startTime, url: match[1], ...(lan ?? {}) }) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
if (detach) child.unref();
|
||||
// The child's stderr goes to the log; the person who started it sees the warning here.
|
||||
if (lan) warn(lanWarning(lan.bind, match[3]));
|
||||
const result = { url: match[1], pid: child.pid, ...(lan ?? {}) };
|
||||
Object.defineProperty(result, 'done', { value: done });
|
||||
return result;
|
||||
return { child, done, url: match[1], port: match[3], startTime };
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
await sleep(50);
|
||||
}
|
||||
fail(`console failed to start; see ${log}`);
|
||||
} catch (e) {
|
||||
if (child && child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
|
||||
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
const record = (run, lan) => JSON.stringify({ pid: run.child.pid, startTime: run.startTime, url: run.url, ...(lan ?? {}) }) + '\n';
|
||||
// F3: a relaunch keeps the arguments and the address. The state file must still name the process
|
||||
// that asked for the restart; a stop that ran in between wins, and nothing is relaunched.
|
||||
async function relaunch(ctx, old, args, lan) {
|
||||
const next = args.map((a, i) => (args[i - 1] === '--port' ? old.port : a));
|
||||
let release;
|
||||
for (let n = 0; !release; n++) {
|
||||
try { release = takeLock(ctx); } catch (e) { if (n >= 50) throw e; await sleep(100); }
|
||||
}
|
||||
try {
|
||||
const s = state(ctx);
|
||||
if (!s || s.pid !== old.child.pid || s.startTime !== old.startTime || s.url !== old.url) fail('console state changed during restart');
|
||||
let fresh;
|
||||
// The old listener has closed; a refused bind is retried briefly.
|
||||
for (let n = 0; !fresh; n++) {
|
||||
try { fresh = await launch(ctx, next, lan, false); } catch (e) { if (n >= 4) throw e; await sleep(500); }
|
||||
}
|
||||
try {
|
||||
if (fresh.url !== old.url) fail('console restarted on another address');
|
||||
const tmp = `${stateFile(ctx)}.tmp-${randomBytes(4).toString('hex')}`;
|
||||
writeFileSync(tmp, record(fresh, lan), { mode: 0o600, flag: 'wx' });
|
||||
renameSync(tmp, stateFile(ctx));
|
||||
} catch (e) { fresh.child.kill('SIGTERM'); await fresh.done; throw e; }
|
||||
return fresh;
|
||||
} finally { release(); }
|
||||
}
|
||||
// Foreground supervision: relaunch on RESTART_EXIT, end on any other exit. `stop` forwards a
|
||||
// stop signal (the unit's SIGTERM, a terminal's Ctrl-C) to the Console; a stop is a clean end.
|
||||
function supervise(ctx, first, args, lan, warn) {
|
||||
let current = first, stopping = false;
|
||||
const stop = () => {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
const child = current.child;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }, 15000).unref();
|
||||
};
|
||||
const done = (async () => {
|
||||
for (;;) {
|
||||
const code = await current.done;
|
||||
if (stopping) return 0;
|
||||
if (code !== RESTART_EXIT) return code;
|
||||
warn('mosaic: console asked for a restart; relaunching on the same arguments\n');
|
||||
try { current = await relaunch(ctx, current, args, lan); }
|
||||
catch (e) { warn(`mosaic: console restart failed: ${e.message}\n`); return 1; }
|
||||
if (stopping) { stopping = false; stop(); await current.done; return 0; }
|
||||
}
|
||||
})();
|
||||
return { done, stop };
|
||||
}
|
||||
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
|
||||
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
|
||||
// In the foreground the Console runs with --supervised: its restart route exits RESTART_EXIT and
|
||||
// this process relaunches it in place (F3). A detached Console has no supervisor.
|
||||
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
|
||||
const value = port === 'auto' ? 0 : Number(port);
|
||||
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
|
||||
let lan;
|
||||
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
|
||||
const release = takeLock(ctx);
|
||||
let run, args;
|
||||
try {
|
||||
const prior = state(ctx);
|
||||
if (prior && !removeStale(ctx, prior, warn)) fail('console state exists; stop or inspect the console first');
|
||||
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
|
||||
if (org) await resolveOrg(ctx, org.id);
|
||||
// The pre-S5 Console still accepts a business preselection.
|
||||
args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []),
|
||||
...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : []), ...(detach ? [] : ['--supervised'])];
|
||||
run = await launch(ctx, args, lan, detach);
|
||||
try { writeFileSync(stateFile(ctx), record(run, lan), { mode: 0o600, flag: 'wx' }); }
|
||||
catch (e) { run.child.kill('SIGTERM'); await run.done; throw e; }
|
||||
} finally { release(); }
|
||||
if (detach) run.child.unref();
|
||||
// The child's stderr goes to the log; the person who started it sees the warning here.
|
||||
if (lan) warn(lanWarning(lan.bind, run.port));
|
||||
const result = { url: run.url, pid: run.child.pid, ...(lan ?? {}) };
|
||||
const supervision = detach ? { done: run.done, stop: () => run.child.kill('SIGTERM') } : supervise(ctx, run, args, lan, warn);
|
||||
Object.defineProperty(result, 'done', { value: supervision.done });
|
||||
Object.defineProperty(result, 'stop', { value: supervision.stop });
|
||||
return result;
|
||||
}
|
||||
export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } = {}) {
|
||||
const release = takeLock(ctx);
|
||||
try {
|
||||
@@ -149,3 +220,79 @@ export function consoleStatus(ctx, { warn = s => process.stderr.write(s) } = {})
|
||||
if (running && s.bind) warn(lanWarning(bind, port));
|
||||
return { running, ...(running ? {} : { stale: true }), pid: s.pid, url: s.url, bind, ...(s.allowHosts ? { allowHosts: s.allowHosts } : {}) };
|
||||
}
|
||||
|
||||
// U1 (#1873): `mosaic console restart` and `mosaic org reload ID` are the F3 page controls from a
|
||||
// terminal. Each one is a request to the running Console named by console.json, sent as the page
|
||||
// sends it: the Console's own Host and Origin, Sec-Fetch-Site: same-origin and a JSON `{}` body.
|
||||
// The Console's route applies its owner, --send and origin checks and writes the action trail;
|
||||
// nothing here writes the trail, signals a process or touches a bus. The CLI verbs refuse inside
|
||||
// an agent run before they get here (transport.mjs refuseInsideAgent).
|
||||
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
function consoleTarget(ctx) {
|
||||
const s = state(ctx);
|
||||
if (!s) fail('no Console runs for this instance; start one with mosaic console start');
|
||||
if (!liveConsole(s)) fail('the Console in console state is not running; see mosaic console status');
|
||||
const url = /^http:\/\/([0-9.]+):(\d+)\/$/.exec(s.url);
|
||||
if (!url) fail('console state is invalid');
|
||||
const port = Number(url[2]);
|
||||
if (forbidden.has(port)) fail('console state names a forbidden port');
|
||||
// A LAN Console on 0.0.0.0 answers on loopback too; one bound to an address answers only there.
|
||||
const address = s.bind && s.bind !== '0.0.0.0' ? s.bind : '127.0.0.1';
|
||||
return { address, port, host: `${address}:${port}` };
|
||||
}
|
||||
function consoleCall(target, { method = 'GET', path, timeoutMs }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': 2 } : {}) };
|
||||
let sent = false;
|
||||
const req = httpRequest({ host: target.address, port: target.port, method, path, headers, timeout: timeoutMs }, res => {
|
||||
let text = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', chunk => { text += chunk; if (text.length > 65536) req.destroy(new Error('the Console answer is too large')); });
|
||||
res.on('end', () => { let body = null; try { body = JSON.parse(text); } catch { /* not JSON */ } resolve({ status: res.statusCode, body }); });
|
||||
res.on('error', e => reject(Object.assign(e, { sent: true })));
|
||||
});
|
||||
req.on('timeout', () => req.destroy(Object.assign(new Error('timed out'), { code: 'timeout' })));
|
||||
req.on('finish', () => { sent = true; });
|
||||
req.on('error', e => reject(Object.assign(e, { sent })));
|
||||
req.end(method === 'POST' ? '{}' : undefined);
|
||||
});
|
||||
}
|
||||
// A refused connection never reached the Console. Anything else after the request left may have
|
||||
// changed something, so it is outcome-unknown and nothing is retried.
|
||||
async function consolePost(target, path, { timeoutMs, unknown }) {
|
||||
let answer;
|
||||
try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs }); }
|
||||
catch (e) {
|
||||
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
|
||||
throw new CliError(`outcome unknown: ${unknown}`, 1);
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
function refusal(answer, what) {
|
||||
const code = typeof answer.body?.error === 'string' && /^[a-z-]{1,64}$/.test(answer.body.error) ? answer.body.error : `http ${answer.status}`;
|
||||
const message = typeof answer.body?.message === 'string' ? `: ${answer.body.message.slice(0, 500)}` : '';
|
||||
return new CliError(`${what} refused (${code})${message}`, answer.status >= 500 ? 1 : 3);
|
||||
}
|
||||
// POST /api/console/restart, then wait for the relaunched Console (a new boot id on /api/config).
|
||||
export async function consoleRestart(ctx, { timeoutMs = 10000, waitMs = 30000 } = {}) {
|
||||
const target = consoleTarget(ctx);
|
||||
const answer = await consolePost(target, '/api/console/restart', { timeoutMs, unknown: 'the Console may be restarting; check mosaic console status and the action trail' });
|
||||
if (answer.status !== 202 || answer.body?.outcome !== 'restarting' || typeof answer.body.boot !== 'string') throw refusal(answer, 'console restart');
|
||||
const from = answer.body.boot, deadline = Date.now() + waitMs;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(250);
|
||||
try {
|
||||
const config = await consoleCall(target, { path: '/api/config', timeoutMs: 2000 });
|
||||
if (config.status === 200 && typeof config.body?.boot === 'string' && config.body.boot !== from) return { outcome: 'restarted', from, boot: config.body.boot };
|
||||
} catch { /* closed while it restarts */ }
|
||||
}
|
||||
throw new CliError('the Console accepted the restart but has not answered since; check mosaic console status', 1);
|
||||
}
|
||||
// POST /api/orgs/ID/restart/bus: a running bus restarts, a stopped one starts (org-admin.mjs restartBus).
|
||||
export async function consoleBusRestart(ctx, id, { timeoutMs = 120000 } = {}) {
|
||||
if (typeof id !== 'string' || !ORG_ID.test(id)) throw new CliError('invalid organization id', 4);
|
||||
const target = consoleTarget(ctx);
|
||||
const answer = await consolePost(target, `/api/orgs/${id}/restart/bus`, { timeoutMs, unknown: `the bus may be restarting; check mosaic bus status ${id} and the action trail` });
|
||||
if (answer.status !== 200 || answer.body?.outcome !== 'done' || answer.body.step !== 'bus-restart') throw refusal(answer, 'org reload');
|
||||
return answer.body;
|
||||
}
|
||||
@@ -2,11 +2,14 @@ import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { listOrgs, orgLock } from '../../organization/src/org-registry.mjs';
|
||||
import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs';
|
||||
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
|
||||
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
|
||||
import { loadSystem } from './config.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { busStopForDelete } from './org-bus.mjs';
|
||||
export const ORG_USAGE = 'mosaic org lock show|clear ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
|
||||
import { consoleBusRestart } from './org-console.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
|
||||
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
|
||||
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e.message}`, 2); } }
|
||||
export async function orgCommand(args, io, deps = {}) {
|
||||
@@ -16,12 +19,19 @@ export async function orgCommand(args, io, deps = {}) {
|
||||
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
|
||||
if (words[0] === 'template' && words[1] === 'render' && words.length === 3) { allowed([], ['--fixture', '--output']); result = await templateRender(words[2], { fixture: p.values['--fixture'] }); await writeFile(need('--output'), JSON.stringify(result, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); io.stdout.write(`rendered ${words[2]} to ${p.values['--output']}\n`); return; }
|
||||
if (words[0] === 'validate' && words.length === 2) { allowed([], []); result = await validateOrg(await document(words[1])); if (!result.ok) throw new CliError(result.errors.map(e => `${e.path}: ${e.message}`).join('\n'), 2); io.stdout.write('valid\n'); return; }
|
||||
// U1 (#1873): `org reload` is a human action; an agent run is refused before any config is read.
|
||||
if (words[0] === 'reload') refuseInsideAgent(io.env);
|
||||
if (words[0] === 'system-types' && words[1] === 'installed' && words.length === 2) { allowed(['--json'], []); io.stdout.write(JSON.stringify(await installedSystemTypes(), null, 2) + '\n'); return; }
|
||||
const instance = devOwnerInstance(io.env); const system = deps.system ?? loadSystem({ env: io.env, instance }); const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
|
||||
if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); }
|
||||
if (words[0] === 'system-types' && words[1] === 'enabled' && words.length === 3) { allowed(['--json'], []); const org = await showOrg(ctx, words[2]); result = { organization: words[2], enabled: org.doc.plugins?.['system-type'] ?? [] }; }
|
||||
else if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); }
|
||||
else if (words[0] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); }
|
||||
else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); }
|
||||
else if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json'], ['--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); const opts = { ifRevision: Number(r) }; result = words[0] === 'archive' ? await archiveOrg(ctx, words[1], { ...opts, busStop: busStopForDelete }) : await restoreOrg(ctx, words[1], opts); }
|
||||
else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const r = p.values['--if-revision']; if (r !== undefined && !/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, confirm: need('--confirm'), ifRevision: r === undefined ? undefined : Number(r) }); }
|
||||
// U1 (#1873): the F3 bus control from a terminal, through the running Console's own route, which
|
||||
// checks the owner and writes the action trail (org-console.mjs consoleBusRestart).
|
||||
else if (words[0] === 'reload' && words.length === 2) { allowed([], []); result = await consoleBusRestart(ctx, words[1], deps.consoleRequest ?? {}); }
|
||||
else if (words[0] === 'show' && words.length === 2) { allowed(['--json'], []); result = await showOrg(ctx, words[1]); }
|
||||
else if (words[0] === 'list' && words.length === 1) { allowed(['--json'], []); for (const entry of await listOrgs(ctx)) if (entry.state === 'active' || entry.state === 'archived') accepted.push(await showOrg(ctx, entry.id, { includeArchived: true })); result = accepted; }
|
||||
else if ((words[0] === 'queue' && words.length === 3) || (words[0] === 'project' && words[1] === 'repo' && words.length === 5)) {
|
||||
|
||||
@@ -14,6 +14,8 @@ export const PROJECT_USAGE = 'mosaic project init <business> <project> [--tester
|
||||
// and check above work on the repository on disk and never contact the bus, except that
|
||||
// init --tester (row 175, #1673) binds the tester once before it writes.
|
||||
export const PROJECT_BUS_USAGE = [
|
||||
'mosaic project system-types <name-or-id> [--business <id>] [--json]',
|
||||
'mosaic project system-types-set <name-or-id> --types <comma-separated-ids> [--business <id>]',
|
||||
'mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]',
|
||||
'mosaic project list [--archived] [--business <id>] [--json] [--tester]',
|
||||
'mosaic project archive <name-or-id> [--business <id>]',
|
||||
@@ -28,8 +30,8 @@ export async function projectBusCommand(args, io, callFor) {
|
||||
? { flags: ['--tester'], values: ['--repo', '--business'] }
|
||||
: sub === 'list'
|
||||
? { flags: ['--json', '--tester', '--archived'], values: ['--business'] }
|
||||
: { flags: [], values: ['--business'] });
|
||||
const one = sub === 'create' || sub === 'archive' || sub === 'unarchive';
|
||||
: { flags: sub === 'system-types' ? ['--json'] : [], values: sub === 'system-types-set' ? ['--business', '--types'] : ['--business'] });
|
||||
const one = ['create', 'archive', 'unarchive', 'system-types', 'system-types-set'].includes(sub);
|
||||
if (parsed.positional.length !== (one ? 1 : 0))
|
||||
throw new CliError(`project ${sub} takes ${one ? (sub === 'create' ? 'one name' : 'one name or id') : 'no arguments'}\n${PROJECT_BUS_USAGE}`, 4);
|
||||
const call = callFor(parsed);
|
||||
@@ -45,6 +47,17 @@ export async function projectBusCommand(args, io, callFor) {
|
||||
}
|
||||
return io.stdout.write(`created project ${name} id ${made.id}\n`);
|
||||
}
|
||||
if (sub === 'system-types' || sub === 'system-types-set') {
|
||||
const projects = await call('project.list', { includeArchived: true });
|
||||
const ref = parsed.positional[0];
|
||||
const hit = projects.find(p => p.id === ref) ?? projects.find(p => p.name === ref);
|
||||
if (!hit) throw new CliError(`no project is named or numbered ${JSON.stringify(ref)}`, 2);
|
||||
const write = sub === 'system-types-set';
|
||||
if (write && parsed.values['--types'] === undefined) throw new CliError('--types is required (an empty string clears the list)', 4);
|
||||
const systemTypes = parsed.values['--types'] ? parsed.values['--types'].split(',') : [];
|
||||
const result = await call(write ? 'project.system-types.set' : 'project.system-types', { project: hit.id, ...(write ? { systemTypes } : {}) });
|
||||
return io.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
}
|
||||
// #797 MVP3: the owner's reversible archive. Resolution reads the full list, archived included,
|
||||
// so an archived project can be named for unarchive.
|
||||
if (sub === 'archive' || sub === 'unarchive') {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// U1 (#1873): `mosaic console restart` and `mosaic org reload ID` are the F3 page controls from a
|
||||
// terminal. Each test runs the real scripts/mosaic on a scratch instance made by bootstrap.sh, and
|
||||
// checks the refusal before any read, the request to the Console's own route and the action trail
|
||||
// that route writes. The supervised Console is the real one; the org reload success path runs
|
||||
// against an in-process Console whose bus service is a fake, so no bus host starts.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { request } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { processState } from '../../seat/src/process-state.mjs';
|
||||
import { startServer } from '../../webui/src/serve.mjs';
|
||||
import { createDevOwner } from '../../webui/src/dev-owner.mjs';
|
||||
import { createOrgAdmin } from '../../webui/src/org-admin.mjs';
|
||||
import { actionTrail } from '../../webui/src/action-trail.mjs';
|
||||
import { close } from '../../webui/tests/fixture.mjs';
|
||||
import { humanEnv } from './helpers.mjs';
|
||||
import { consoleStart, consoleStop } from '../src/org-console.mjs';
|
||||
|
||||
const REPO = new URL('../../../', import.meta.url).pathname;
|
||||
const MOSAIC = join(REPO, 'scripts/mosaic');
|
||||
const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350];
|
||||
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
|
||||
|
||||
// A scratch instance, as bootstrap.sh makes one, with org alpha active.
|
||||
async function instance(t) {
|
||||
const home = mkdtempSync(join(tmpdir(), 'u1-'));
|
||||
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-u1test/config.json') });
|
||||
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'u1test'], { env, encoding: 'utf8' });
|
||||
assert.equal(setup.status, 0, setup.stderr);
|
||||
const ctx = { configDir: join(home, '.config/mosaic-u1test'), dataRoot: join(home, '.mosaic-u1test'), instance: 'u1test', env };
|
||||
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
|
||||
t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); });
|
||||
return { ctx, env, trailFile: join(ctx.dataRoot, 'console/actions.jsonl') };
|
||||
}
|
||||
// The CLI as a child, without blocking this process: the in-process Console must keep answering.
|
||||
function mosaic(env, args, timeout = 60000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(MOSAIC, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '', stderr = '';
|
||||
child.stdout.setEncoding('utf8').on('data', c => { stdout += c; });
|
||||
child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
|
||||
child.on('error', reject);
|
||||
child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); });
|
||||
});
|
||||
}
|
||||
const trailLines = file => existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.ok(Number.isFinite(Date.parse(at))); return e; }) : [];
|
||||
function get(port, path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request({ host: '127.0.0.1', port, path, headers: { host: `127.0.0.1:${port}` } }, res => {
|
||||
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
|
||||
}).on('error', reject).end();
|
||||
});
|
||||
}
|
||||
// console.json naming a Console that runs in this test process (same checks as a real one).
|
||||
function recordConsole(ctx, url, { pid = process.pid, startTime = processState(process.pid).startTime } = {}) {
|
||||
const dir = join(ctx.dataRoot, 'console');
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700);
|
||||
const file = join(dir, 'console.json');
|
||||
writeFileSync(file, JSON.stringify({ pid, startTime, url }), { mode: 0o600 }); chmodSync(file, 0o600);
|
||||
}
|
||||
|
||||
test('both verbs refuse inside an agent run before any config is read', async () => {
|
||||
// The config path is not an instance config, so a read would refuse with another message.
|
||||
// humanEnv strips agent markers, so the marker goes in after it.
|
||||
const env = { ...humanEnv({ MOSAIC_CONFIG: '/nonexistent/u1/config.json' }), CLAUDECODE: '1' };
|
||||
for (const args of [['console', 'restart'], ['org', 'reload', 'alpha']]) {
|
||||
const r = await mosaic(env, args);
|
||||
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
|
||||
assert.match(r.stderr, /outside any agent run \(found CLAUDECODE/);
|
||||
assert.equal(r.stdout, '');
|
||||
}
|
||||
});
|
||||
|
||||
test('with no live Console both verbs refuse and nothing is written', { timeout: 90000 }, async t => {
|
||||
const { ctx, env, trailFile } = await instance(t);
|
||||
for (const args of [['console', 'restart'], ['org', 'reload', 'alpha']]) {
|
||||
const r = await mosaic(env, args);
|
||||
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
|
||||
assert.match(r.stderr, /no Console runs for this instance/);
|
||||
}
|
||||
recordConsole(ctx, 'http://127.0.0.1:1/', { pid: 2147483647, startTime: '1' });
|
||||
const stale = await mosaic(env, ['console', 'restart']);
|
||||
assert.equal(stale.status, 3, stale.stderr);
|
||||
assert.match(stale.stderr, /is not running/);
|
||||
const bad = await mosaic(env, ['org', 'reload', 'Bad/Id']);
|
||||
assert.equal(bad.status, 4, bad.stderr);
|
||||
assert.match(bad.stderr, /invalid organization id/);
|
||||
assert.equal((await mosaic(env, ['console', 'restart', 'extra'])).status, 4);
|
||||
assert.equal(existsSync(trailFile), false);
|
||||
});
|
||||
|
||||
test('mosaic console restart relaunches a supervised Console and the trail records it', { timeout: 120000 }, async t => {
|
||||
const { ctx, env, trailFile } = await instance(t);
|
||||
const warnings = [];
|
||||
const result = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) });
|
||||
t.after(async () => { result.stop(); await result.done; });
|
||||
const port = Number(new URL(result.url).port);
|
||||
assert.ok(!FORBIDDEN.includes(port));
|
||||
const before = (await get(port, '/api/config')).body;
|
||||
assert.equal(before.consoleRestart, true);
|
||||
const r = await mosaic(env, ['console', 'restart']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const answer = JSON.parse(r.stdout);
|
||||
assert.equal(answer.outcome, 'restarted'); assert.equal(answer.from, before.boot);
|
||||
assert.equal(typeof answer.boot, 'string'); assert.notEqual(answer.boot, before.boot);
|
||||
assert.equal((await get(port, '/api/config')).body.boot, answer.boot);
|
||||
assert.deepEqual(trailLines(trailFile), [{ by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot }]);
|
||||
assert.equal(statSync(trailFile).mode & 0o777, 0o600);
|
||||
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
|
||||
});
|
||||
|
||||
test('a detached Console refuses mosaic console restart, and org reload of an unknown org, with no trail', { timeout: 90000 }, async t => {
|
||||
const { ctx, env, trailFile } = await instance(t);
|
||||
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
|
||||
const port = Number(new URL(result.url).port);
|
||||
assert.ok(!FORBIDDEN.includes(port));
|
||||
const r = await mosaic(env, ['console', 'restart']);
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.match(r.stderr, /console restart refused \(not-supervised\)/);
|
||||
// The real organization service refuses an org that does not exist before any trail line.
|
||||
const unknown = await mosaic(env, ['org', 'reload', 'nosuch']);
|
||||
assert.equal(unknown.status, 3, unknown.stderr);
|
||||
assert.match(unknown.stderr, /org reload refused \(org-[a-z-]+\)/);
|
||||
assert.equal((await get(port, '/api/config')).status, 200);
|
||||
assert.equal(existsSync(trailFile), false);
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
});
|
||||
|
||||
// The F3 bus control through the Console's own route: a stopped bus starts and a running one restarts.
|
||||
function fakeAdmin(ctx) {
|
||||
const calls = [];
|
||||
let running = false;
|
||||
const known = id => { if (id !== 'acme') throw Object.assign(new Error('unknown organization'), { code: 'org-unknown' }); };
|
||||
const service = { showOrg: async (c, id) => { known(id); return { id, root: join(ctx.dataRoot, 'org-runtime', id), revision: 1, doc: {}, principal: c.principal }; } };
|
||||
const bus = {
|
||||
busStatus: async (c, id) => { known(id); return { org: id, instance: ctx.instance, root: join(ctx.dataRoot, 'org-runtime', id), socket: '/run/u1.sock', healthy: running, pid: running ? 4242 : null }; },
|
||||
busStart: async (c, id, opts) => { calls.push(['busStart', id, opts]); running = true; return { org: id, pid: 4242, socket: '/run/u1.sock', root: join(ctx.dataRoot, 'org-runtime', id) }; },
|
||||
busRestart: async (c, id) => { calls.push(['busRestart', id]); return { org: id, pid: 4343, socket: '/run/u1.sock', root: join(ctx.dataRoot, 'org-runtime', id) }; },
|
||||
};
|
||||
const registry = { listOrgs: async () => [{ id: 'acme', state: 'active' }] };
|
||||
return { calls, admin: createOrgAdmin({ service, registry, bus, context: async () => ({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: ctx.instance }) }) };
|
||||
}
|
||||
|
||||
test('mosaic org reload starts or restarts the bus through the Console route and the trail records each', { timeout: 90000 }, async t => {
|
||||
const { ctx, env, trailFile } = await instance(t);
|
||||
const f = fakeAdmin(ctx);
|
||||
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), orgAdmin: f.admin, log: () => {}, trail: actionTrail(join(ctx.dataRoot, 'console')) });
|
||||
t.after(() => close(server));
|
||||
const port = server.address().port;
|
||||
assert.ok(!FORBIDDEN.includes(port));
|
||||
recordConsole(ctx, `http://127.0.0.1:${port}/`);
|
||||
let r = await mosaic(env, ['org', 'reload', 'acme']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.deepEqual(JSON.parse(r.stdout), { outcome: 'done', step: 'bus-restart', restarted: false, healthy: true, receipt: { org: 'acme', pid: 4242, socket: '<host path>', root: '<data root>/org-runtime/acme' }, verb: 'mosaic bus start acme --dev-local-owner --detach' });
|
||||
r = await mosaic(env, ['org', 'reload', 'acme']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const second = JSON.parse(r.stdout);
|
||||
assert.equal(second.restarted, true); assert.equal(second.verb, 'mosaic bus restart acme'); assert.equal(second.receipt.pid, 4343);
|
||||
assert.deepEqual(f.calls, [['busStart', 'acme', { detach: true, devLocalOwner: true, pm: false }], ['busRestart', 'acme']]);
|
||||
// An unknown org is refused by the route before the trail (as org-unavailable, which does not say
|
||||
// whether it exists); extra arguments never reach the Console.
|
||||
const unknown = await mosaic(env, ['org', 'reload', 'other']);
|
||||
assert.equal(unknown.status, 3, unknown.stderr);
|
||||
assert.match(unknown.stderr, /org reload refused \(org-unavailable\)/);
|
||||
assert.equal((await mosaic(env, ['org', 'reload', 'acme', 'extra'])).status, 4);
|
||||
assert.deepEqual(trailLines(trailFile), [
|
||||
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: false },
|
||||
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
|
||||
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: true },
|
||||
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
|
||||
]);
|
||||
// A read-only Console (no --send) refuses, and the CLI reports the Console's own reason.
|
||||
await close(server);
|
||||
const readonly = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), orgAdmin: f.admin, log: () => {}, trail: actionTrail(join(ctx.dataRoot, 'console')) });
|
||||
t.after(() => close(readonly));
|
||||
recordConsole(ctx, `http://127.0.0.1:${readonly.address().port}/`);
|
||||
const denied = await mosaic(env, ['org', 'reload', 'acme']);
|
||||
assert.equal(denied.status, 3, denied.stderr);
|
||||
assert.match(denied.stderr, /org reload refused \(read-only\): start the Console with --send/);
|
||||
assert.equal(trailLines(trailFile).length, 4);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync, statSync } from 'node:fs';
|
||||
import { get, request } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -133,3 +133,80 @@ test('mosaic console start refuses the LAN flags without the dev owner and allow
|
||||
assert.deepEqual(JSON.parse(none.stdout), { running: false });
|
||||
assert.equal(mosaic(['console', 'status', '--json']).status, 4);
|
||||
});
|
||||
|
||||
// F3: the owner restarts the Console from Settings. A foreground `mosaic console start` runs the
|
||||
// Console with --supervised and relaunches it on the same arguments and address when it exits 75.
|
||||
const WEBUI = join(REPO, 'packages/webui/src/cli.mjs');
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const argvOf = pid => readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').slice(0, -1);
|
||||
function call(port, path, { method = 'GET', body = null, headers = {} } = {}) {
|
||||
const h = { host: `127.0.0.1:${port}`, ...(method === 'GET' ? {} : { origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }), ...headers };
|
||||
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = request({ host: '127.0.0.1', port, path, method, headers: h }, res => {
|
||||
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
|
||||
res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch {} resolve({ status: res.statusCode, body: json }); });
|
||||
});
|
||||
r.on('error', reject); r.end(body ?? undefined);
|
||||
});
|
||||
}
|
||||
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
|
||||
|
||||
test('a foreground console relaunches on the same address when the owner restarts it, and a stop ends it', { timeout: 90000 }, async t => {
|
||||
const { ctx } = await instance(t);
|
||||
const warnings = [];
|
||||
const result = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) });
|
||||
t.after(async () => { result.stop(); await result.done; });
|
||||
const port = Number(new URL(result.url).port);
|
||||
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(port));
|
||||
assert.deepEqual(argvOf(result.pid), [process.execPath, WEBUI, 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner', '--supervised']);
|
||||
const before = (await call(port, '/api/config')).body;
|
||||
assert.equal(before.consoleRestart, true); assert.equal(typeof before.boot, 'string');
|
||||
const stateFile = join(ctx.dataRoot, 'console/console.json');
|
||||
const trailFile = join(ctx.dataRoot, 'console/actions.jsonl');
|
||||
// Another origin or site restarts nothing.
|
||||
for (const headers of [{ origin: 'http://evil.example' }, { origin: null }, { 'sec-fetch-site': 'cross-site' }, { host: `evil.example:${port}` }]) {
|
||||
assert.equal((await call(port, '/api/console/restart', { method: 'POST', body: '{}', headers })).status, 403, JSON.stringify(headers));
|
||||
}
|
||||
assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).pid, result.pid);
|
||||
assert.equal(existsSync(trailFile), false);
|
||||
const answer = await call(port, '/api/console/restart', { method: 'POST', body: '{}' });
|
||||
assert.equal(answer.status, 202);
|
||||
assert.deepEqual(answer.body, { outcome: 'restarting', boot: before.boot });
|
||||
let saved;
|
||||
for (const deadline = Date.now() + 30000; ; await sleep(100)) {
|
||||
saved = JSON.parse(readFileSync(stateFile, 'utf8'));
|
||||
if (saved.pid !== result.pid) break;
|
||||
if (Date.now() > deadline) assert.fail('the Console was not relaunched');
|
||||
}
|
||||
// Same address, same arguments; the auto port is now the port it had.
|
||||
assert.equal(saved.url, result.url);
|
||||
assert.deepEqual(argvOf(saved.pid), [process.execPath, WEBUI, 'serve', '--port', String(port), '--business', 'alpha', '--dev-local-owner', '--supervised']);
|
||||
assert.equal(statSync(stateFile).mode & 0o777, 0o600);
|
||||
const after = (await call(port, '/api/config')).body;
|
||||
assert.notEqual(after.boot, before.boot);
|
||||
assert.equal(after.consoleRestart, true);
|
||||
const [line, ...more] = readFileSync(trailFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
|
||||
assert.deepEqual(more, []);
|
||||
assert.ok(Number.isFinite(Date.parse(line.at)));
|
||||
assert.deepEqual({ ...line, at: undefined }, { at: undefined, by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot });
|
||||
assert.equal(statSync(trailFile).mode & 0o777, 0o600);
|
||||
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
|
||||
assert.deepEqual(consoleStatus(ctx), { running: true, pid: saved.pid, url: result.url, bind: '127.0.0.1' });
|
||||
result.stop();
|
||||
assert.equal(await result.done, 0);
|
||||
assert.equal(consoleStatus(ctx).running, false);
|
||||
});
|
||||
|
||||
test('a detached console has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => {
|
||||
const { ctx } = await instance(t);
|
||||
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
|
||||
const port = Number(new URL(result.url).port);
|
||||
assert.ok(!argvOf(result.pid).includes('--supervised'));
|
||||
assert.equal((await call(port, '/api/config')).body.consoleRestart, false);
|
||||
const r = await call(port, '/api/console/restart', { method: 'POST', body: '{}' });
|
||||
assert.equal(r.status, 409); assert.equal(r.body.error, 'not-supervised');
|
||||
assert.equal((await call(port, '/api/config')).status, 200);
|
||||
assert.equal(existsSync(join(ctx.dataRoot, 'console/actions.jsonl')), false);
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
});
|
||||
@@ -12,6 +12,7 @@ test('organization CLI renders, validates, creates, updates and initializes git'
|
||||
const file = join(home, 'alpha.json'); const invoke = (...args) => { const r = run(env, ...args); assert.equal(r.status, 0, r.stderr); return r.stdout; };
|
||||
invoke('org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file);
|
||||
invoke('org', 'validate', file); invoke('org', 'create', 'alpha', '--from', file);
|
||||
assert.deepEqual(JSON.parse(invoke('org', 'system-types', 'enabled', 'alpha')), { organization: 'alpha', enabled: [] });
|
||||
const bootCheck = spawnSync(process.execPath, ['--input-type=module', '-e', `import {loadSystem, bootConfig} from './packages/cli/src/config.mjs'; const system=loadSystem({env:process.env, instance:'orgtest'}); bootConfig({system,businessId:'alpha',env:process.env});`], { cwd: repo, env, encoding: 'utf8' }); assert.equal(bootCheck.status, 0, bootCheck.stderr);
|
||||
let info = JSON.parse(invoke('org', 'show', 'alpha', '--json')); assert.equal(info.id, 'alpha'); assert.equal(JSON.parse(invoke('org', 'list', '--json')).length, 1);
|
||||
invoke('org', 'update', 'alpha', '--from', file, '--if-revision', String(info.revision));
|
||||
|
||||
@@ -306,7 +306,7 @@ test('#811 migration readers follow the engine order and refuse malformed pairs'
|
||||
assert.notEqual(engineSource,source);
|
||||
const engine=await import(`data:text/javascript;base64,${Buffer.from(engineSource).toString('base64')}`);
|
||||
const pairs=engine.migrations.map(({from,to})=>[from,to]);
|
||||
assert.equal(pairs.length,18);assert.deepEqual(pairs.at(-1),['3s','3t']);
|
||||
assert.equal(pairs.length,19);assert.deepEqual(pairs.at(-1),['3t','3u']);
|
||||
const release=readFileSync(join(ROOT,'scripts/stack-release.sh'),'utf8');
|
||||
const fn=(name)=>{const m=release.match(new RegExp(`^${name}\\(\\) \{[\\s\\S]*?^\}`, 'm'));assert.ok(m,name);return m[0]};
|
||||
const functions=['copy_schema','target_schema','plan_steps'].map(fn).join('\n');
|
||||
@@ -885,7 +885,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' ');
|
||||
const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1];
|
||||
// sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
|
||||
// well as T46.
|
||||
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','project_archives','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events'];
|
||||
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','project_archives','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_system_types'];
|
||||
const LATE='2999-01-01T00:00:00.000Z';
|
||||
const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()};
|
||||
|
||||
@@ -903,7 +903,7 @@ test('T45 row 181 items 2 and 3: the restore guard counts events and every COUNT
|
||||
both(store('3f.sqlite',['events'],['events','instance_names']),'1\t1 event(s)\n');
|
||||
both(store('none.sqlite',[],['instance_names']),'0\t0 event(s)\n');
|
||||
});
|
||||
for(const x of ['instance_names','usage_records'])await t.test(`one ${x} row and no event refuses before any stop; --events 1 restores`,async(t)=>{
|
||||
for(const x of ['instance_names','usage_records','project_system_types'])await t.test(`one ${x} row and no event refuses before any stop; --events 1 restores`,async(t)=>{
|
||||
const e=await mk(t);await seed(e);
|
||||
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
|
||||
addRow(e.store,x);
|
||||
@@ -925,10 +925,10 @@ test('T45 row 181 items 2 and 3: the restore guard counts events and every COUNT
|
||||
assert.equal(r.code,3,r.out+r.err);assert.ok(r.err.endsWith(`stack-release: refusing restore-mismatch: cannot count the rows after ${stamp}\n`),r.err);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).at(-1).code,'restore-mismatch');
|
||||
});
|
||||
await t.test('the in-run recount counts the listed tables too',async(t)=>{
|
||||
for(const x of ['thread_opens','project_system_types'])await t.test(`the in-run recount counts the listed tables too (${x})`,async(t)=>{
|
||||
const e=await mk(t);await seed(e);
|
||||
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
|
||||
fault(e,'stop-writes-row','thread_opens');
|
||||
fault(e,'stop-writes-row',x);
|
||||
const r=await run(e,'rollback','acme','--restore',act);assert.equal(r.code,1,r.out+r.err);
|
||||
assert.equal(r.err,rolledBack('post-backup-events',C));
|
||||
assert.ok(r.out.includes('; 0 event(s) were written after the backup\n'),r.out);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { orgCommand } from '../src/org.mjs';
|
||||
import { projectBusCommand } from '../src/project.mjs';
|
||||
|
||||
test('org installed system types and project selection reads print JSON', async () => {
|
||||
let output = '';
|
||||
const io = { stdout: { write: s => { output += s; } }, env: {} };
|
||||
await orgCommand(['system-types', 'installed'], io);
|
||||
assert.deepEqual(JSON.parse(output).map(x => x.id), ['business-management', 'coding', 'creative-writing', 'marketing', 'montage']);
|
||||
output = '';
|
||||
const calls = [];
|
||||
await projectBusCommand(['system-types', 'work', '--business', 'demo', '--json'], io, () => async (verb, args) => {
|
||||
calls.push([verb, args]);
|
||||
return verb === 'project.list' ? [{ id: 'project-id', name: 'work' }] : { project: 'project-id', selected: ['coding'], active: ['coding'], inactive: [] };
|
||||
});
|
||||
assert.deepEqual(calls.map(x => x[0]), ['project.list', 'project.system-types']);
|
||||
assert.deepEqual(JSON.parse(output).active, ['coding']);
|
||||
});
|
||||
|
||||
// sys1-review P2: both commands go through run() and the real CLI channel module, so a verb the
|
||||
// channel does not admit fails here. A disabled channel and an agent run still refuse.
|
||||
test('project system-types and system-types-set pass the CLI channel', async t => {
|
||||
const { run } = await import('../src/cli.mjs');
|
||||
const { fixture, humanEnv, io: mkio, tmp } = await import('./helpers.mjs');
|
||||
const root = tmp(t), f = fixture(root);
|
||||
const P = '10000000-0000-4000-8000-000000000001';
|
||||
const transportFor = channels => {
|
||||
const calls = [];
|
||||
const transport = () => async (verb, args) => {
|
||||
if (verb === 'plugins') return { business: 'acme', revision: 'a'.repeat(64), plugins: { channel: channels } };
|
||||
calls.push([verb, args]);
|
||||
if (verb === 'project.list') return [{ id: P, at: '2026-11-09T00:00:00.000Z', name: 'work', repo: null }];
|
||||
if (verb === 'project.system-types') return { project: P, selected: ['coding'], active: ['coding'], inactive: [] };
|
||||
if (verb === 'project.system-types.set') return { project: P, selected: args.systemTypes, active: args.systemTypes, inactive: [] };
|
||||
throw Object.assign(new Error(verb), { code: 'bus-refused' });
|
||||
};
|
||||
return { calls, transport };
|
||||
};
|
||||
const read = transportFor(['cli']), rx = mkio(humanEnv(f.env));
|
||||
assert.equal(await run(['project', 'system-types', 'work', '--business', 'acme'], rx, { transport: read.transport }), 0, rx.err?.text);
|
||||
assert.deepEqual(read.calls, [['project.list', { includeArchived: true }], ['project.system-types', { project: P }]]);
|
||||
assert.deepEqual(JSON.parse(rx.out.text).active, ['coding']);
|
||||
const write = transportFor(['cli']), wx = mkio(humanEnv(f.env));
|
||||
assert.equal(await run(['project', 'system-types-set', 'work', '--types', 'coding,marketing', '--business', 'acme'], wx, { transport: write.transport }), 0, wx.err?.text);
|
||||
assert.deepEqual(write.calls.at(-1), ['project.system-types.set', { project: P, systemTypes: ['coding', 'marketing'] }]);
|
||||
const off = transportFor(['webui']), ox = mkio(humanEnv(f.env));
|
||||
assert.equal(await run(['project', 'system-types', 'work', '--business', 'acme'], ox, { transport: off.transport }), 3);
|
||||
assert.deepEqual(off.calls, []);
|
||||
const agent = transportFor(['cli']);
|
||||
assert.notEqual(await run(['project', 'system-types-set', 'work', '--types', 'coding', '--business', 'acme'], mkio({ ...humanEnv(f.env), CODEX_THREAD_ID: 'fixture-agent' }), { transport: agent.transport }), 0);
|
||||
assert.deepEqual(agent.calls, []);
|
||||
});
|
||||
@@ -578,3 +578,8 @@ The local development owner is accepted only through the existing named-instance
|
||||
selection on this release's throwaway development instances. Receipt subjects
|
||||
name that principal, not Jason. The dev-purpose bootstrap fence is a separate
|
||||
follow-up before person-used deployment.
|
||||
|
||||
The optional `plugins.system-type` list enables installed system-type modules for
|
||||
an organization. Missing means none. Unknown ids refuse validation. Changes
|
||||
are owner-only; project selections are separate bus settings and a selection
|
||||
whose type is disabled remains recorded as inactive.
|
||||
@@ -12,6 +12,7 @@ import { parseCredentialRef } from "./credentials.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { END_PROTECTED } from "./vocabulary.mjs";
|
||||
import { checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
|
||||
import { hasModule } from "../../plugins/src/loader.mjs";
|
||||
import { checkMapping } from "../../library/src/mapping.mjs";
|
||||
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
|
||||
import { checkAppearance } from "./appearance.mjs";
|
||||
@@ -322,6 +323,9 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
|
||||
// here; whether each module loads is the loader's check, at load time.
|
||||
const plugins = document.plugins === undefined ? {}
|
||||
: structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
|
||||
for (const id of plugins["system-type"] ?? []) {
|
||||
if (!hasModule("system-type", id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
|
||||
}
|
||||
const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage);
|
||||
const appearance = checkAppearance(document.appearance, `${file} appearance`);
|
||||
const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file);
|
||||
|
||||
@@ -105,6 +105,13 @@ function usageFence(old, document, actor) {
|
||||
fail("organization-usage-human-only");
|
||||
}
|
||||
|
||||
function systemTypesFence(old, document, actor) {
|
||||
if (canonicalJson(old.plugins?.['system-type'] ?? []) === canonicalJson(document?.plugins?.['system-type'] ?? [])) return;
|
||||
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
|
||||
if (actor === old.human && actor !== 'tester' && !agents.includes(actor)) return;
|
||||
fail('organization-system-types-human-only');
|
||||
}
|
||||
|
||||
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is
|
||||
// the business's human as the file stood before the write, and no agent or the tester under that
|
||||
// name. Compared as M9 compares projects, on the document and again on what lands on disk.
|
||||
@@ -286,6 +293,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
replyLimitsFence(old, document, actor);
|
||||
modelQuotaFence(old, document, actor);
|
||||
usageFence(old, document, actor);
|
||||
systemTypesFence(old, document, actor);
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
const bytes = JSON.stringify(document, null, 2) + "\n";
|
||||
// Check and log exactly what lands on disk, for every setter: a value JSON
|
||||
@@ -295,6 +303,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
replyLimitsFence(old, written, actor);
|
||||
modelQuotaFence(old, written, actor);
|
||||
usageFence(old, written, actor);
|
||||
systemTypesFence(old, written, actor);
|
||||
validateBusinessDocument(written, file, { rolesDir });
|
||||
if (beforeWrite) {
|
||||
beforeWrite();
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'node:path';
|
||||
import { validateBusinessDocument } from '../src/organization-config.mjs';
|
||||
import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs';
|
||||
|
||||
test('organization enables only installed system types; absent means none', () => {
|
||||
const root = tmp(), doc = businessDoc(root), file = join(root, 'businesses/acme.json');
|
||||
const validate = () => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES });
|
||||
assert.deepEqual(validate().plugins['system-type'] ?? [], []);
|
||||
doc.plugins = { 'system-type': ['coding', 'montage'] };
|
||||
assert.deepEqual(validate().plugins['system-type'], ['coding', 'montage']);
|
||||
doc.plugins['system-type'] = ['not-installed'];
|
||||
assert.throws(validate, /system-type.*not-installed/);
|
||||
});
|
||||
|
||||
test('system type changes through the organization writer are owner-only', async t => {
|
||||
const { rolesCopy, writeJson } = await import('./helpers.mjs');
|
||||
const { pluginSettings } = await import('../src/plugin-settings.mjs');
|
||||
const { rmSync } = await import('node:fs');
|
||||
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root), doc = businessDoc(root), file = join(root, 'businesses/acme.json');
|
||||
writeJson(file, doc);
|
||||
const settings = pluginSettings({ dir: root, rolesDir });
|
||||
const revision = settings.read('acme').revision;
|
||||
assert.throws(() => settings.set('acme', { revision, plugins: { 'system-type': ['coding'] } }, 'tester'), /human-only/);
|
||||
assert.deepEqual(settings.read('acme').plugins, {});
|
||||
assert.deepEqual(settings.set('acme', { revision, plugins: { 'system-type': ['coding'] } }, doc.human).plugins['system-type'], ['coding']);
|
||||
});
|
||||
@@ -496,3 +496,12 @@ counts; it has no quota authority. See
|
||||
record separation, bounded reads and per-key quota settings.
|
||||
|
||||
A harness declares `outputPolicy({ proxy })` as `adapter`, `non-newline-stdout`, or `stdout`. The launcher validates and serializes that declaration; old sessions retain their recorded activity behavior.
|
||||
|
||||
### System types
|
||||
|
||||
`system-type` modules export `interfaceVersion = 1` and `systemType()`, returning
|
||||
`{ id, label, description, toolsets }`. The id is the module directory name;
|
||||
`toolsets` is a distinct list of toolset ids. The installed list is discovered
|
||||
from module directories, and malformed modules refuse discovery. An organization
|
||||
selects modules with `plugins.system-type`; an omitted list enables none. The
|
||||
initial five modules declare no toolsets yet.
|
||||
@@ -6,6 +6,8 @@ const VERBS = new Set([
|
||||
"project.create",
|
||||
"project.archive",
|
||||
"project.unarchive",
|
||||
"project.system-types",
|
||||
"project.system-types.set",
|
||||
"thread.list",
|
||||
"thread.create",
|
||||
"thread.messages",
|
||||
|
||||
@@ -80,6 +80,10 @@ export const KINDS = Object.freeze({
|
||||
"model-provider": Object.freeze({
|
||||
kind: "model-provider", interfaceVersion: 1, exports: Object.freeze(["catalogue", "preflight", "route"]), switch: "list",
|
||||
}),
|
||||
// Project system types are installed modules, enabled per organization.
|
||||
"system-type": Object.freeze({
|
||||
kind: "system-type", interfaceVersion: 1, exports: Object.freeze(["systemType"]), switch: "list",
|
||||
}),
|
||||
// A Console colour theme (stream S4, PLAN C5). A module is data only: theme() takes nothing and returns
|
||||
// { displayName, tokens: { light, dim, dark } }
|
||||
// where each mode is a flat map of the Console's colour custom properties to #rrggbb strings. theme.mjs
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { loadInstalled } from './loader.mjs';
|
||||
import { NAME } from './kinds.mjs';
|
||||
|
||||
// Discovery is fail-closed: malformed or unloadable installed modules cannot be advertised.
|
||||
export async function installedSystemTypes() {
|
||||
const { modules, refused } = await loadInstalled('system-type');
|
||||
if (refused.length) throw Object.assign(new Error('system type module refused'), { code: 'system-type-invalid' });
|
||||
return modules.map(({ name, module }) => {
|
||||
const value = module.systemType();
|
||||
if (!value || Object.keys(value).sort().join(',') !== 'description,id,label,toolsets' || value.id !== name ||
|
||||
typeof value.label !== 'string' || !value.label.trim() || /[\r\n]/.test(value.label) ||
|
||||
typeof value.description !== 'string' || !value.description.trim() || /[\r\n]/.test(value.description) ||
|
||||
!Array.isArray(value.toolsets) || value.toolsets.some(t => typeof t !== 'string' || !NAME.test(t)) ||
|
||||
new Set(value.toolsets).size !== value.toolsets.length) {
|
||||
throw Object.assign(new Error(`invalid system type ${name}`), { code: 'system-type-invalid' });
|
||||
}
|
||||
return structuredClone(value);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const interfaceVersion = 1;
|
||||
|
||||
export function systemType() {
|
||||
return { id: "business-management", label: "Business management", description: "Coordinate and manage business operations.", toolsets: [] };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const interfaceVersion = 1;
|
||||
|
||||
export function systemType() {
|
||||
return { id: "coding", label: "Coding", description: "Build and maintain software.", toolsets: [] };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const interfaceVersion = 1;
|
||||
|
||||
export function systemType() {
|
||||
return { id: "creative-writing", label: "Creative writing", description: "Develop and write creative works.", toolsets: [] };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const interfaceVersion = 1;
|
||||
|
||||
export function systemType() {
|
||||
return { id: "marketing", label: "Marketing", description: "Plan and produce marketing work.", toolsets: [] };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const interfaceVersion = 1;
|
||||
|
||||
export function systemType() {
|
||||
return { id: "montage", label: "Mosaic Montage", description: "Create and manage media productions.", toolsets: [] };
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs";
|
||||
const code = expected => e => e instanceof PluginError && e.code === expected;
|
||||
|
||||
test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1, limit-check at 2 (row 125), each a list", () => {
|
||||
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "theme"]);
|
||||
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "system-type", "theme"]);
|
||||
// Stream S4 (PLAN C5): a theme is data. interfaceVersion 1, one export, a list switch.
|
||||
assert.deepEqual({ ...KINDS.theme, exports: [...KINDS.theme.exports] }, { kind: "theme", interfaceVersion: 1, exports: ["theme"], switch: "list" });
|
||||
assert.ok(Object.isFrozen(KINDS.theme) && Object.isFrozen(KINDS.theme.exports));
|
||||
|
||||
@@ -12,7 +12,7 @@ import { validateBusinessDocument } from "../../organization/src/organization-co
|
||||
|
||||
const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] };
|
||||
// What loaded() reports for GOOD: search-source is a kind too, switched off here.
|
||||
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] };
|
||||
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] };
|
||||
|
||||
// A tree with one good module of each kind, already loaded.
|
||||
async function loadedTree(t, options) {
|
||||
@@ -56,7 +56,7 @@ test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization
|
||||
const loader = await s.loader();
|
||||
const result = await loader.load(business.plugins);
|
||||
assert.deepEqual(result.refused, []);
|
||||
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
|
||||
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
|
||||
assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage");
|
||||
assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit");
|
||||
assert.equal(loader.modules("limit-check")[0].mustStop(), false);
|
||||
@@ -170,7 +170,7 @@ test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses a
|
||||
assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/);
|
||||
assert.deepEqual(result.refused[0].kept, ["meter"]);
|
||||
assert.deepEqual(s.loader.logged.slice(before), result.refused);
|
||||
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
|
||||
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
|
||||
} finally {
|
||||
if (lock) chmodSync(lock, 0o755);
|
||||
}
|
||||
@@ -232,7 +232,7 @@ test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and t
|
||||
const loader = await s.loader();
|
||||
// meter loads, absent refuses: the whole kind stays off, not half on.
|
||||
const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
|
||||
assert.deepEqual(loader.loaded(), result.loaded);
|
||||
assert.deepEqual(loader.modules("usage-source"), []);
|
||||
assert.equal(loader.logged.length, 1);
|
||||
@@ -260,7 +260,7 @@ test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole se
|
||||
await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/);
|
||||
// An absent key switches the kind off.
|
||||
result = await s.loader.load({ "limit-check": ["cap"] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
|
||||
});
|
||||
|
||||
test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createLoader, loadInstalled } from '../src/loader.mjs';
|
||||
|
||||
test('installed system types describe the five modules and unknown names refuse', async () => {
|
||||
const { modules, refused } = await loadInstalled('system-type');
|
||||
assert.deepEqual(refused, []);
|
||||
assert.deepEqual(modules.map(m => m.name), ['business-management', 'coding', 'creative-writing', 'marketing', 'montage']);
|
||||
for (const { name, module } of modules) {
|
||||
const info = module.systemType();
|
||||
assert.equal(info.id, name);
|
||||
assert.ok(info.label && info.description && Array.isArray(info.toolsets));
|
||||
if (name === 'montage') assert.equal(info.label, 'Mosaic Montage');
|
||||
}
|
||||
const loader = createLoader({ log() {} });
|
||||
const result = await loader.load({ 'system-type': ['unknown'] });
|
||||
assert.equal(result.refused.length, 1);
|
||||
assert.deepEqual(result.loaded['system-type'], []);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"prd": "docs/prd/mosaic-stack.md",
|
||||
"version": "1.19",
|
||||
"version": "1.20",
|
||||
"requirements": [
|
||||
{ "id": "REQ-ROLE-1", "title": "Each role is a reviewed roles/<name>.json: contract, authority map, credentials" },
|
||||
{ "id": "REQ-ROLE-2", "title": "One holder per role instance, one per assistant session; the chief of staff may end a run, others need a decision" },
|
||||
@@ -10,10 +10,13 @@
|
||||
{ "id": "REQ-ROLE-6", "title": "Agents are proactive: each role contract says to act within its grants without waiting to be asked" },
|
||||
{ "id": "REQ-COS-1", "title": "The chief of staff runs the organization: hires, terminates, launches; never talks to the user" },
|
||||
{ "id": "REQ-COS-2", "title": "The PM asks the chief of staff for staff; a staff request names the roles and how many of each" },
|
||||
{ "id": "REQ-ASSIST-1", "title": "The assistant is the user's main agent and passes organization work to the chief of staff" },
|
||||
{ "id": "REQ-ASSIST-1", "title": "One assistant per user across organizations, with shared memory, passes work to each chief of staff" },
|
||||
{ "id": "REQ-ASSIST-2", "title": "The broker limits the assistant to the user and the chief of staff in both directions; refusals logged" },
|
||||
{ "id": "REQ-ASSIST-3", "title": "The assistant tells the user what other roles sent it in its own words and does not relay their text" },
|
||||
{ "id": "REQ-COS-3", "title": "The chief of staff hires and fires role instances without a decision, within fixed bounds; the user forbids per agent" },
|
||||
{ "id": "REQ-AGENT-1", "title": "Agents are configured at root, organization and project levels; a project's agents are their union" },
|
||||
{ "id": "REQ-AGENT-2", "title": "The user can see and change agent, organization, project and session configuration through the write path" },
|
||||
{ "id": "REQ-AGENT-3", "title": "The chief hires and fires at all levels; root-level changes need user approval" },
|
||||
{ "id": "REQ-VAR-1", "title": "Variables in four layers, declared in code; limits intersect; unknown keys refused" },
|
||||
{ "id": "REQ-VAR-2", "title": "One system config, never auto-written; secrets only as references the broker reads" },
|
||||
{ "id": "REQ-CRED-1", "title": "Each role instance has its own Gitea and Vikunja token, held by the broker" },
|
||||
@@ -37,7 +40,7 @@
|
||||
{ "id": "REQ-WAIT-3", "title": "An action added to the vocabulary is a hard wait until a PRD version says it is not" },
|
||||
{ "id": "REQ-MSG-1", "title": "Messages are addressed to a role, append-only, delivered to the current holder; assistant messages name a thread" },
|
||||
{ "id": "REQ-MSG-2", "title": "Messages can be addressed by an instance name unique in its business; the message records the name and the instance" },
|
||||
{ "id": "REQ-TASK-1", "title": "Tasks live in Vikunja, each citing a requirement id; the verbs enforce field writers" },
|
||||
{ "id": "REQ-TASK-1", "title": "Each organization stores tasks through a task tracker module; Vikunja is optional" },
|
||||
{ "id": "REQ-TASK-2", "title": "Polling is the source of truth: a 30-second board poll and an hourly reconcile" },
|
||||
{ "id": "REQ-TASK-3", "title": "The installer points at an existing Vikunja or deploys the unmodified upstream image" },
|
||||
{ "id": "REQ-TASK-4", "title": "The queue keeps the agents' lock, review rounds and audit log; no two-way sync" },
|
||||
@@ -51,7 +54,15 @@
|
||||
{ "id": "REQ-CLI-2", "title": "The assistant session mosaic talks to is launched and owned by the stack, not T3" },
|
||||
{ "id": "REQ-WEB-1", "title": "The WebUI shows the CLI's data, a chat area of projects and threads, and Settings for agent limits" },
|
||||
{ "id": "REQ-WEB-2", "title": "The Console resolves an open decision with one button per option, only for a human it signed in itself (OIDC)" },
|
||||
{ "id": "REQ-WEB-3", "title": "The Console restarts the bus and reloads the organization file, gated and trailed; reload applies launch.instances live" },
|
||||
{ "id": "REQ-WEB-3", "title": "Human-gated Console restart, bus restart, reload and agent model controls; dev-local owner needs no sign-in" },
|
||||
{ "id": "REQ-WS-1", "title": "The WebUI always shows all organizations in a collapsible sidebar, with no switcher" },
|
||||
{ "id": "REQ-WS-2", "title": "The Console builds shared views across the user's organization buses, subject to membership" },
|
||||
{ "id": "REQ-WS-3", "title": "The main dashboard sums activity, work and metrics across the user's organizations and projects" },
|
||||
{ "id": "REQ-WS-4", "title": "Each organization has a dashboard for docs, settings, KPIs, metrics and active sessions" },
|
||||
{ "id": "REQ-WS-5", "title": "The projects list spans organizations, sorts and filters, and shows ownership" },
|
||||
{ "id": "REQ-WS-6", "title": "One inbox holds items needing the user from all organizations, projects and sessions" },
|
||||
{ "id": "REQ-WS-7", "title": "One view shows cross-organization activity; organizations and projects open side by side" },
|
||||
{ "id": "REQ-WS-8", "title": "The tasks view aggregates every organization, with Kanban and list layouts and filters" },
|
||||
{ "id": "REQ-LAUNCH-1", "title": "The chief of staff launches role sessions within the agent limits, logged and revocable; threads start on demand" },
|
||||
{ "id": "REQ-LIMIT-1", "title": "Agent limits have no code ceiling and are set from WebUI Settings and mosaic: total, per provider, per model" },
|
||||
{ "id": "REQ-LIMIT-2", "title": "A daily quota per OAuth account or CLIProxyAPI client key, counting only the stack's own use" },
|
||||
@@ -59,6 +70,8 @@
|
||||
{ "id": "REQ-LIMIT-4", "title": "Usage sources and limit checks are the first plug-in kinds; an unknown turn is logged and shown, never counted as zero" },
|
||||
{ "id": "REQ-PLUG-1", "title": "Every part the core calls out to is a plug-in kind; modules load from packages/plugins, switched on in the org file" },
|
||||
{ "id": "REQ-PLUG-2", "title": "New call-outs go through a plug-in kind from the start; a hard-coded part converts when a row changes it" },
|
||||
{ "id": "REQ-SYS-1", "title": "System types are a plug-in kind; organizations enable a list of modules" },
|
||||
{ "id": "REQ-SYS-2", "title": "Projects use enabled system types and their agents receive the corresponding toolsets" },
|
||||
{ "id": "REQ-HARN-1", "title": "The meta-harness generates each session's launch bundle from role and variables" },
|
||||
{ "id": "REQ-HARN-2", "title": "Credential scope, verbs, the tool limit and a container enforce the rules" },
|
||||
{ "id": "REQ-EVT-1", "title": "Every slice 1 step emits an event; measures come from events" },
|
||||
@@ -78,7 +91,7 @@
|
||||
{ "id": "REQ-DISK-14", "title": "Events" },
|
||||
{ "id": "REQ-ORG-1", "title": "Business becomes organization; an install holds 1-N, each with its own bus as tenant boundary" },
|
||||
{ "id": "REQ-ORG-2", "title": "Data model: install > organization > projects > threads" },
|
||||
{ "id": "REQ-USER-1", "title": "Users and RBAC at install and organization level; an agent reaches only its allowed projects and threads" },
|
||||
{ "id": "REQ-USER-1", "title": "Users and RBAC at install and organization level; agents are scoped to one organization except the assistant" },
|
||||
{ "id": "REQ-USER-2", "title": "Only a thread's owning user, their assistant and the chief of staff's replies reach the thread" },
|
||||
{ "id": "REQ-THREAD-1", "title": "Threads live under projects" },
|
||||
{ "id": "REQ-THREAD-2", "title": "The user creates projects and named threads; a thread holds independent assistant sessions, each with kept history" },
|
||||
@@ -91,6 +104,8 @@
|
||||
{ "id": "REQ-SESS-1", "title": "Each session shows its lifetime count of completed compactions on pi and Claude Code; an unproven count shows unknown" },
|
||||
{ "id": "REQ-SESS-2", "title": "Clear context retires a session after a safe stop and starts a fresh successor with none of the old context" },
|
||||
{ "id": "REQ-SESS-3", "title": "Every session start passes the launch checks: authority, route, revocation, kill switch, limits and admission" },
|
||||
{ "id": "REQ-ACCT-1", "title": "Deployed systems require sign-in; users create and belong to organizations" },
|
||||
{ "id": "REQ-ACCT-2", "title": "Organization owners invite users with a chosen grant" },
|
||||
{ "id": "REQ-LIB-1", "title": "A human-set library maps the user's repository onto universes, books, types, playgrounds, supporting and archive folders" },
|
||||
{ "id": "REQ-LIB-2", "title": "Scope comes from the path or a DOCX's source, lookups default to canon, and only canon files are ever cited as canon" },
|
||||
{ "id": "REQ-LIB-3", "title": "Universes stay separate; a crossover is a hybrid universe only a human creates, reading its sources' canon" },
|
||||
@@ -102,6 +117,8 @@
|
||||
{ "id": "REQ-LIB-9", "title": "The Console's DOCX editor: only a human saves, each save is one commit, and the editor code is vendored and pinned" },
|
||||
{ "id": "REQ-INST-1", "title": "The installer offers optional components: pinned, health-checked, removable, switched on in config; first is SearXNG" },
|
||||
{ "id": "REQ-INST-2", "title": "With no OIDC provider to configure, the installer deploys a bundled Pocket ID, Q203 (post-v1)" },
|
||||
{ "id": "REQ-INST-3", "title": "One-command install of a named instance from a profile, and one-command upgrade with backup and rollback" }
|
||||
{ "id": "REQ-INST-3", "title": "One-command install of a named instance from a profile, and one-command upgrade with backup and rollback" },
|
||||
{ "id": "REQ-SETUP-1", "title": "Quick Setup initializes the project records" },
|
||||
{ "id": "REQ-SETUP-2", "title": "The assistant offers an onboarding interview and drafts a north star and first PRD for user approval" }
|
||||
]
|
||||
}
|
||||
@@ -18,12 +18,12 @@ test("the list holds the PRD's requirement ids, in its order, at its version", (
|
||||
const ids = [...prd.matchAll(/^- \*\*(REQ-[A-Z]+-[0-9]+)\.\*\*/gm)].map((m) => m[1]);
|
||||
// Any other mention of a bold id would be a bullet this pattern missed.
|
||||
assert.equal(ids.length, [...prd.matchAll(/\*\*REQ-/g)].length);
|
||||
assert.equal(ids.length, 101);
|
||||
assert.equal(ids.length, 118);
|
||||
assert.deepEqual(list.requirements.map((r) => r.id), ids);
|
||||
assert.match(prd, new RegExp(`^- Status: approved, version ${list.version.replace('.', '\\.')}\\.`, 'm'));
|
||||
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.19']);
|
||||
assert.match(prd, /^- Status: approved, version 1\.19\. Jason approved the exact text in Q289\.$/m);
|
||||
assert.match(prd, /^- Prior approval history, through version 1\.18:/m);
|
||||
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.20']);
|
||||
assert.match(prd, /^- Status: approved, version 1\.20\. Jason approved draft 2 on 2026-11-09\.$/m);
|
||||
assert.match(prd, /^- Prior approval history, through version 1\.19:/m);
|
||||
});
|
||||
|
||||
test('the committed list loads frozen', () => {
|
||||
@@ -76,7 +76,7 @@ test('the schema refuses any other shape', () => {
|
||||
const twice = raw();
|
||||
twice.requirements.push(structuredClone(twice.requirements[0]));
|
||||
writeFileSync(join(dir, 'twice.json'), JSON.stringify(twice));
|
||||
assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[101].id: REQ-ROLE-1 twice'));
|
||||
assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[118].id: REQ-ROLE-1 twice'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ baseline CLI.
|
||||
| Recover a stopped bus with stale host state | `mosaic bus recover <id>` | CLI only |
|
||||
| Inspect or clear a dead lifecycle lock | `mosaic org lock show <id>` / `mosaic org lock clear <id>` | CLI only |
|
||||
| Start, stop or restart a bus | `mosaic bus start <id>` / `mosaic bus stop <id>` / `mosaic bus restart <id>` | S6 integration hook |
|
||||
| Restart (or first start) an organization's bus | `mosaic bus restart <id>`, or `mosaic bus start <id>` for a bus never started | Enabled (F3): the owner's **Restart bus** control, offered where a page says the bus is down or a change needs a restart; trailed |
|
||||
| Restart the Console | Stop and run `mosaic console start` again | Enabled (F3) when `mosaic console start` runs it in the foreground: Settings > Console; the owner only; trailed. Other Consoles answer `not-supervised` |
|
||||
| Create chat project linked to a configured repository | `mosaic project create <name> --repo <project> --org <id>` | Enabled |
|
||||
| Create a thread | `mosaic thread create <project> <name> --org <id>` | Planned enabled |
|
||||
| Open a thread | `mosaic thread open <thread> --org <id>` | Planned enabled |
|
||||
@@ -34,19 +36,36 @@ baseline CLI.
|
||||
| Mark a board session seen/unseen; reply to a board seat | No CLI equivalent identified | Unavailable |
|
||||
| Approve or deny a decision | `mosaic decide <id> <choice> --org <id>` | Owner-only confirmation and resolution on the selected organization bus |
|
||||
| Answer, attach, change mode, cancel or revoke a request | No equivalent CLI identified | Unavailable |
|
||||
| Edit or reset launch/assistant limits | `mosaic limits set` / `mosaic limits reset` | Unavailable pending parity |
|
||||
| Rename an instance | `mosaic name` | Unavailable pending parity |
|
||||
| Stop an assistant | No equivalent CLI identified | Unavailable |
|
||||
| Add, change or delete an instance; launch, regenerate or terminate an agent; change a veto | `mosaic org instance add`, `set`, `delete`, `launch`, `regenerate`, `terminate`; `mosaic org veto` (old spelling `mosaic business instance`, `mosaic business veto`) | Unavailable pending parity |
|
||||
| Change routes or enabled harnesses/providers | `mosaic org instance set`; `mosaic plugins set` | Unavailable pending parity |
|
||||
| Edit or reset launch/assistant limits | `mosaic limits set` / `mosaic limits reset` | Enabled (F3) for the owner on a selected organization's bus; unavailable on other Consoles pending parity |
|
||||
| Rename an instance | `mosaic name` | Enabled (F3) for the owner on a selected organization's bus; unavailable elsewhere pending parity |
|
||||
| Stop an assistant | No equivalent CLI identified | Enabled (F3) for the owner on a selected organization's bus; unavailable elsewhere |
|
||||
| Add, change or delete an instance; launch, regenerate or terminate an agent; change a veto | `mosaic org instance add`, `set`, `delete`, `launch`, `regenerate`, `terminate`; `mosaic org veto` (old spelling `mosaic business instance`, `mosaic business veto`) | Enabled (F3) for the owner on a selected organization's bus, with **Restart NAME now** (terminate, then launch) after a route change; unavailable elsewhere pending parity |
|
||||
| Change routes or enabled harnesses/providers | `mosaic org instance set`; `mosaic plugins set` | Enabled (F3) for the owner on a selected organization's bus; the page lists how the change reaches each saved agent; unavailable elsewhere pending parity |
|
||||
| Halt or resume processing | `mosaic halt` / `mosaic resume` | Unavailable pending parity |
|
||||
| Save a project file or revert library content | No equivalent CLI identified | Unavailable |
|
||||
| Change a project remote | No equivalent CLI identified | Unavailable |
|
||||
| Start, submit or cancel proxy sign-in; add an API key; set account priority or models | No equivalent CLI identified | Unavailable |
|
||||
| Start, submit or cancel proxy sign-in; add an API key; set account priority or models | No equivalent CLI identified | Enabled (F3) for the owner on a selected organization's bus once its proxy is set up; setup itself stays `mosaic proxy init` in a terminal |
|
||||
|
||||
Browser navigation, local appearance preferences and unsaved drafts are local
|
||||
state, not server writes. Signing out is a session action.
|
||||
|
||||
F3 writes (Console restart, bus restart, and the owner's routing, agent and
|
||||
instance changes on an organization) append one line each to
|
||||
`<dataRoot>/console/actions.jsonl` (0600): who (provider, issuer, subject,
|
||||
name), when, what and the outcome. A restart that cannot write its line is
|
||||
refused. On a Console with sign-in, no tester, granted user or API token
|
||||
reaches these controls: each needs the signed-in owner, the page's exact Origin
|
||||
and `Sec-Fetch-Site: same-origin`. Those headers stop another site's page, not
|
||||
a local program, which can send any header it likes.
|
||||
|
||||
A dev local owner Console (`--dev-local-owner`) has no sign-in. Every request
|
||||
that passes the Host and Origin checks acts as the owner, and this includes the
|
||||
F3 controls. An agent launched on the same machine runs as the same OS user, so
|
||||
it can restart the Console or a bus and change routing, agents and settings just
|
||||
as the owner's page can. The startup warning says so. Use a dev instance for UI
|
||||
testing only. The real boundary is sign-in (`--sso`); a refusal for local agent
|
||||
peers in dev mode is a recorded follow-up.
|
||||
|
||||
### Route registration
|
||||
|
||||
A module may export `registerRoutes(table)` and be included with one entry in
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// F3: the Console's trail of human operations (Console restart, org bus restart, route and
|
||||
// instance changes made from the page). One JSON line per use: who, when, what, outcome.
|
||||
// The file is <dataRoot>/console/actions.jsonl, owner-only. A write that cannot be made safely
|
||||
// throws, and the caller refuses the operation (fail closed).
|
||||
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, writeSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// The person, never a token: provider, issuer, subject and display name of the session.
|
||||
export const actor = session => ({ provider: session.provider, iss: session.iss, sub: session.sub, ...(typeof session.name === 'string' ? { name: session.name } : {}) });
|
||||
|
||||
export function actionTrail(dir) {
|
||||
const file = join(dir, 'actions.jsonl');
|
||||
return entry => {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const st = lstatSync(dir);
|
||||
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077) !== 0) throw new Error('unsafe trail directory');
|
||||
const fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
||||
try {
|
||||
const f = fstatSync(fd);
|
||||
if (!f.isFile() || f.uid !== process.getuid() || (f.mode & 0o777) !== 0o600 || f.nlink !== 1) throw new Error('unsafe trail file');
|
||||
writeSync(fd, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`);
|
||||
} finally { closeSync(fd); }
|
||||
};
|
||||
}
|
||||
+21
-38
@@ -27,19 +27,24 @@ export const subjectOk = s => typeof s === 'string' && s.length > 0 && s.length
|
||||
// Same protocol as humanTransport in packages/cli/src/transport.mjs: the
|
||||
// request on stdin, JSON on stdout, or a bus error code as the last
|
||||
// `^[a-z-]{1,64}$` line of stderr.
|
||||
export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
|
||||
return (verb, args = {}) => transport({ cli, socket, env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, input: { business, verb, args }, timeoutMs, maxBytes });
|
||||
export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024, signal, deadline }) {
|
||||
return (verb, args = {}) => transport({ cli, socket, signal, deadline, env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, input: { business, verb, args }, timeoutMs, maxBytes });
|
||||
}
|
||||
function transport({ cli, socket, env, input, timeoutMs, maxBytes }) {
|
||||
function transport({ cli, socket, env, input, timeoutMs, maxBytes, signal, deadline }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const remaining = deadline === undefined ? timeoutMs : Math.min(timeoutMs, deadline - Date.now());
|
||||
if (signal?.aborted || (deadline !== undefined && remaining <= 0)) return reject(new BusReadError('outcome-unknown', 'the bus transport deadline expired'));
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [cli, socket], { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
|
||||
const out = [], err = [];
|
||||
let size = 0, settled = false;
|
||||
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
|
||||
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs);
|
||||
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); signal?.removeEventListener('abort', abort); fn(value); } };
|
||||
const abort = () => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport deadline expired')); };
|
||||
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, remaining);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) abort();
|
||||
child.stdout.on('data', chunk => {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); }
|
||||
@@ -82,9 +87,10 @@ export function consoleCall({ dataRoot, socket, business = null, env = process.e
|
||||
|
||||
// The business is the --business flag, or else the live bus host's, read
|
||||
// on every request so a host started after the Console is picked up.
|
||||
export function busCaller({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) {
|
||||
export function busCaller({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, signal, deadline }) {
|
||||
return async (verb, args) => {
|
||||
return humanCall({ socket, business: business || hostBusiness(dataRoot), env, cli, timeoutMs })(verb, args);
|
||||
if (signal?.aborted || (deadline !== undefined && Date.now() >= deadline)) throw new BusReadError('outcome-unknown', 'the bus transport deadline expired');
|
||||
return humanCall({ socket, business: business || hostBusiness(dataRoot), env, cli, timeoutMs, signal, deadline })(verb, args);
|
||||
};
|
||||
}
|
||||
function hostBusiness(dataRoot) {
|
||||
@@ -103,36 +109,10 @@ export const USER_CLI = fileURLToPath(new URL('../../bus/src/user-cli.mjs', impo
|
||||
// The Console sets the user nonce itself, so user-cli.mjs doesn't re-exec and the broker sees the
|
||||
// Console (`cli.mjs serve`) as its parent (principal.mjs). The human nonce is dropped, so this child
|
||||
// can never pass as human-cli, and it only ever runs USER_CLI.
|
||||
export function userCall({ socket, business, user, env = process.env, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
|
||||
export function userCall({ socket, business, user, env = process.env, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024, signal, deadline }) {
|
||||
const { MOSAIC_BUS_CLI_NONCE, ...rest } = env;
|
||||
return (verb, args = {}) => new Promise((resolve, reject) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [USER_CLI, socket], { env: { ...rest, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') }, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
|
||||
const out = [], err = [];
|
||||
let size = 0, settled = false;
|
||||
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
|
||||
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs);
|
||||
child.stdout.on('data', chunk => {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); }
|
||||
out.push(chunk);
|
||||
});
|
||||
child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); });
|
||||
child.on('error', () => done(reject, new BusReadError('outcome-unknown', 'the bus transport did not start')));
|
||||
child.on('close', status => {
|
||||
if (status === null) return done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish'));
|
||||
if (status !== 0) {
|
||||
const code = Buffer.concat(err).toString('utf8').trim().split('\n').reverse().find(l => /^[a-z-]{1,64}$/.test(l)) ?? 'invalid-response';
|
||||
return done(reject, new BusReadError(code));
|
||||
}
|
||||
try { done(resolve, JSON.parse(Buffer.concat(out).toString('utf8'))); }
|
||||
catch { done(reject, new BusReadError('invalid-response')); }
|
||||
});
|
||||
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
|
||||
child.stdin.end(`${JSON.stringify({ business, user, verb, args })}\n`);
|
||||
});
|
||||
return (verb, args = {}) => transport({ cli: USER_CLI, socket, env: { ...rest, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') },
|
||||
input: { business, user, verb, args }, timeoutMs, maxBytes, signal, deadline });
|
||||
}
|
||||
|
||||
// The business is the --business flag, or else the live bus host's, read on every call, as busCaller does.
|
||||
@@ -147,9 +127,12 @@ export function busBusiness({ dataRoot, business = null }) {
|
||||
}
|
||||
|
||||
// One granted user's caller, for that user's session only.
|
||||
export function userCaller({ dataRoot, socket, business = null, user, env = process.env, timeoutMs = 30000 }) {
|
||||
export function userCaller({ dataRoot, socket, business = null, user, env = process.env, timeoutMs = 30000, signal, deadline }) {
|
||||
const which = busBusiness({ dataRoot, business });
|
||||
return async (verb, args) => userCall({ socket, business: which(), user, env, timeoutMs })(verb, args);
|
||||
return async (verb, args) => {
|
||||
if (signal?.aborted || (deadline !== undefined && Date.now() >= deadline)) throw new BusReadError('outcome-unknown', 'the bus transport deadline expired');
|
||||
return userCall({ socket, business: which(), user, env, timeoutMs, signal, deadline })(verb, args);
|
||||
};
|
||||
}
|
||||
|
||||
// A send's error, sorted. The broker writes in one transaction, so any code it
|
||||
|
||||
@@ -22,7 +22,9 @@ import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.m
|
||||
import { createSso, readSlot } from './sso.mjs';
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { actionTrail } from './action-trail.mjs';
|
||||
import { RESTART_EXIT } from '../../cli/src/org-console.mjs';
|
||||
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
|
||||
import { selectorScope } from '../../organization/src/selector.mjs';
|
||||
// The bus verifies the Console's exact same-tree script in /proc, whose argv preserves a
|
||||
@@ -40,12 +42,12 @@ if (process.platform === 'linux') {
|
||||
}
|
||||
}
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--supervised] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
|
||||
try {
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
|
||||
if (args.shift() !== 'serve') throw new Error(usage);
|
||||
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
|
||||
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null;
|
||||
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null, supervised = false;
|
||||
const devNames = [];
|
||||
// --org and --business are one selector in this parser's scope (rename design r2). A flag-shaped
|
||||
// or invalid value keeps the old wording.
|
||||
@@ -62,6 +64,8 @@ try {
|
||||
if (flag === '--dev-local-owner') { devOwner = true; continue; }
|
||||
if (flag === '--sso') { ssoOn = true; continue; }
|
||||
if (flag === '--tester-socket') { testerSocket = true; continue; }
|
||||
// F3: `mosaic console start` supervises this process and relaunches it on exit 75.
|
||||
if (flag === '--supervised') { supervised = true; continue; }
|
||||
if (flag === '--tester-port') throw new Error('--tester-port retired; activate explicitly with --tester-socket');
|
||||
const value = args.shift();
|
||||
if (!value) throw new Error(`missing value for ${flag}`);
|
||||
@@ -152,9 +156,12 @@ try {
|
||||
const organizations = devOwner ? organizationConsole({
|
||||
ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance },
|
||||
listOrgs, showOrg, resolveOrg, orgPaths, busStatus: orgBusStatus,
|
||||
options: ({ id, root, paths, session }) => {
|
||||
const owner = busCaller({ dataRoot: root, socket: paths.socket, business: id });
|
||||
const call = session.user === undefined ? owner : userCaller({ dataRoot: root, socket: paths.socket, business: id, user: session.user });
|
||||
// U1 (#1873): the cross-organization read passes `timeoutMs` so a slow bus's children are
|
||||
// killed at its deadline. The org pages pass none and keep the transport default.
|
||||
options: ({ id, root, paths, session, timeoutMs, signal, deadline }) => {
|
||||
const bound = timeoutMs === undefined ? {} : { timeoutMs, signal, deadline };
|
||||
const owner = busCaller({ dataRoot: root, socket: paths.socket, business: id, ...bound });
|
||||
const call = session.user === undefined ? owner : userCaller({ dataRoot: root, socket: paths.socket, business: id, user: session.user, ...bound });
|
||||
return {
|
||||
...consoleBus(call, { send: sendOn, ...(session.user === undefined ? {} : { owner }) }),
|
||||
decisionWrite: session.user === undefined && sendOn ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
|
||||
@@ -165,11 +172,19 @@ try {
|
||||
};
|
||||
},
|
||||
}) : null;
|
||||
let server;
|
||||
let server, testerServer = null;
|
||||
// F3: the restart route closes every listener and connection, then exits RESTART_EXIT for the
|
||||
// supervisor to relaunch. Without --supervised the route answers not-supervised.
|
||||
const restart = supervised ? () => {
|
||||
sso?.close(); testerServer?.close();
|
||||
setTimeout(() => process.exit(RESTART_EXIT), 2000).unref();
|
||||
server.close(() => process.exit(RESTART_EXIT));
|
||||
server.closeAllConnections();
|
||||
} : null;
|
||||
const trail = system ? actionTrail(join(system.dataRoot, 'console')) : null;
|
||||
try {
|
||||
server = await startServer({ ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, preselect: options.business ?? null });
|
||||
server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, preselect: options.business ?? null });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
let testerServer = null;
|
||||
if (testerSocket) {
|
||||
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
|
||||
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// F3: POST /api/console/restart. The owner restarts the Console from Settings.
|
||||
//
|
||||
// The route table's hook fence has already required a signed-in session on the page's own name,
|
||||
// the exact Origin and Sec-Fetch-Site: same-origin. This handler adds the owner, --send, a JSON
|
||||
// `{}` body and the trail. The Console then closes and exits RESTART_EXIT, and the supervisor in
|
||||
// `mosaic console start` relaunches it on the same arguments and address. Org buses are separate
|
||||
// processes and keep running. A Console with no supervisor answers not-supervised and stays up.
|
||||
import { actor } from './action-trail.mjs';
|
||||
|
||||
const json = (res, status, body) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
||||
|
||||
async function emptyBody(req) {
|
||||
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(one(req, 'content-type') ?? '')) return 'JSON body required';
|
||||
let text = '';
|
||||
for await (const chunk of req) { text += chunk; if (text.length > 64) return 'body too large'; }
|
||||
let value;
|
||||
try { value = JSON.parse(text); } catch { return 'invalid JSON'; }
|
||||
return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0 ? null : 'the body is {}';
|
||||
}
|
||||
|
||||
export async function consoleRestartHTTP(req, res, { session = null, owner = false, send = false, restart = null, trail = null, boot, log = () => {} }) {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'restarting the Console is the owner\'s' });
|
||||
if (!send) return json(res, 403, { error: 'read-only', message: 'start the Console with --send to make changes' });
|
||||
const invalid = await emptyBody(req);
|
||||
if (invalid) return json(res, invalid === 'JSON body required' ? 415 : 400, { error: 'invalid-request', message: invalid });
|
||||
if (!restart) return json(res, 409, { error: 'not-supervised', message: 'This Console was not started in the foreground by mosaic console start, so nothing would start it again. Restart it where it was started.' });
|
||||
if (!trail) return json(res, 503, { error: 'trail-unavailable', message: 'this Console has no action trail, so it does not restart itself' });
|
||||
try { trail({ by: actor(session), action: 'console.restart', outcome: 'restarting', boot }); }
|
||||
catch { return json(res, 500, { error: 'trail-unavailable', message: 'the action trail could not be written, so the Console did not restart' }); }
|
||||
log('console: restart requested by the owner');
|
||||
// The answer reaches the page first; then every listener and connection closes.
|
||||
res.once('finish', () => restart());
|
||||
return json(res, 202, { outcome: 'restarting', boot });
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
// POST /api/orgs/ID/setup/queue C2 queueInit mosaic org queue init
|
||||
// POST /api/orgs/ID/setup/project/P C2 projectRepoInit mosaic org project repo init
|
||||
// POST /api/orgs/ID/setup/bus C3 busStart mosaic bus start --detach
|
||||
// POST /api/orgs/ID/restart/bus C3 busRestart mosaic bus restart (F3; busStart when stopped)
|
||||
//
|
||||
// GET /api/orgs (the list) and other /api/orgs/ID/... routes are S5's; this
|
||||
// handler returns false for them.
|
||||
@@ -25,12 +26,13 @@ import { lstatSync } from 'node:fs';
|
||||
import { checkProject, initProject, projectRepo, recordContext } from '../../project/src/index.mjs';
|
||||
import { refuseInsideAgent } from '../../cli/src/transport.mjs';
|
||||
import { join } from 'node:path';
|
||||
import { actor } from './action-trail.mjs';
|
||||
|
||||
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
const MAX_BODY = 64 * 1024;
|
||||
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
|
||||
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
|
||||
// The org-template placeholder (packages/organization/src/org-service.mjs templateRender).
|
||||
export const placeholderRoot = (id, project) => `/__mosaic_org__/${id}/projects/${project}`;
|
||||
// Project fields the server owns. The browser never sees or sends them.
|
||||
@@ -47,6 +49,7 @@ export function verbFor(step, id, opts = {}) {
|
||||
if (step === 'delete') return `mosaic org delete ${id} --confirm ${id} --if-revision ${opts.revision}`;
|
||||
if (step === 'records') return `mosaic project init ${id} ${opts.project}`;
|
||||
if (step === 'bus') return `mosaic bus start ${id} --dev-local-owner --detach`;
|
||||
if (step === 'bus-restart') return `mosaic bus restart ${id}`;
|
||||
throw new Error(`unknown step ${step}`);
|
||||
}
|
||||
|
||||
@@ -295,7 +298,8 @@ export function createOrgAdmin({ service, registry = null, bus = null, context }
|
||||
// The handler. Returns true when it answered. `owner` is the signed-in owner
|
||||
// session (provider, iss, sub) or null; `send` says --send is on; `origin` is
|
||||
// the page's own origin for the write fence.
|
||||
export async function orgAdminHTTP(req, res, { path, search = '', admin, session = null, owner = false, send = false, origin = null, log = () => {} }) {
|
||||
// `trail` (F3) records each bus restart: who, when, which organization and the outcome.
|
||||
export async function orgAdminHTTP(req, res, { path, search = '', admin, session = null, owner = false, send = false, origin = null, log = () => {}, trail = null }) {
|
||||
const org = path.match(ROUTE);
|
||||
const top = ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin'].includes(path) || (path === '/api/orgs' && req.method === 'POST');
|
||||
if (!org && !top) return false;
|
||||
@@ -311,7 +315,7 @@ export async function orgAdminHTTP(req, res, { path, search = '', admin, session
|
||||
const principal = { provider: session.provider, iss: session.iss, sub: session.sub };
|
||||
if (![principal.provider, principal.iss, principal.sub].every(v => typeof v === 'string' && v)) { json(res, 403, { error: 'owner-only' }); return true; }
|
||||
try {
|
||||
await route(req, res, { path, search, org, admin, principal, write, log });
|
||||
await route(req, res, { path, search, org, admin, principal, write, log, trail, by: actor(session) });
|
||||
} catch (error) {
|
||||
if (error?.status) json(res, error.status, { error: error.message, ...(error.detail ? { message: error.detail } : {}), ...(error.errors ? { errors: error.errors } : {}) });
|
||||
else { log(`org-admin: ${path}: ${error?.code ?? error?.name ?? 'error'}`); json(res, 500, { error: 'internal' }); }
|
||||
@@ -385,7 +389,7 @@ const gitOptions = (body, res, step) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
async function route(req, res, { path, search, org, admin, principal, write, log }) {
|
||||
async function route(req, res, { path, search, org, admin, principal, write, log, trail = null, by = null }) {
|
||||
if (path === '/api/org-presets' && !write) {
|
||||
if (new URLSearchParams(search).size) return json(res, 422, { error: 'invalid', message: 'unexpected query parameter' });
|
||||
const service = await need(admin, 'service', res, 'presets'); if (!service) return;
|
||||
@@ -508,6 +512,7 @@ async function route(req, res, { path, search, org, admin, principal, write, log
|
||||
if (kind === 'setup' && !sub && !write) {
|
||||
try { return json(res, 200, await readSetup(admin, ctx, id)); } catch (e) { return refusal(res, e, { step: 'setup', id }); }
|
||||
}
|
||||
if (kind === 'restart') return restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by });
|
||||
if (kind !== 'setup' || !sub || !write) return json(res, 404, { error: 'not found' });
|
||||
|
||||
// Every setup write reads the step back first and never overwrites.
|
||||
@@ -553,3 +558,44 @@ async function route(req, res, { path, search, org, admin, principal, write, log
|
||||
}
|
||||
return json(res, 404, { error: 'not found' });
|
||||
}
|
||||
|
||||
// F3 (REQ-WEB-3): one control restarts an organization's bus and answers with a done receipt. A
|
||||
// running host restarts on the options it was started with (C3 busRestart keeps --dev-local-owner);
|
||||
// a stopped one starts as setup starts it. The trail line is written before anything changes, and
|
||||
// a trail that cannot be written refuses the restart. Stale host state is never cleared here.
|
||||
async function restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by }) {
|
||||
const step = 'bus-restart';
|
||||
if (sub !== 'bus' || !write) return json(res, 404, { error: 'not found' });
|
||||
const body = await readBody(req); strictKeys(body, []);
|
||||
try { await service.showOrg(ctx, id); } catch (e) { return refusal(res, e, { step, id }); }
|
||||
const bus = await need(admin, 'bus', res, step); if (!bus) return;
|
||||
if (!trail) return json(res, 503, { error: 'trail-unavailable', step, message: 'this Console has no action trail, so it does not restart buses' });
|
||||
let status;
|
||||
try { status = await bus.busStatus(ctx, id); }
|
||||
catch (e) {
|
||||
if (['org-unknown', 'org-mismatch', 'org-unavailable', 'org-archived'].includes(e?.code)) return refusal(res, e, { step, id });
|
||||
if (isCliError(e)) {
|
||||
// Refused before anything changed, but the owner asked: the trail records the attempt.
|
||||
try { trail({ by, action: 'bus.restart', org: id, outcome: 'refused', running: null }); } catch { log(`org-admin: ${id} bus restart: trail write failed after refused`); }
|
||||
return json(res, 409, { error: 'bus-refused', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.` });
|
||||
}
|
||||
return refusal(res, e, { step, id });
|
||||
}
|
||||
const running = Number.isSafeInteger(status?.pid);
|
||||
try { trail({ by, action: 'bus.restart', org: id, outcome: 'requested', running }); }
|
||||
catch { return json(res, 500, { error: 'trail-unavailable', step, message: 'the action trail could not be written, so nothing was restarted' }); }
|
||||
const note = outcome => { try { trail({ by, action: 'bus.restart', org: id, outcome }); } catch { log(`org-admin: ${id} bus restart: trail write failed after ${outcome}`); } };
|
||||
let receipt;
|
||||
try {
|
||||
receipt = running ? await bus.busRestart(ctx, id) : await bus.busStart(ctx, id, { detach: true, devLocalOwner: by?.provider === 'local-dev', pm: false });
|
||||
} catch (e) {
|
||||
note('refused');
|
||||
if (!isCliError(e)) return refusal(res, e, { step, id, unknownOk: true });
|
||||
return json(res, 409, { error: /^bus failed to start\b/.test(e.message) ? 'bus-failed' : 'bus-refused', step, message: plainMessage(ctx, e.message) });
|
||||
}
|
||||
note('done');
|
||||
log(`org-admin: ${id} bus ${running ? 'restarted' : 'started'}`);
|
||||
let healthy = null;
|
||||
try { healthy = (await bus.busStatus(ctx, id)).healthy; } catch { /* the receipt still stands */ }
|
||||
return json(res, 200, { outcome: 'done', step, restarted: running, healthy, receipt: forReceipt(ctx, receipt), verb: running ? verbFor('bus-restart', id) : verbFor('bus', id) });
|
||||
}
|
||||
@@ -35,8 +35,40 @@ export function organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPat
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// U1 (#1873): the orgs a session belongs to, for the cross-organization read (workspace-read.mjs).
|
||||
// The same admission as `list` and `resolve`: an org the session does not own is skipped, and a
|
||||
// broken org shows only to its verified owner, as invalid. Bus status and options are lazy, so a
|
||||
// stopped bus never gets a transport; `timeoutMs` bounds each bus read the options make.
|
||||
async function members(session, { timeoutMs } = {}) {
|
||||
const entries = await listOrgs(ctx), result = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.state !== 'active') continue;
|
||||
let verified;
|
||||
try { verified = await admitted(entry.id, session); }
|
||||
catch (error) {
|
||||
if (error?.ownerVerified !== true) continue;
|
||||
result.push({ id: entry.id, invalid: true, reason: publicReason(ctx, error) });
|
||||
continue;
|
||||
}
|
||||
const { org, shown, context } = verified, id = entry.id;
|
||||
result.push({
|
||||
id, doc: shown.doc,
|
||||
// Host paths in a refusal or boot reason become relative to the instance before they leave.
|
||||
async status(bound) {
|
||||
let s;
|
||||
try { s = await busStatus(ctx, id, bound); }
|
||||
catch (error) { throw new Error(plainMessage(ctx, typeof error?.message === 'string' ? error.message : 'bus state could not be read')); }
|
||||
const boot = s?.boot && typeof s.boot === 'object' ? { ok: s.boot.ok, at: s.boot.at, lastGood: s.boot.lastGood ?? null, reason: s.boot.reason == null ? null : plainMessage(ctx, s.boot.reason) } : null;
|
||||
return { healthy: s?.healthy === true, pid: Number.isSafeInteger(s?.pid) ? s.pid : null, boot };
|
||||
},
|
||||
options: bound => options({ id, root: org.root, paths: orgPaths(org.root), session, context, doc: shown.doc, ...(timeoutMs === undefined ? {} : { timeoutMs }), ...bound }),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return Object.freeze({
|
||||
list,
|
||||
members,
|
||||
async resolve(id, session) {
|
||||
const { org, shown, context } = await admitted(id, session);
|
||||
let busHealthy = false;
|
||||
|
||||
@@ -10,8 +10,12 @@
|
||||
const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const POLL_MS = 2000, POLL_LIMIT = 90;
|
||||
const UNKNOWN = 'Outcome unknown. Read the agents again before trying; nothing was retried.';
|
||||
// The same text bus.js shows for an instances.set answer (bus.js RESTART_TEXT, NO_RESTART_TEXT).
|
||||
const RESTART_TEXT = 'Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
|
||||
// The same text bus.js shows for an instances.set answer (bus.js restartText(), NO_RESTART_TEXT).
|
||||
// F3: on an organization Console its owner restarts the bus from the receipt (console-restart.js).
|
||||
const restartText = () => window.MosaicConsole?.busRestart
|
||||
? 'Restart needed: the running bus keeps the roles it loaded at start. Restart the bus to load the change.'
|
||||
: 'Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
|
||||
const busControl = text => (String(text).includes('Restart needed:') ? window.MosaicConsole?.busButton('Restart bus') ?? '' : '');
|
||||
const NO_RESTART_TEXT = 'No restart: the launcher reads the change at its next launch check.';
|
||||
const DONE = { launch: 'Launched', regenerate: 'Regenerated', end: 'Terminated', delete: 'Deleted' };
|
||||
let ctx = null, view = null, busy = false, receipt = '', confirming = null, pending = null, generation = 0;
|
||||
@@ -30,7 +34,7 @@
|
||||
const { txt, esc } = c, title = '<h2 id="agents-title" class="sr-only">Agents</h2>';
|
||||
if (!v) return `<section class="panel" id="agents-section">${title}<p class="small warn">The agents could not be read, so there is nothing to act on. Read again to retry.</p></section>`;
|
||||
const open=usable(), off=!open||busy||pending?' disabled':'', addOff=!open||busy||pending||c.instanceBusyOps.includes('add')?' disabled':'', changeOff=!open||busy||pending||c.instanceBusyOps.includes('change')?' disabled':'', defs=Object.keys(v.definitions||{}), accepted=defs.filter(d=>v.definitions[d]?.accepted);
|
||||
const instanceStatus=op=>{const text=c.instanceReceipts[op]||'', current=c.instanceReceiptOp===op||(!c.instanceReceiptOp&&op==='add');return `<p role="status" data-instance-receipt="${op}"${current?' id="instances-receipt"':''} class="${text.startsWith('Outcome unknown')?'banner ref':text.includes('Draft kept')?'banner err':text.startsWith('Added')||text.startsWith('Changed')?'settings-success':''}">${txt(text)}</p>`;};
|
||||
const instanceStatus=op=>{const text=c.instanceReceipts[op]||'', current=c.instanceReceiptOp===op||(!c.instanceReceiptOp&&op==='add');return `<p role="status" data-instance-receipt="${op}"${current?' id="instances-receipt"':''} class="${text.startsWith('Outcome unknown')?'banner ref':text.includes('Draft kept')?'banner err':text.startsWith('Added')||text.startsWith('Changed')?'settings-success':''}">${txt(text)}</p>${busControl(text)}`;};
|
||||
const option=(name,selected,first)=>`<option value="">${first}</option>${accepted.map(d=>`<option value="${esc(d)}"${d===selected?' selected':''}>${txt(d)}</option>`).join('')}`;
|
||||
const button=(action,name,label)=>`<button type="button"${['keep','unkeep'].includes(action)?' role="menuitem"':''} data-agent-action="${esc(action)}" data-agent-name="${esc(name)}"${off}>${txt(label)}</button>`;
|
||||
const rows=Object.entries(v.instances).map(([name,e])=>{
|
||||
@@ -45,7 +49,7 @@
|
||||
const noHire=Array.isArray(v.noHire)?v.noHire:[], retired=Array.isArray(v.retired)?v.retired:[];
|
||||
const hiring=!c.isTester?`<section class="panel"><h3>Hiring</h3><p>Turn hiring off to stop new agents of a definition.</p><div class="hiring-grid">${defs.map(n=>`<div>${open?`<button type="button" role="switch" aria-label="Hiring allowed for ${esc(n)}" aria-checked="${!noHire.includes(n)}" data-agent-action="${noHire.includes(n)?'hire':'nohire'}" data-agent-name="${esc(n)}"${off}><span class="switch-track"></span></button>`:`<span>${noHire.includes(n)?'Off':'On'}</span>`}<span class="hiring-name">${txt(n)}</span></div>`).join('')}</div>${retired.length?`<p>Retired names, never reused: ${retired.map(txt).join(', ')}.</p>`:''}</section>`:'';
|
||||
const state=pending?`<p id="agents-pending">Request <code>${txt(pending.request)}</code> to ${txt(pending.action)} ${txt(pending.instance)}: ${txt(pending.state)}.</p>`:'';
|
||||
return `<div id="agents-section">${title}${state}<section class="panel"><div class="table-wrap" role="region" aria-label="Agents"><table id="agents-table"><thead><tr><th id="agent-col-name" scope="col">Agent</th><th id="agent-col-definition" scope="col">Definition</th><th id="agent-col-launch" scope="col">Launch</th><th id="agent-col-kept" scope="col">Kept</th><th id="agent-col-running" scope="col">Running</th><th scope="col">Actions</th></tr></thead><tbody>${rows}</tbody></table></div><p role="status" id="agents-receipt">${txt(receipt)}</p>${!open||c.isTester?instanceStatus('add'):''}</section>${add}${hiring}<dialog id="agent-dialog" aria-labelledby="agent-dialog-title"><h2 id="agent-dialog-title"></h2><p id="agent-dialog-copy"></p><label id="agent-reason-label" hidden>Reason <input id="agent-dialog-reason" maxlength="500"></label><div><button type="button" id="agent-dialog-confirm">Confirm</button><button type="button" id="agent-dialog-cancel">Cancel</button></div></dialog></div>`;
|
||||
return `<div id="agents-section">${title}${state}<section class="panel"><div class="table-wrap" role="region" aria-label="Agents"><table id="agents-table"><thead><tr><th id="agent-col-name" scope="col">Agent</th><th id="agent-col-definition" scope="col">Definition</th><th id="agent-col-launch" scope="col">Launch</th><th id="agent-col-kept" scope="col">Kept</th><th id="agent-col-running" scope="col">Running</th><th scope="col">Actions</th></tr></thead><tbody>${rows}</tbody></table></div><p role="status" id="agents-receipt">${txt(receipt)}</p>${busControl(receipt)}${!open||c.isTester?instanceStatus('add'):''}</section>${add}${hiring}<dialog id="agent-dialog" aria-labelledby="agent-dialog-title"><h2 id="agent-dialog-title"></h2><p id="agent-dialog-copy"></p><label id="agent-reason-label" hidden>Reason <input id="agent-dialog-reason" maxlength="500"></label><div><button type="button" id="agent-dialog-confirm">Confirm</button><button type="button" id="agent-dialog-cancel">Cancel</button></div></dialog></div>`;
|
||||
}
|
||||
|
||||
// One POST. `null` means the page signed out; otherwise the status and the parsed answer.
|
||||
@@ -79,7 +83,7 @@
|
||||
const { ok, result } = answer;
|
||||
if (!ok) { receipt = refused(result); redraw(); return; }
|
||||
// Row 194 b6 (webby-0094 B1): a veto says whether the reload took, as a create does.
|
||||
if (url === '/api/veto') { receipt = `${{ keep: 'Kept', unkeep: 'Unkept', nohire: 'No hire for', hire: 'Hiring allowed for' }[action]} ${name}. ${result.restart ? RESTART_TEXT : NO_RESTART_TEXT}`; reread(); return; }
|
||||
if (url === '/api/veto') { receipt = `${{ keep: 'Kept', unkeep: 'Unkept', nohire: 'No hire for', hire: 'Hiring allowed for' }[action]} ${name}. ${result.restart ? restartText() : NO_RESTART_TEXT}`; reread(); return; }
|
||||
if (!REQUEST_ID.test(result?.request ?? '')) { receipt = UNKNOWN; redraw(); return; }
|
||||
if (action === 'end') delete reasons[name];
|
||||
pending = { request: result.request, action, instance: name, state: 'open', tries: 0 };
|
||||
@@ -122,7 +126,7 @@
|
||||
try { answer = await post('/api/instances', { revision: view.revision, instance, op: 'add', definition }); } catch { answer = { ok: false, result: { sent: 'unknown' } }; }
|
||||
busy = false;
|
||||
if (answer === null) return;
|
||||
if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}. ${answer.result.restart ? RESTART_TEXT : NO_RESTART_TEXT}`; reread(); }
|
||||
if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}. ${answer.result.restart ? restartText() : NO_RESTART_TEXT}`; reread(); }
|
||||
else { receipt = answer.ok ? UNKNOWN : `${refused(answer.result)} Draft kept.`; redraw(); }
|
||||
}
|
||||
|
||||
@@ -151,5 +155,78 @@
|
||||
// A sign-out stops any poll; nothing that was asked is retried.
|
||||
document.addEventListener('mosaic-signed-out', () => { generation++; pending = null; confirming = null; });
|
||||
|
||||
window.MosaicAgents = Object.freeze({ section });
|
||||
// F3: restart an agent so a route change reaches it now, offered by the Routing receipt. One
|
||||
// terminate; then reads of the instances until the run has left (up to the 60 s regenerate
|
||||
// allows); then one launch. A thread definition is not launched: its next message starts it.
|
||||
// A refusal, a sign-out or an unknown outcome stops the chain and says so; nothing is retried.
|
||||
const restarts = Object.create(null);
|
||||
const RESTART_WAIT = 30;
|
||||
function restartMarkup(name, thread = false) {
|
||||
const r = restarts[name];
|
||||
return `<span class="agent-restart"><button type="button" class="btn" data-agent-restart="${esc(name)}" data-agent-thread="${thread ? 'yes' : 'no'}"${r?.busy ? ' disabled' : ''}>${r?.busy ? 'Restarting…' : `Restart ${esc(name)} now`}</button> <span role="status" data-agent-restart-status="${esc(name)}">${esc(r?.text ?? '')}</span></span>`;
|
||||
}
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
const paintRestart = name => document.querySelectorAll('.agent-restart').forEach(el => {
|
||||
const b = el.querySelector('[data-agent-restart]');
|
||||
if (b?.dataset.agentRestart === name) el.outerHTML = restartMarkup(name, b.dataset.agentThread === 'yes');
|
||||
});
|
||||
async function send(url, payload) {
|
||||
let res, result = null;
|
||||
try { res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), cache: 'no-store', redirect: 'manual' }); }
|
||||
catch { return { unknown: true }; }
|
||||
if (res.type === 'opaqueredirect') return { signedOut: true };
|
||||
try { result = await res.json(); } catch { return { unknown: true }; }
|
||||
return { ok: res.ok, result };
|
||||
}
|
||||
// The request's closing read, or a reason the chain stops.
|
||||
async function closing(request) {
|
||||
for (let tries = 0; tries < POLL_LIMIT; tries++) {
|
||||
await new Promise(r => setTimeout(r, POLL_MS));
|
||||
let res, r = null;
|
||||
try { res = await fetch(`/api/agent/request?id=${encodeURIComponent(request)}`, { cache: 'no-store', redirect: 'manual' }); } catch { continue; }
|
||||
if (res.type === 'opaqueredirect') return { stop: 'Sign in again, then read the agents.' };
|
||||
try { r = await res.json(); } catch { continue; }
|
||||
if (res.ok && r?.request === request && r.state === 'closed') return { done: r };
|
||||
}
|
||||
return { stop: `Request ${request} is still open. Read the agents again later.` };
|
||||
}
|
||||
async function runs(name) {
|
||||
try {
|
||||
const res = await fetch('/api/bus/instances', { cache: 'no-store', redirect: 'manual' });
|
||||
if (!res.ok || res.type === 'opaqueredirect') return null;
|
||||
const e = (await res.json())?.rows?.[0]?.instances?.[name];
|
||||
return e ? (Array.isArray(e.running) ? e.running.length : 0) : 0;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function restartAgent(name, thread) {
|
||||
const r = restarts[name] = { busy: true, text: `Terminating ${name}…` };
|
||||
const stop = text => { r.busy = false; r.text = text; paintRestart(name); };
|
||||
paintRestart(name);
|
||||
const asked = await send('/api/agent/terminate', { instance: name, reason: 'route change' });
|
||||
if (asked.signedOut) return stop('Sign in again, then retry.');
|
||||
if (asked.unknown || !asked.ok || !REQUEST_ID.test(asked.result?.request ?? '')) return stop(asked.ok || asked.unknown ? UNKNOWN : refused(asked.result));
|
||||
const ended = await closing(asked.result.request);
|
||||
if (ended.stop) return stop(ended.stop);
|
||||
if (ended.done.reason) return stop(`${name}: ${ended.done.reason}. It was not restarted.`);
|
||||
r.text = `Terminated ${name}. Waiting for its session to end…`; paintRestart(name);
|
||||
let left = false;
|
||||
for (let i = 0; i < RESTART_WAIT && !left; i++) { await new Promise(res => setTimeout(res, POLL_MS)); left = (await runs(name)) === 0; }
|
||||
if (!left) return stop(`${name} was asked to end but its session is still running. Launch it from Agents once it has ended.`);
|
||||
if (thread) return stop(`Terminated ${name} at ${new Date().toLocaleTimeString()}. Its next message starts it on the new route.`);
|
||||
r.text = `Launching ${name}…`; paintRestart(name);
|
||||
const launch = await send('/api/agent/launch', { instance: name });
|
||||
if (launch.signedOut) return stop('Sign in again. It was terminated and not launched.');
|
||||
if (launch.unknown || !launch.ok || !REQUEST_ID.test(launch.result?.request ?? '')) return stop(launch.ok || launch.unknown ? `${UNKNOWN} It was terminated.` : `${refused(launch.result)} It was terminated and not launched.`);
|
||||
const started = await closing(launch.result.request);
|
||||
if (started.stop) return stop(started.stop);
|
||||
if (started.done.reason) return stop(`${name}: ${started.done.reason}. It was terminated and not launched.`);
|
||||
return stop(`Restarted ${name} on the new route${started.done.done?.run ? ` (run ${started.done.done.run})` : ''}.`);
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const b = e.target.closest?.('[data-agent-restart]');
|
||||
if (!b || b.disabled || restarts[b.dataset.agentRestart]?.busy) return;
|
||||
restartAgent(b.dataset.agentRestart, b.dataset.agentThread === 'yes');
|
||||
});
|
||||
|
||||
window.MosaicAgents = Object.freeze({ section, restartMarkup, isThread: definition => threadDefinitions.has(definition) });
|
||||
})();
|
||||
@@ -490,7 +490,7 @@
|
||||
// (likely when the bus is down) still refreshes the list, its banner and the Settings Start control.
|
||||
if (window.MosaicOrg) {
|
||||
const org = (await window.MosaicOrg.readList()).find(row => row.id === window.MosaicOrg.id);
|
||||
if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; return; }
|
||||
if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; window.MosaicConsole?.attachBus(box, 'Restart the bus'); return; }
|
||||
}
|
||||
try {
|
||||
const s = await api('/api/bus-status');
|
||||
@@ -498,6 +498,7 @@
|
||||
const b = s.boot;
|
||||
box.replaceChildren(node('b', '', 'Bus down: organization file invalid. '), node('span', '', `${b.reason} `), node('span', 'small muted', `File ${b.file}. Refused at ${b.at}. Last good load: ${b.lastGood ?? 'none'}. Fix the file, then restart the bus.`));
|
||||
box.hidden = false;
|
||||
window.MosaicConsole?.attachBus(box, 'Restart the bus');
|
||||
} catch { /* no status to show: leave the banner as it was */ }
|
||||
}
|
||||
async function refresh() {
|
||||
|
||||
@@ -319,7 +319,7 @@
|
||||
});
|
||||
|
||||
let configRead = Promise.resolve();
|
||||
let canSend = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
|
||||
let canSend = false, canMessage = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
|
||||
const REPLIES = 50; // the broker's cap
|
||||
function repliesHtml() {
|
||||
if (replies === null) return '<p class="small warn">The replies to it could not be read. Read again to retry; <code>mosaic inbox</code> in a terminal lists them.</p>';
|
||||
@@ -344,7 +344,7 @@
|
||||
<button class="btn primary" type="submit" data-send aria-disabled="${sending}">${sending ? 'Sending…' : 'Send'}</button></form>${said}</section>`;
|
||||
}
|
||||
async function composer(get) {
|
||||
if (!canSend) return '<p class="small muted">Messaging a role needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.</p>';
|
||||
if (!canMessage) return '<p class="small muted">Messaging a role needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.</p>';
|
||||
try { sendRoles = (await get('agents')).map(c => c.role).filter(r => typeof r === 'string' && SAFE.test(r)); } catch { sendRoles = null; }
|
||||
if (receipt?.kind === 'sent') {
|
||||
const r = receipt;
|
||||
@@ -612,8 +612,8 @@
|
||||
let heldOrganizations = { empty: false, invalid: [], error: null };
|
||||
function heldTable(claims) {
|
||||
const cols = ['Role', 'State', 'Attention', 'Address', 'Holder', 'Held since'];
|
||||
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${c.address ? txt(c.address) : '<span class="muted">no address</span>'}</td><td>${txt(c.role || ('a session '+short(c.holder_run)))}${c.harness ? `<span class="source">${txt(c.harness)}</span>` : ''}</td><td>${when(c.at)}</td>${canSend ? `<td>${typeof c.role === 'string' && SAFE.test(c.role) ? `<button type="button" class="btn" data-message-role="${esc(c.role)}" aria-label="Message ${esc(c.role)}">Message</button>` : ''}</td>` : ''}</tr>`).join('');
|
||||
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles on the board, scroll for all columns"><table class="s1-table s1-agents s1-held"><thead><tr>${cols.map(h => `<th scope="col">${h}</th>`).join('')}${canSend ? '<th scope="col"><span class="sr-only">Message</span></th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${c.address ? txt(c.address) : '<span class="muted">no address</span>'}</td><td>${txt(c.role || ('a session '+short(c.holder_run)))}${c.harness ? `<span class="source">${txt(c.harness)}</span>` : ''}</td><td>${when(c.at)}</td>${canMessage ? `<td>${typeof c.role === 'string' && SAFE.test(c.role) ? `<button type="button" class="btn" data-message-role="${esc(c.role)}" aria-label="Message ${esc(c.role)}">Message</button>` : ''}</td>` : ''}</tr>`).join('');
|
||||
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles on the board, scroll for all columns"><table class="s1-table s1-agents s1-held"><thead><tr>${cols.map(h => `<th scope="col">${h}</th>`).join('')}${canMessage ? '<th scope="col"><span class="sr-only">Message</span></th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
}
|
||||
function paintHeld(err, noOrganizations, invalidOrganizations, organizationError) {
|
||||
heldErr = err;
|
||||
@@ -982,7 +982,8 @@
|
||||
// offered: binding credentials stays a hand edit. When a change needs a bus restart the receipt
|
||||
// says so; the Console never restarts the bus. Drafts survive polling and refusals; nothing retries.
|
||||
let instanceView=null, instanceDrafts={add:null,change:null}, instanceBusyOps=new Set(), instanceReceipt='', instanceReceiptOp=null, instanceReceipts=Object.create(null);
|
||||
const RESTART_TEXT='Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
|
||||
// F3: an organization Console's owner restarts the bus from the receipt (console-restart.js).
|
||||
const restartText=()=>window.MosaicConsole?.busRestart?'Restart needed: the running bus keeps the roles it loaded at start. Restart the bus to load the change.':'Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
|
||||
const NO_RESTART_TEXT='No restart: the launcher reads the change at its next launch check.';
|
||||
function instancesSection(view){
|
||||
const title='<h2 id="instances-title">Role instances</h2>';
|
||||
@@ -1024,7 +1025,7 @@
|
||||
try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;instanceReceipts[op]=instanceReceipt;render(false);return;}
|
||||
instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false);
|
||||
try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
|
||||
if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?RESTART_TEXT:NO_RESTART_TEXT}`;}
|
||||
if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?restartText():NO_RESTART_TEXT}`;}
|
||||
else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}}
|
||||
catch{instanceReceipt=unknownInstance;}finally{instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);}
|
||||
});
|
||||
@@ -1035,6 +1036,21 @@
|
||||
// choosing none for all three. Save sends the changed rows through routes.set. Drafts survive
|
||||
// polling and refusals; nothing retries.
|
||||
let routesView=null, routesDraft=null, routesBusy=false, routesReceipt='', routesEditVersion=0;
|
||||
// F3: after a save, how the change reaches each saved agent: a session keeps the route it started
|
||||
// with, so a running one moves when it ends (a thread when it goes idle) or when the owner
|
||||
// restarts it here (agents.js restartMarkup). Read from the instances after the save.
|
||||
let routesApply=null;
|
||||
async function routesApplyRead(names){
|
||||
let view=null;
|
||||
try{const res=await fetch('/api/bus/instances',{cache:'no-store',redirect:'manual'});if(res.ok&&res.type!=='opaqueredirect')view=(await res.json())?.rows?.[0]??null;}catch{/* said below */}
|
||||
return names.map(name=>{const e=view?.instances?.[name];
|
||||
if(!view||!e)return {name,text:'Could not read whether it is running. A running session keeps its old route until it ends.'};
|
||||
const running=Array.isArray(e.running)?e.running.length:0, thread=!!window.MosaicAgents?.isThread(e.definition);
|
||||
if(e.launcher)return {name,text:'The launcher. It takes the new route at its next start.'};
|
||||
if(!running)return {name,text:'Not running. Its next start uses the new route.'};
|
||||
return {name,thread,restart:true,text:thread?'Running. Its session keeps the old route until the thread goes idle; the next message after that starts it on the new route. Restart it to move it now.':'Running. Its session keeps the old route until it ends. Restart it to move it now.'};});
|
||||
}
|
||||
const routesApplyMarkup=()=>routesApply?.length?`<ul class="routes-apply" aria-label="How the change reaches each agent">${routesApply.map(a=>`<li><b>${txt(a.name)}</b>: ${txt(a.text)}${a.restart&&canSend&&!isTester?` ${window.MosaicAgents?.restartMarkup(a.name,a.thread)??''}`:''}</li>`).join('')}</ul>`:'';
|
||||
let harnessSettings=null, harnessDraft=null, harnessLoading=false, harnessReceipt='', harnessBusy=false;
|
||||
function readHarnesses(){
|
||||
if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true;
|
||||
@@ -1070,7 +1086,7 @@
|
||||
const providerControl=canSend&&!isTester?`<button type="button" id="provider-switch" data-enabled="${v.catalogue?.status!=='disabled'}">${v.catalogue?.status!=='disabled'?'Disable':'Enable'} model proxy catalogue</button><p role="status" id="provider-switch-status"></p>`:'';
|
||||
const harnessControl=canSend&&!isTester?`<fieldset class="harness-control"><legend>Harnesses</legend>${harnessSettings ? [...new Set([...harnessSettings.available.harness,...(harnessSettings.plugins.harness??[])])].map(name=>`<label><input type="checkbox" data-harness="${esc(name)}"${(harnessDraft??harnessSettings.plugins.harness??[]).includes(name)?' checked':''}${harnessBusy?' disabled':''}> ${txt(name)}</label>`).join('')+` <button type="button" id="harness-save"${harnessBusy?' disabled':''}>Save harnesses</button>` : '<p>Harnesses unavailable.</p><button type="button" id="harness-read">Read harnesses again</button>'}<p role="status" id="harness-status">${txt(harnessReceipt)}</p></fieldset>`:'';
|
||||
const off=v.harnesses.length?'':'<p class="small warn">No harness is switched on in the business file\'s plugins.harness, so every launch refuses harness-off and a save that changes a harness refuses too. Switch one on under Harnesses.</p>';
|
||||
return `<section class="panel routing-panel" aria-labelledby="routes-title"><span class="sr-only">${title}</span>${catalogueInfo}${providerControl}${harnessControl}${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col" rowspan="2">Agent</th><th scope="colgroup" colspan="3">Primary</th><th scope="colgroup" colspan="3">Fallback</th></tr><tr>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l.replace('Fallback ','')}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><div class="settings-actions route-actions${routesDraft?' has-draft':''}"><button type="submit"${disabled}>${routesBusy?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save routes'}</button> <button type="button" id="routes-reload"${routesDraft?'':' hidden'}${routesBusy||!routesDraft?' disabled':''}>Discard draft</button><p role="status" id="routes-receipt" class="${routesReceipt.startsWith('Outcome unknown')?'banner ref':routesReceipt.includes('Draft kept')?'banner err':routesReceipt.includes('saved')?'settings-success':''}">${txt(routesReceipt)}</p></div></form></section>`;
|
||||
return `<section class="panel routing-panel" aria-labelledby="routes-title"><span class="sr-only">${title}</span>${catalogueInfo}${providerControl}${harnessControl}${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col" rowspan="2">Agent</th><th scope="colgroup" colspan="3">Primary</th><th scope="colgroup" colspan="3">Fallback</th></tr><tr>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l.replace('Fallback ','')}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><div class="settings-actions route-actions${routesDraft?' has-draft':''}"><button type="submit"${disabled}>${routesBusy?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save routes'}</button> <button type="button" id="routes-reload"${routesDraft?'':' hidden'}${routesBusy||!routesDraft?' disabled':''}>Discard draft</button><p role="status" id="routes-receipt" class="${routesReceipt.startsWith('Outcome unknown')?'banner ref':routesReceipt.includes('Draft kept')?'banner err':routesReceipt.includes('saved')?'settings-success':''}">${txt(routesReceipt)}</p></div>${routesApplyMarkup()}</form></section>`;
|
||||
}
|
||||
function routesFields(form){const draft=Object.fromEntries(Object.keys(routesView.instances).map(name=>[name,routeCells(routesView.instances[name])]));for(const el of form.querySelectorAll('[data-route]')){const [name,key]=JSON.parse(el.dataset.route);if(draft[name])draft[name][key]=el.value;}return draft;}
|
||||
// The changed rows only. A fallback with all three cells empty is cleared; one with some set goes
|
||||
@@ -1112,13 +1128,13 @@
|
||||
}catch(error){status.textContent=error.message;button.disabled=false;}
|
||||
});
|
||||
document.addEventListener('click',e=>{if(e.target.matches?.('.fallback-toggle'))e.target.closest('tr')?.classList.add('fallback-open');});
|
||||
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';last.delete('routes');render(true);});
|
||||
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';routesApply=null;last.delete('routes');render(true);});
|
||||
document.addEventListener('submit',async e=>{
|
||||
if(e.target.id!=='routes-form')return;e.preventDefault();if(!(canSend || isTester)||out()||routesBusy||!routesView)return;
|
||||
routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;}
|
||||
routesBusy=true;routesReceipt='';render(false);
|
||||
routesBusy=true;routesReceipt='';routesApply=null;render(false);
|
||||
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
|
||||
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;}
|
||||
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;routesApply=await routesApplyRead(Object.keys(payload.routes));}
|
||||
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'} Draft kept.`;}}
|
||||
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
|
||||
});
|
||||
@@ -1148,8 +1164,8 @@
|
||||
});
|
||||
});
|
||||
// Organizations is writable when the server allows org setup, which it does in organization mode where send is off.
|
||||
const readonlyPage = page => !['appearance','business'].includes(page) && !(page === 'organizations' && orgSetup) && !canSend && !isTester;
|
||||
const settingsPages = [['appearance','Appearance'],['business',window.MosaicOrg ? 'Organization' : 'Business'],['limits','Launch limits'],['organizations','Organizations'],['routing','Routing'],['agents','Agents'],['assistant','Assistant'],['tokens','Token use'],['model-proxy','Model proxy'],['about','About']];
|
||||
const readonlyPage = page => !['appearance','business','console'].includes(page) && !(page === 'organizations' && orgSetup) && !canSend && !isTester;
|
||||
const settingsPages = [['appearance','Appearance'],['business',window.MosaicOrg ? 'Organization' : 'Business'],['limits','Launch limits'],['organizations','Organizations'],['routing','Routing'],['agents','Agents'],['assistant','Assistant'],['tokens','Token use'],['model-proxy','Model proxy'],['console','Console'],['about','About']];
|
||||
function markSettingsDraft(page,dirty){const link=document.querySelector(`#settings-nav a[href="#/settings/${page}"]`), dot='<span class="settings-dot"><span class="sr-only">unsaved changes</span></span>';if(link && dirty && !link.querySelector('.settings-dot'))link.insertAdjacentHTML('beforeend',dot);const button=$('settings-disclosure');if(dirty && link?.getAttribute('aria-current')==='page' && button && !button.querySelector('.settings-dot'))button.querySelector('strong').insertAdjacentHTML('beforeend',dot);}
|
||||
function settingsShell(page, body, helper='') {
|
||||
const pages=isTester ? [['appearance','Appearance'],['business','Business'],['routing','Routing'],['agents','Agents'],['names','Seat names']] : settingsPages;
|
||||
@@ -1164,6 +1180,8 @@
|
||||
if (page==='appearance') return settingsShell(page,appearance(),'These choices apply in this browser only.');
|
||||
if (page==='organizations') { await configRead; return settingsShell(page,window.MosaicOrgCreate.markup,'Create an organization from the template, edit it, and finish its setup. Each step names the command it equals.'); }
|
||||
if (page==='model-proxy') { await configRead; return settingsShell(page,window.MosaicProxy.markup,'Accounts used by this instance, their priority, and the client keys assigned to agents.'); }
|
||||
// F3: Settings > Console (console-restart.js). The server checks the owner, Origin and trail.
|
||||
if (page==='console') { await configRead; await window.MosaicConsole?.ready; return settingsShell(page,window.MosaicConsole?.settingsMarkup()??'<section class="panel"><p class="small warn">The Console controls did not load. Reload the page to try again.</p></section>','Restart this Console on the same address and configuration.'); }
|
||||
const s = await get.api('settings'), n = s.notifier || {};
|
||||
let usage, names = null, instances = null, routes = null, limitRows = [], limitsRefused = false;
|
||||
if (page==='tokens') { try { [usage] = await get('usage'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; } }
|
||||
@@ -1715,10 +1733,14 @@
|
||||
});
|
||||
// Whether this server takes a send here. The form is drawn on the next Inbox render; the Board's
|
||||
// Message buttons are drawn from the roles already read.
|
||||
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => {
|
||||
// F3: `settingsSend` lets the owner of an organization Console save Settings on the selected
|
||||
// organization's bus; messaging a role still follows `send` (canMessage) and `chatSend`.
|
||||
// A bus restart reads the config again and sends it here as a mosaic-config event.
|
||||
const applyConfig = c => {
|
||||
isTester = c?.tester === true;
|
||||
canEdit = !isTester && c?.edit === true;
|
||||
canSend = !isTester && c?.send === true;
|
||||
canSend = !isTester && (c?.send === true || c?.settingsSend === true);
|
||||
canMessage = !isTester && c?.send === true;
|
||||
chatSend = !isTester && c?.chatSend === true;
|
||||
orgSetup = !isTester && c?.orgSetup === true;
|
||||
window.MosaicOrgBusStopped = !!window.MosaicOrg && c?.devOwner === true && orgSetup && c?.chatSend === false;
|
||||
@@ -1726,7 +1748,7 @@
|
||||
if (orgSetup && !$('org-new-link')) { const a = document.createElement('a'); a.id = 'org-new-link'; a.href = NEW_ORG; a.setAttribute('role', 'menuitem'); a.textContent = 'New organization'; document.querySelector('#avatar-menu > a[href="#/settings"]')?.after(a); }
|
||||
ssoOn = c?.sso === true;
|
||||
canDecide = !isTester && c?.decide === true;
|
||||
if (isTester) {
|
||||
if (isTester && !$('tester-banner')) {
|
||||
const banner = document.createElement('div'); banner.id = 'tester-banner'; banner.className = 'banner ref';
|
||||
banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.';
|
||||
$('main').prepend(banner);
|
||||
@@ -1734,7 +1756,9 @@
|
||||
if (!canSend && !isTester && !ssoOn) return;
|
||||
const path = routeOf().split('?')[0];
|
||||
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
|
||||
}).catch(() => {});
|
||||
};
|
||||
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {});
|
||||
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') applyConfig(e.detail); });
|
||||
// This context comes from the conversations renderer after its authorized read.
|
||||
let projectContext = null;
|
||||
document.addEventListener('mosaic-project-context', event => {
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/* F3: restart the Console from Settings, restart an organization's bus, and bring an open page
|
||||
back after any Console restart. The controls are the owner's: the server checks the human, the
|
||||
exact Origin and Fetch Metadata, and writes the action trail. This file only asks and reports.
|
||||
|
||||
Reconnect: /api/config carries `boot`, an id the Console picks at each start. When a same-origin
|
||||
request fails at the network level, or the owner asks for a restart, the page polls /api/config.
|
||||
The same boot means the Console never went away, and the banner goes. A new boot means a new
|
||||
Console process: the page reloads itself, so its scripts, icons and session match the new one.
|
||||
A page with an unsaved draft does not reload; it says the Console is back and keeps the draft. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
let config = null, boot = null, reconnecting = false, planned = false;
|
||||
let restartState = 'idle', restartReceipt = '', busReceipt = '', busBusy = false;
|
||||
|
||||
const readConfig = () => nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })
|
||||
.then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null));
|
||||
const ready = readConfig().then(c => { config = c; boot = typeof c?.boot === 'string' ? c.boot : null; return c; }).catch(() => null);
|
||||
|
||||
const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.()
|
||||
|| document.querySelector('.settings-dot')
|
||||
|| [...document.querySelectorAll('#reply, #conv-reply, #doc-source, #send-body')].some(el => !!el.value?.trim()));
|
||||
|
||||
function banner(text, action = '') {
|
||||
let box = $('console-reconnect');
|
||||
if (!box) {
|
||||
box = document.createElement('div'); box.id = 'console-reconnect'; box.className = 'banner ref console-reconnect';
|
||||
box.setAttribute('role', 'status'); box.setAttribute('aria-live', 'polite');
|
||||
($('main') ?? document.body).prepend(box);
|
||||
}
|
||||
box.innerHTML = `<span>${esc(text)}</span>${action}`;
|
||||
box.hidden = false;
|
||||
}
|
||||
const hideBanner = () => { const box = $('console-reconnect'); if (box) box.hidden = true; };
|
||||
|
||||
// One poll loop at a time. It ends when the Console answers; it never sends anything but a read.
|
||||
async function reconnect(reason) {
|
||||
if (reconnecting) return;
|
||||
reconnecting = true;
|
||||
const started = Date.now();
|
||||
banner(reason === 'restart' ? 'Console restarting. This page reconnects when it is back.' : 'The Console is not answering. Reconnecting…');
|
||||
for (;;) {
|
||||
const waited = Date.now() - started;
|
||||
await sleep(waited < 120000 ? 1000 : 5000);
|
||||
if (waited >= 120000) banner('The Console has not come back after two minutes. Check the terminal or service that started it. This page keeps trying.');
|
||||
let c;
|
||||
try {
|
||||
const r = await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' });
|
||||
// A sign-in redirect: the new Console wants a sign-in, which the reload shows.
|
||||
if (r.type === 'opaqueredirect') { location.reload(); return; }
|
||||
if (!r.ok) continue;
|
||||
c = await r.json();
|
||||
} catch { continue; }
|
||||
// The process that took the restart can still answer before it closes.
|
||||
if (boot !== null && c?.boot === boot) { if (planned) continue; reconnecting = false; hideBanner(); return; }
|
||||
reconnecting = false; planned = false;
|
||||
if (dirty()) {
|
||||
boot = typeof c?.boot === 'string' ? c.boot : boot; config = c;
|
||||
banner('The Console is back. Your unsaved draft is kept on this page.', ' <button type="button" class="btn" id="console-reconnect-reload">Reload now</button>');
|
||||
return;
|
||||
}
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', e => { if (e.target.id === 'console-reconnect-reload') location.reload(); });
|
||||
|
||||
// A same-origin request that fails at the network level (the Console went away) starts the poll.
|
||||
// An aborted request is an AbortError, not a TypeError, and starts nothing.
|
||||
const sameOrigin = input => { try { return new URL(typeof input === 'string' ? input : input.url, location.href).origin === location.origin; } catch { return false; } };
|
||||
window.fetch = (input, init) => nativeFetch(input, init).catch(err => {
|
||||
if (err?.name === 'TypeError' && sameOrigin(input) && boot !== null) reconnect('lost');
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Settings > Console.
|
||||
function settingsMarkup() {
|
||||
if (!config) return '<section class="panel"><p class="small warn">The Console settings could not be read. Reload the page to try again.</p></section>';
|
||||
if (config.tester === true || config.user === true) return '<section class="panel"><p>Restarting the Console is the owner\'s.</p></section>';
|
||||
const signOut = config.sso === true && config.devOwner !== true ? ' Sign-in sessions are held in memory, so everyone signs in again after it.' : '';
|
||||
let body;
|
||||
if (config.consoleRestart === true) {
|
||||
const busy = restartState !== 'idle' ? ' disabled' : '';
|
||||
const confirm = restartState === 'confirm'
|
||||
? '<div class="settings-actions" id="console-restart-confirm"><p>Restart the Console now?</p><button type="button" id="console-restart-go">Restart now</button> <button type="button" id="console-restart-cancel">Cancel</button></div>' : '';
|
||||
body = `<p>The Console stops, and <code>mosaic console start</code> starts it again on the same address, arguments and configuration. Organization buses are separate processes and keep running. Open pages show the restart and reconnect when it is back.${signOut}</p><p class="small muted">Each restart is recorded in the Console's action trail with your identity.</p><div class="settings-actions"><button type="button" id="console-restart"${busy}${restartState === 'confirm' ? ' hidden' : ''}>Restart Console</button></div>${confirm}`;
|
||||
} else if (config.orgSetup !== true && config.send !== true) {
|
||||
body = '<p>This Console is read-only, so it does not restart itself. Restart it where it was started.</p>';
|
||||
} else {
|
||||
body = '<p>This Console was not started in the foreground by <code>mosaic console start</code>, so nothing would start it again. Restart it where it was started: in its terminal, or with <code>systemctl --user restart</code> and its unit.</p>';
|
||||
}
|
||||
return `<section class="panel" aria-labelledby="console-restart-title"><h3 id="console-restart-title">Restart the Console</h3>${body}<p role="status" id="console-restart-receipt">${esc(restartReceipt)}</p></section>`;
|
||||
}
|
||||
const repaint = () => { const panel = $('console-restart-title')?.closest('section'); if (panel) panel.outerHTML = settingsMarkup(); };
|
||||
|
||||
async function restartConsole() {
|
||||
restartState = 'sending'; restartReceipt = 'Asking the Console to restart…'; repaint();
|
||||
let response, answer = null;
|
||||
try { response = await nativeFetch('/api/console/restart', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' }); }
|
||||
catch { restartState = 'idle'; restartReceipt = 'Outcome unknown. If the Console restarted, this page reconnects by itself.'; repaint(); reconnect('lost'); return; }
|
||||
try { answer = await response.json(); } catch { /* an unreadable answer is refused below */ }
|
||||
if (response.status === 202 && answer?.outcome === 'restarting') {
|
||||
planned = true; if (typeof answer.boot === 'string') boot = answer.boot;
|
||||
restartState = 'idle'; restartReceipt = `Restart requested at ${clock()}.`; repaint();
|
||||
reconnect('restart');
|
||||
return;
|
||||
}
|
||||
restartState = 'idle';
|
||||
restartReceipt = response.type === 'opaqueredirect' ? 'Sign in again, then retry.' : (answer?.message || 'The restart was refused. Nothing was retried.');
|
||||
repaint();
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.id === 'console-restart' && config?.consoleRestart === true) { restartState = 'confirm'; restartReceipt = ''; repaint(); $('console-restart-go')?.focus(); }
|
||||
else if (e.target.id === 'console-restart-cancel') { restartState = 'idle'; repaint(); $('console-restart')?.focus(); }
|
||||
else if (e.target.id === 'console-restart-go' && restartState === 'confirm') restartConsole();
|
||||
});
|
||||
|
||||
// The org bus restart control, offered on the organization's own pages to its owner.
|
||||
const busAllowed = () => !!window.MosaicOrg && config?.orgSetup === true;
|
||||
function busButton(label = 'Restart bus') {
|
||||
if (!busAllowed()) return '';
|
||||
return `<span class="bus-restart"><button type="button" class="btn" data-bus-restart data-label="${esc(label)}"${busBusy ? ' disabled' : ''}>${esc(busBusy ? 'Restarting…' : label)}</button> <span role="status" data-bus-restart-receipt>${esc(busReceipt)}</span></span>`;
|
||||
}
|
||||
async function attachBus(box, label) {
|
||||
await ready;
|
||||
// The bus is unavailable again: an earlier receipt no longer describes it.
|
||||
if (box && !box.hidden) $('bus-restart-done')?.remove();
|
||||
if (!box || box.querySelector('[data-bus-restart]') || !busAllowed()) return;
|
||||
box.insertAdjacentHTML('beforeend', ` ${busButton(label)}`);
|
||||
}
|
||||
const paintBus = () => document.querySelectorAll('.bus-restart').forEach(el => {
|
||||
const label = el.querySelector('[data-bus-restart]')?.dataset.label;
|
||||
el.outerHTML = busButton(label);
|
||||
});
|
||||
document.addEventListener('click', async e => {
|
||||
const button = e.target.closest?.('[data-bus-restart]');
|
||||
if (!button || button.disabled || busBusy || !busAllowed()) return;
|
||||
const id = window.MosaicOrg.id;
|
||||
busBusy = true; busReceipt = ''; paintBus();
|
||||
try {
|
||||
const response = await nativeFetch(`/api/orgs/${encodeURIComponent(id)}/restart/bus`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' });
|
||||
if (response.type === 'opaqueredirect') throw new Error('Sign in again, then retry.');
|
||||
const body = await response.json();
|
||||
if (!response.ok || body.outcome !== 'done' || body.step !== 'bus-restart') throw new Error(body.message || 'The restart was refused. Nothing was retried.');
|
||||
busReceipt = `${body.restarted ? 'Bus restarted' : 'Bus started'} at ${clock()}. ${body.healthy ? 'It answers.' : `It has not answered yet. Check it with ${body.verb.replace('restart', 'status')}.`}`;
|
||||
// The page's send flags were read while the bus was in its old state; read them again. This
|
||||
// read goes through the page's fetch, which display.js scopes to the selected organization.
|
||||
// The organization list goes first, so the view the config event redraws shows the bus as it is now.
|
||||
await Promise.resolve(window.MosaicOrg.refreshList?.()).catch(() => {});
|
||||
const fresh = await window.fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => (r.ok ? r.json() : null)).catch(() => null);
|
||||
if (fresh) { config = fresh; document.dispatchEvent(new CustomEvent('mosaic-config', { detail: fresh })); }
|
||||
// A healthy bus ends the "Bus unavailable" banner. Its receipt stays on the page in its place.
|
||||
const down = $('bus-down');
|
||||
if (down && body.healthy) {
|
||||
if (!down.hidden) {
|
||||
$('bus-restart-done')?.remove();
|
||||
const done = document.createElement('p'); done.id = 'bus-restart-done'; done.className = 'banner'; done.setAttribute('role', 'status');
|
||||
done.textContent = busReceipt; down.after(done);
|
||||
}
|
||||
down.hidden = true;
|
||||
}
|
||||
} catch (error) {
|
||||
busReceipt = error instanceof SyntaxError || error?.name === 'TypeError' ? 'Outcome unknown. Check the bus with mosaic bus status before retrying.' : error.message;
|
||||
} finally { busBusy = false; paintBus(); }
|
||||
});
|
||||
|
||||
window.MosaicConsole = Object.freeze({ ready, settingsMarkup, busButton, attachBus, get busRestart() { return busAllowed(); }, get config() { return config; } });
|
||||
})();
|
||||
@@ -65,7 +65,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
|
||||
const org = rows.find(row => row.id === decodeURIComponent(id) && !row.invalid);
|
||||
const mark = document.querySelector('.wordmark');
|
||||
if (mark && org) { const name = document.createElement('span'); name.id = 'current-organization'; name.textContent = ` · ${org.label || org.id}`; mark.append(name); }
|
||||
if (org?.busHealthy === false) { const banner = document.getElementById('bus-down'); if (banner) { banner.textContent = 'Bus unavailable'; banner.hidden = false; } }
|
||||
if (org?.busHealthy === false) { const banner = document.getElementById('bus-down'); if (banner) { banner.textContent = 'Bus unavailable'; banner.hidden = false; window.MosaicConsole?.attachBus(banner, 'Restart the bus'); } }
|
||||
return rows;
|
||||
};
|
||||
let list = readList().then(paintHeader);
|
||||
@@ -240,13 +240,23 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
|
||||
});
|
||||
const cleanup = () => { clearTimeout(poll); login = null; const redirect=$('proxy-login-submit')?.elements.redirect_url, key=$('proxy-apikey')?.elements.key; if(redirect)redirect.value='';if(key)key.value=''; };
|
||||
window.addEventListener('pagehide', cleanup, {once:true});
|
||||
let devOwner = false;
|
||||
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(config => {
|
||||
canWrite = config.send === true && config.tester !== true && config.user !== true;
|
||||
// F3: an organization's settings writes go with settingsSend (the org Console has no global send).
|
||||
canWrite = (config.send === true || config.settingsSend === true) && config.tester !== true && config.user !== true;
|
||||
devOwner = config.devOwner === true;
|
||||
document.querySelectorAll('[data-write]').forEach(fieldset => { fieldset.disabled = !canWrite; });
|
||||
$('proxy-readonly').hidden = canWrite;
|
||||
if (!canWrite) document.querySelectorAll('[aria-labelledby=proxy-signin-title],[aria-labelledby=proxy-apikey-title]').forEach(el=>el.hidden=true);
|
||||
return refresh();
|
||||
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message); if(error.message==='The model proxy is not configured for this instance.') { $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel, #proxy-refresh').forEach(el=>el.hidden=true); } });
|
||||
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message); if(error.message==='The model proxy is not configured for this instance.') { $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel, #proxy-refresh').forEach(el=>el.hidden=true);
|
||||
// F3: on a dev instance, say plainly why this page cannot set the proxy up and what does.
|
||||
// Setup takes a management key typed twice at a terminal and writes the proxy's credentials;
|
||||
// this page never handles that key. Account sign-in (a provider's own OAuth) then runs here.
|
||||
if (devOwner) { const org = window.MosaicOrg?.id, note = document.createElement('div'); note.id = 'proxy-dev-setup'; note.className = 'panel';
|
||||
note.innerHTML = '<h2>Set up the model proxy</h2><p>Nothing else on this dev Console asks for a sign-in. The proxy is the exception: its setup asks for a management key, typed twice, and writes the proxy\'s credentials. This page never handles that key, so setup runs once in a terminal on this machine:</p><pre><code></code></pre><p>Then refresh this page. Signing in to a Claude or Codex account is the provider\'s own sign-in, which cannot be skipped. It runs here, under Sign in to an account, with one button.</p>';
|
||||
note.querySelector('code').textContent = `mosaic proxy fetch\nmosaic proxy init${org ? ` --org ${org}` : ''}`;
|
||||
$('proxy-message').after(note); } } });
|
||||
return cleanup;
|
||||
}
|
||||
window.MosaicProxy={markup,mount};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
|
||||
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script>
|
||||
<script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<body>
|
||||
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
});
|
||||
window.addEventListener('pagehide', () => { endLogin(); $('proxy-apikey').elements.key.value = ''; });
|
||||
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(config => {
|
||||
canWrite = config.send === true && config.tester !== true && config.user !== true;
|
||||
canWrite = (config.send === true || config.settingsSend === true) && config.tester !== true && config.user !== true;
|
||||
document.querySelectorAll('[data-write]').forEach(fieldset => { fieldset.disabled = !canWrite; });
|
||||
$('proxy-readonly').hidden = canWrite;
|
||||
return refresh();
|
||||
|
||||
@@ -93,6 +93,16 @@ table.s1-table td code{font-size:12.5px;white-space:nowrap}
|
||||
.banner.ref{border-color:color-mix(in srgb,var(--warning) 50%,var(--line));background:color-mix(in srgb,var(--warning) 9%,var(--surface))}
|
||||
.banner b,.banner strong{color:var(--strong)}
|
||||
.banner code{overflow-wrap:anywhere}
|
||||
/* F3: the Console restart banner stays in view while the page waits for the Console, and the bus restart control sits inline in the receipt or banner that asks for it. */
|
||||
.console-reconnect{position:sticky;top:0;z-index:30;align-items:center}
|
||||
.console-reconnect>span{flex:1 1 220px;min-width:0;overflow-wrap:anywhere}
|
||||
.bus-restart{display:inline-flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0}
|
||||
.bus-restart [role=status]:empty{display:none}
|
||||
.routes-apply{margin:12px 0 0;padding-left:18px;display:grid;gap:8px}
|
||||
.routes-apply li{overflow-wrap:anywhere}
|
||||
.agent-restart{display:inline-flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0}
|
||||
.agent-restart [role=status]:empty{display:none}
|
||||
#proxy-dev-setup pre{overflow-x:auto;max-width:100%}
|
||||
/* Stream S4: which organization this is, in words. Colour (the theme) is never the only cue; the strip appears whenever the theme is not the built-in one. */
|
||||
.org-cue{margin:0 0 12px;padding:6px 10px;border:1px solid var(--line);border-left:4px solid var(--action);border-radius:var(--r-table);background:var(--raised);color:var(--text);font-size:13px;overflow-wrap:anywhere}.skel-rows{display:grid;gap:10px;padding:14px 12px;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface)}
|
||||
.skel{display:block;height:12px;border-radius:4px;background:var(--raised);animation:skel 1.4s ease-in-out infinite}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { proxyHTTP } from './proxy-http.mjs';
|
||||
import { createServer } from 'node:http';
|
||||
import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs';
|
||||
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { timingSafeEqual, randomUUID } from 'node:crypto';
|
||||
import { resolve } from 'node:path';
|
||||
import { isIP } from 'node:net';
|
||||
import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from './bus.mjs';
|
||||
@@ -19,6 +19,9 @@ import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } fro
|
||||
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
|
||||
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
|
||||
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
|
||||
import { consoleRestartHTTP } from './console-restart-http.mjs';
|
||||
import { workspaceHTTP } from './workspace-read.mjs';
|
||||
import { actor } from './action-trail.mjs';
|
||||
import { devHostName } from '../../cli/src/dev-local-owner.mjs';
|
||||
|
||||
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
|
||||
@@ -55,7 +58,7 @@ const files = new Map([
|
||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
|
||||
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
|
||||
@@ -443,10 +446,11 @@ const reasonFor = (code, done) => {
|
||||
return `A skill this role needs isn't turned on: scripts/skill.sh activate ${done.skill}`;
|
||||
return Object.hasOwn(INSTANCE_REASONS, code) ? INSTANCE_REASONS[code] : Object.hasOwn(AGENT_REASONS, code) ? AGENT_REASONS[code] : typeof code === 'string' && code.startsWith('proxy-') ? 'Check the model proxy configuration and account status' : String(code ?? 'unknown');
|
||||
};
|
||||
async function instanceSend(req, res, { instancesWrite, preview, log }) {
|
||||
let instance = null;
|
||||
async function instanceSend(req, res, { instancesWrite, preview, log, note = null }) {
|
||||
let instance = null, op = null;
|
||||
// The instance is quoted and cut short so a crafted value can't forge a log line. Vars are never logged.
|
||||
const line = outcome => log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
|
||||
// F3 review P3: the owner's instance changes also go to the action trail, like routes and agents.
|
||||
const line = outcome => { log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`); note?.({ action: 'instances.set', op, instance: typeof instance === 'string' ? instance.slice(0, 200) : null, outcome }); };
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
@@ -462,7 +466,7 @@ async function instanceSend(req, res, { instancesWrite, preview, log }) {
|
||||
(has('vars') && (!value.vars || typeof value.vars !== 'object' || Array.isArray(value.vars))) || (has('launch') && typeof value.launch !== 'boolean')) {
|
||||
return refuse(400, 'invalid-request', 'this takes a revision, an instance, op add or change and a definition, vars or launch, or op delete alone, nothing else');
|
||||
}
|
||||
({ instance } = value);
|
||||
({ instance, op } = value);
|
||||
// D4.1: the tester never deletes. The bus refuses it too (broker tester-refused); this makes no call.
|
||||
if (deleting && preview.tester) return refuse(403, 'tester-refused', 'the tester listener doesn\'t delete. Nothing changed.');
|
||||
let result;
|
||||
@@ -499,10 +503,13 @@ const AGENT_ROUTES = {
|
||||
'/api/veto': { kind: 'veto', verb: 'veto.set', keys: ['revision', 'op', 'name'], tester: false,
|
||||
done: r => /^[a-f0-9]{64}$/.test(r?.revision ?? '') && typeof r.restart === 'boolean' && r.instances && typeof r.instances === 'object' && !Array.isArray(r.instances) },
|
||||
};
|
||||
async function agentSend(req, res, { route, agentWrite, preview, log }) {
|
||||
// F3: the writes the owner makes on a selected organization's bus from Settings (dev org Console).
|
||||
const ORG_OWNER_WRITE = path => path === '/api/settings' || path === '/api/instances' || path === '/api/routes'
|
||||
|| Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/');
|
||||
async function agentSend(req, res, { route, agentWrite, preview, log, note = null }) {
|
||||
let target = null;
|
||||
// The instance or name is quoted and cut short so a crafted value can't forge a log line. A reason is never logged.
|
||||
const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(target)} ${outcome}`);
|
||||
const line = outcome => { log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(target)} ${outcome}`); note?.({ action: route.verb, instance: typeof target === 'string' ? target.slice(0, 200) : null, outcome }); };
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
@@ -562,10 +569,11 @@ const ROUTE_REASONS = {
|
||||
};
|
||||
const routeObject = o => o && typeof o === 'object' && !Array.isArray(o);
|
||||
const routesAnswer = v => /^[a-f0-9]{64}$/.test(v?.revision ?? '') && Array.isArray(v.harnesses) && Array.isArray(v.models) && routeObject(v.levels) && routeObject(v.instances);
|
||||
async function routesSend(req, res, { routesWrite, preview, log }) {
|
||||
// `note` (F3) adds the owner's change to the Console's action trail; best effort, after the log line.
|
||||
async function routesSend(req, res, { routesWrite, preview, log, note = null }) {
|
||||
// Instance names only; routes are never logged.
|
||||
let names = [];
|
||||
const line = outcome => log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`);
|
||||
const line = outcome => { log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`); note?.({ action: 'routes.set', instances: names, outcome }); };
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
@@ -763,7 +771,7 @@ async function libraryStatusRead(res, read, search) {
|
||||
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined } = {}) {
|
||||
// org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host
|
||||
// names (without a port) a remote browser may use; only the dev owner takes them, and only with
|
||||
// them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback.
|
||||
@@ -790,11 +798,16 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
const registered = routeTable();
|
||||
for (const registerRoutes of routes) registerRoutes(registered);
|
||||
registered.add('GET', '/api/themes', (req, res) => themesHTTP(req, res, { log }), { publicRead: true });
|
||||
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log });
|
||||
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
|
||||
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log, trail });
|
||||
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore|restart)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
|
||||
for (const method of ['GET', 'POST']) registered.add(method, path, adminRoute);
|
||||
}
|
||||
registered.add('POST', '/api/orgs', adminRoute);
|
||||
// F3: the owner restarts the Console (console-restart-http.mjs). `restart` is set only under the
|
||||
// supervisor; `boot` names this process so an open page can tell when the new one answers.
|
||||
registered.add('POST', '/api/console/restart', (req, res, context) => consoleRestartHTTP(req, res, { ...context, restart, trail, boot, log }));
|
||||
// U1 (#1873): one read across every organization the owner belongs to (workspace-read.mjs).
|
||||
registered.add('GET', '/api/workspace', (req, res, context) => workspaceHTTP(req, res, { ...context, organizations, log, ...(workspaceTimeoutMs === undefined ? {} : { timeoutMs: workspaceTimeoutMs }) }));
|
||||
const upstream = boardURL(board);
|
||||
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
|
||||
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
|
||||
@@ -942,7 +955,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null,
|
||||
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, upstream = null } = selected);
|
||||
}
|
||||
if (!hook && !['GET', 'HEAD'].includes(req.method) && !decisionPath && path !== '/api/north-star/approve' && !(/^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f-]+\/(?:messages|open))$/i.test(path)))
|
||||
// F3: the owner's settings writes reach the selected organization's bus too (limits, routing,
|
||||
// agents, assistant, instances, model proxy). The routes below keep their own checks.
|
||||
if (!hook && !['GET', 'HEAD'].includes(req.method) && !decisionPath && path !== '/api/north-star/approve' && !(/^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f-]+\/(?:messages|open))$/i.test(path))
|
||||
&& !(orgId !== null && !tester && !granted && named && ORG_OWNER_WRITE(path)))
|
||||
return json(res, 403, { error: 'unavailable', sent: false });
|
||||
}
|
||||
if (hook) {
|
||||
@@ -953,6 +969,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
return hook.handler(req, res, { path: originalPath, search, session: ssoSession, orgId, origin, owner: !tester && !granted && named && sso !== null, send: owner.send !== null });
|
||||
}
|
||||
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer;
|
||||
// F3: the owner's routing and agent changes go to the action trail too (who, when, what).
|
||||
const ownerNote = !tester && !granted && ssoSession && trail ? entry => {
|
||||
try { trail({ by: actor(ssoSession), ...(orgId !== null ? { org: orgId } : {}), ...entry }); }
|
||||
catch { log(`${new Date().toISOString()} trail write failed for ${entry.action}`); }
|
||||
} : null;
|
||||
// P4 page and verbs are owner-only, including reads and static page assets.
|
||||
// This runs after owner sign-in and the granted-user fence, before generic
|
||||
// tester writes or static-file serving. No localhost preview grants access.
|
||||
@@ -1008,13 +1029,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (path === '/api/instances') {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
if ((!tester && (!send || !named)) || !instancesWrite) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return instanceSend(req, res, { instancesWrite, preview: writer, log });
|
||||
return instanceSend(req, res, { instancesWrite, preview: writer, log, note: ownerNote });
|
||||
}
|
||||
// Row 194 (#1692, D7): the Agents section. No route gets a --send exception.
|
||||
if (Object.hasOwn(AGENT_ROUTES, path)) {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
if ((!tester && (!send || !named)) || (tester && !AGENT_ROUTES[path].tester) || !agentWrite) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return agentSend(req, res, { route: AGENT_ROUTES[path], agentWrite, preview: writer, log });
|
||||
return agentSend(req, res, { route: AGENT_ROUTES[path], agentWrite, preview: writer, log, note: ownerNote });
|
||||
}
|
||||
if (path === '/api/agent/request') {
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
@@ -1024,7 +1045,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (path === '/api/routes') {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
if ((!tester && (!send || !named)) || !routesWrite) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return routesSend(req, res, { routesWrite, preview: writer, log });
|
||||
return routesSend(req, res, { routesWrite, preview: writer, log, note: ownerNote });
|
||||
}
|
||||
if (path === '/api/library/revert') {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
@@ -1138,7 +1159,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy } : {}), decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && !granted && named && sso !== null && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
// U1 (#1873, PRD 1.20 REQ-WS-1..8): the cross-organization read model behind GET /api/workspace.
|
||||
//
|
||||
// One snapshot across the bus of every organization the viewer belongs to. Membership is the
|
||||
// per-org check the org list and the org pages already use (organizations.mjs `admitted`), so an
|
||||
// org the viewer does not belong to is never read and never named. Each org keeps its own bus;
|
||||
// this module only fans out over the same per-org options the org pages get (cli.mjs) and merges.
|
||||
// There is no second path to a bus and no write.
|
||||
//
|
||||
// Every org has one deadline. A bus that is down, still starting or slow is marked with a state
|
||||
// and a time, and the other orgs still answer. Every merged item carries its org id.
|
||||
import { plainMessage } from './org-admin.mjs';
|
||||
|
||||
export const WORKSPACE_SECTIONS = Object.freeze(['projects', 'sessions', 'inbox', 'tasks', 'metrics']);
|
||||
export const WORKSPACE_POLL_MS = 10000;
|
||||
export const WORKSPACE_TIMEOUT_MS = 4000;
|
||||
const PROJECT_STATUSES = ['active', 'archived'];
|
||||
const PROJECT_STATES = ['working', 'waiting', 'idle', 'none'];
|
||||
const SORTS = ['activity', 'name', 'org', 'status'];
|
||||
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
// A project selector is `<org>:<project uuid or org-file project key>`.
|
||||
const PROJECT_SELECTOR = /^([a-z0-9][a-z0-9._-]{0,63}):([A-Za-z0-9._-]{1,128})$/;
|
||||
// Only these reach a copied shell command; anything else gets no command.
|
||||
const SHELL_SAFE = /^[A-Za-z0-9._:-]{1,128}$/;
|
||||
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
||||
const PER_ORG_CONCURRENCY = 3;
|
||||
|
||||
// The broker reads each section needs. Each name is one human-side read the org pages already make.
|
||||
const NEEDS = {
|
||||
projects: ['projects', 'agents', 'claims', 'threads', 'tasks', 'inbox'],
|
||||
sessions: ['agents', 'claims', 'threads', 'projects', 'tasks'],
|
||||
inbox: ['inbox', 'agents', 'tasks', 'projects'],
|
||||
tasks: ['tasks', 'projects', 'inbox'],
|
||||
metrics: ['inbox', 'agents', 'claims', 'tasks', 'projects', 'usage', 'limits', 'decisions'],
|
||||
};
|
||||
const READS = {
|
||||
inbox: o => o.bus.inbox(),
|
||||
agents: o => o.bus.agents(),
|
||||
claims: o => o.bus['thread.claims'](),
|
||||
threads: o => o.chat.threads({}),
|
||||
tasks: o => o.bus.tasks(),
|
||||
projects: o => o.chat.projects({ includeArchived: true }),
|
||||
usage: o => o.bus.usage(),
|
||||
limits: o => o.bus.limits(),
|
||||
decisions: o => o.bus.decisions('recent'),
|
||||
};
|
||||
|
||||
export class WorkspaceQueryError extends Error {}
|
||||
const bad = message => { throw new WorkspaceQueryError(message); };
|
||||
const list = value => value.split(',').map(v => v.trim()).filter(Boolean);
|
||||
function oneOf(name, value, allowed) {
|
||||
const values = list(value);
|
||||
if (!values.length) bad(`${name} is empty`);
|
||||
for (const v of values) if (!allowed.includes(v)) bad(`${name} takes ${allowed.join(', ')}`);
|
||||
return new Set(values);
|
||||
}
|
||||
|
||||
// Strict: an unknown or repeated key is refused, so a misspelled filter never reads as "all".
|
||||
export function parseWorkspaceQuery(search = '') {
|
||||
const params = new URLSearchParams(search), seen = new Set();
|
||||
const q = { sections: new Set(WORKSPACE_SECTIONS), orgs: null, status: new Set(['active']), state: null, name: null, sort: 'activity', order: null, project: null };
|
||||
for (const [key, value] of params) {
|
||||
if (seen.has(key)) bad(`${key} is given twice`);
|
||||
seen.add(key);
|
||||
if (key === 'sections') q.sections = oneOf('sections', value, WORKSPACE_SECTIONS);
|
||||
else if (key === 'org') {
|
||||
const ids = list(value);
|
||||
if (!ids.length || ids.some(id => !ORG_ID.test(id))) bad('org takes organization ids, comma separated');
|
||||
q.orgs = new Set(ids);
|
||||
} else if (key === 'status') q.status = oneOf('status', value, PROJECT_STATUSES);
|
||||
else if (key === 'state') q.state = oneOf('state', value, PROJECT_STATES);
|
||||
else if (key === 'name') {
|
||||
if (!value.trim() || value.length > 200) bad('name takes 1 to 200 characters');
|
||||
q.name = value.trim().toLowerCase();
|
||||
} else if (key === 'sort') { if (!SORTS.includes(value)) bad(`sort takes ${SORTS.join(', ')}`); q.sort = value; }
|
||||
else if (key === 'order') { if (!['asc', 'desc'].includes(value)) bad('order takes asc or desc'); q.order = value; }
|
||||
else if (key === 'project') {
|
||||
const m = PROJECT_SELECTOR.exec(value);
|
||||
if (!m) bad('project takes <org>:<project id or key>');
|
||||
q.project = { org: m[1], key: m[2] };
|
||||
} else bad(`unknown query key ${key}`);
|
||||
}
|
||||
q.order ??= q.sort === 'activity' ? 'desc' : 'asc';
|
||||
return q;
|
||||
}
|
||||
|
||||
const TIMED_OUT = Symbol('timed-out');
|
||||
function deadline(ms) {
|
||||
const controller = new AbortController(), until = Date.now() + ms;
|
||||
let timer;
|
||||
const promise = new Promise(resolve => { timer = setTimeout(() => { controller.abort(); resolve(TIMED_OUT); }, ms); timer.unref?.(); });
|
||||
return { promise, signal: controller.signal, deadline: until, abort: () => controller.abort(), clear: () => clearTimeout(timer) };
|
||||
}
|
||||
const code = e => (typeof e?.code === 'string' && /^[a-z-]{1,64}$/.test(e.code) ? e.code : 'read-failed');
|
||||
|
||||
// Runs `names` reads `limit` at a time and stops starting new ones once `stopped()` says so.
|
||||
async function pooled(names, limit, run, stopped) {
|
||||
const out = {}, queue = [...names];
|
||||
const worker = async () => {
|
||||
while (queue.length && !stopped()) {
|
||||
const name = queue.shift();
|
||||
try { out[name] = { ok: true, value: await run(name) }; } catch (e) { out[name] = { ok: false, error: code(e) }; }
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, names.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
|
||||
const iso = ms => new Date(ms).toISOString();
|
||||
const latest = values => values.filter(v => typeof v === 'string' && !Number.isNaN(Date.parse(v))).sort().at(-1) ?? null;
|
||||
function median(values) {
|
||||
if (!values.length) return null;
|
||||
const s = [...values].sort((a, b) => a - b), mid = s.length >> 1;
|
||||
return s.length % 2 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
|
||||
}
|
||||
|
||||
// One org's bus state and reads. Never throws: every failure becomes the org's state.
|
||||
async function readOrg(member, needs, { timeoutMs, now, ctx }) {
|
||||
const started = now();
|
||||
const head = { org: member.id, label: member.id, theme: member.doc?.appearance?.theme ?? null, tracker: member.doc?.tracker ? 'vikunja' : 'built-in', roles: Object.keys(member.doc?.roles ?? {}).sort() };
|
||||
const finish = (state, extra = {}) => ({ ...head, state, at: iso(now()), readMs: now() - started, ...extra });
|
||||
if (member.invalid) return { org: { ...head, roles: [], state: 'invalid', at: iso(now()), readMs: 0, reason: member.reason } };
|
||||
const limit = deadline(timeoutMs);
|
||||
const bound = { signal: limit.signal, deadline: limit.deadline };
|
||||
const invalid = () => ({ org: finish('invalid', { error: 'invalid-response' }) });
|
||||
try {
|
||||
let status;
|
||||
try {
|
||||
status = await Promise.race([member.status(bound), limit.promise]);
|
||||
} catch (e) {
|
||||
if (limit.signal.aborted || Date.now() >= limit.deadline) return { org: finish('timeout', { reason: `the bus state was not read within ${timeoutMs} ms` }) };
|
||||
// A stale or mismatched host state (org-bus.mjs checkedState) is a stopped bus to the reader.
|
||||
// members() has already made host paths relative; any absolute path left is withheld.
|
||||
return { org: finish('down', { reason: plainMessage(ctx, typeof e?.message === 'string' ? e.message : 'bus state could not be read').slice(0, 300) }) };
|
||||
}
|
||||
if (status === TIMED_OUT || limit.signal.aborted || Date.now() >= limit.deadline) return { org: finish('timeout', { reason: `the bus state was not read within ${timeoutMs} ms` }) };
|
||||
if (status?.healthy !== true) {
|
||||
const boot = status?.boot && typeof status.boot === 'object' ? { ok: status.boot.ok === true, at: status.boot.at ?? null, lastGood: status.boot.lastGood ?? null } : null;
|
||||
if (Number.isSafeInteger(status?.pid)) return { org: finish('starting', { reason: 'the bus host is running but does not answer yet', boot }) };
|
||||
const reason = boot && !boot.ok && typeof status.boot.reason === 'string'
|
||||
? `the bus refused to start: ${plainMessage(ctx, status.boot.reason).slice(0, 300)}`
|
||||
: 'the bus is not running';
|
||||
return { org: finish('down', { reason, boot }) };
|
||||
}
|
||||
const options = member.options(bound);
|
||||
const reads = pooled(needs, PER_ORG_CONCURRENCY, name => READS[name](options), () => limit.signal.aborted || Date.now() >= limit.deadline);
|
||||
const results = await Promise.race([reads, limit.promise]);
|
||||
if (results === TIMED_OUT || limit.signal.aborted || Date.now() >= limit.deadline) return { org: finish('timeout', { reason: `the bus did not answer within ${timeoutMs} ms` }) };
|
||||
const errors = Object.fromEntries(Object.entries(results).filter(([, r]) => !r.ok).map(([name, r]) => [name, r.error]));
|
||||
const data = Object.fromEntries(Object.entries(results).filter(([, r]) => r.ok).map(([name, r]) => [name, r.value]));
|
||||
if (Object.values(errors).includes('invalid-response') || !Object.entries(data).every(([name, value]) => validRead(name, value))) return invalid();
|
||||
const org = finish('ok', { errors });
|
||||
return { org, rows: orgRows(org, data, member.doc, now()) };
|
||||
} catch { return invalid(); } finally { if (Date.now() >= limit.deadline) limit.abort(); limit.clear(); }
|
||||
}
|
||||
|
||||
// Validate the fields consumed by projection and by the cross-org filters and sorts. Unknown
|
||||
// fields are retained by the bus contract; optional fields can be absent or null.
|
||||
const record = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
const optional = (v, check) => v == null || check(v);
|
||||
const string = v => typeof v === 'string';
|
||||
const strings = (v, keys) => keys.every(k => optional(v[k], string));
|
||||
const records = v => Array.isArray(v) && v.every(record);
|
||||
function validRead(name, value) {
|
||||
if (name === 'usage' || name === 'limits') {
|
||||
if (!record(value)) return false;
|
||||
return name === 'limits' ? optional(value.tokens, records) : optional(value.total, record);
|
||||
}
|
||||
if (!records(value)) return false;
|
||||
return value.every(v => {
|
||||
if (name === 'projects') return string(v.id) && strings(v, ['repo', 'name', 'at']);
|
||||
if (name === 'threads') return strings(v, ['id', 'project']);
|
||||
if (name === 'claims') return strings(v, ['thread', 'instance', 'holder_run', 'harness', 'at']);
|
||||
if (name === 'inbox') return string(v.id) && strings(v, ['question', 'class', 'raised_by_role', 'task_ref', 'at']) && optional(v.options, a => records(a) && a.every(o => strings(o, ['key', 'text'])));
|
||||
if (name === 'agents') return strings(v, ['role', 'holder_run', 'harness', 'state', 'at']) && optional(v.working, w => record(w) && strings(w, ['since'])) && ['inputNeeded', 'stranded'].every(k => optional(v[k], records)) && (v.inputNeeded ?? []).every(d => strings(d, ['task_ref', 'at']));
|
||||
if (name === 'tasks') return strings(v, ['task_ref', 'project_key', 'at']) && optional(v.fields, f => record(f) && strings(f, ['title', 'bucket']) && ['assignees', 'labels'].every(k => optional(f[k], Array.isArray)));
|
||||
if (name === 'decisions') return strings(v, ['at']) && optional(v.closed, c => record(c) && strings(c, ['op', 'at']));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const arr = value => (Array.isArray(value) ? value : []);
|
||||
|
||||
// Merges one org's reads into rows. Each row names its org; ids stay the bus's own.
|
||||
function orgRows(org, data, doc, nowMs) {
|
||||
const id = org.org;
|
||||
const projects = arr(data.projects).filter(p => p && typeof p.id === 'string');
|
||||
const byKey = new Map(projects.filter(p => typeof p.repo === 'string').map(p => [p.repo, p]));
|
||||
const byId = new Map(projects.map(p => [p.id, p]));
|
||||
const ref = p => (p ? { id: p.id, key: p.repo ?? null, name: p.name ?? null } : null);
|
||||
const threadProject = new Map(arr(data.threads).map(t => [t.id, byId.get(t.project) ?? null]));
|
||||
const taskRows = arr(data.tasks).filter(t => t && typeof t.task_ref === 'string' && !t.fields?.gone);
|
||||
const bots = new Map(Object.entries(doc?.roles ?? {}).filter(([, r]) => r?.tracker?.botId !== undefined).map(([role, r]) => [r.tracker.botId, role]));
|
||||
const tasks = taskRows.map(t => {
|
||||
const f = t.fields ?? {};
|
||||
return {
|
||||
org: id, ref: t.task_ref, tracker: org.tracker, title: f.title ?? null, done: Boolean(f.done), bucket: f.bucket ?? null,
|
||||
assignees: arr(f.assignees).filter(u => bots.has(u)).map(u => bots.get(u)).sort(), labels: arr(f.labels), priority: t.priority ?? null,
|
||||
requirement: t.requirement ?? null, project: ref(typeof t.project_key === 'string' ? byKey.get(t.project_key) : null), changedAt: t.at ?? null,
|
||||
};
|
||||
});
|
||||
const taskByRef = new Map(tasks.map(t => [t.ref, t]));
|
||||
// A role session's project and task: the task its open decision names, else its first open
|
||||
// assigned task. A role claim itself names no project, so this is inferred, and says so.
|
||||
const roleWork = role => {
|
||||
const open = tasks.filter(t => !t.done && t.assignees.includes(role));
|
||||
return open[0] ?? null;
|
||||
};
|
||||
const sessions = [];
|
||||
for (const c of arr(data.agents)) {
|
||||
if (!c || typeof c.role !== 'string') continue;
|
||||
const asked = arr(c.inputNeeded).map(d => taskByRef.get(d.task_ref)).find(Boolean);
|
||||
const task = asked ?? roleWork(c.role);
|
||||
sessions.push({
|
||||
org: id, kind: 'role', role: c.role, run: c.holder_run ?? null, harness: c.harness ?? null, state: c.state ?? null,
|
||||
since: c.working?.since ?? arr(c.inputNeeded)[0]?.at ?? c.at ?? null, claimedAt: c.at ?? null,
|
||||
messages: c.working?.messages ?? 0, inputNeeded: arr(c.inputNeeded).length, stranded: arr(c.stranded).length,
|
||||
task: task ? { ref: task.ref, title: task.title } : null, project: task?.project ?? null, projectFrom: task ? 'task' : null,
|
||||
});
|
||||
}
|
||||
for (const c of arr(data.claims)) {
|
||||
if (!c || typeof c.thread !== 'string') continue;
|
||||
const p = threadProject.get(c.thread) ?? null;
|
||||
sessions.push({ org: id, kind: 'thread', thread: c.thread, instance: c.instance ?? null, run: c.holder_run ?? null, harness: c.harness ?? null, state: 'live', since: c.at ?? null, claimedAt: c.at ?? null, project: ref(p), projectFrom: p ? 'thread' : null });
|
||||
}
|
||||
const sessionByRole = new Map(sessions.filter(s => s.kind === 'role').map(s => [s.role, s]));
|
||||
const inbox = arr(data.inbox).filter(d => d && typeof d.id === 'string').map(d => {
|
||||
const task = taskByRef.get(d.task_ref) ?? null, s = sessionByRole.get(d.raised_by_role) ?? null;
|
||||
const options = arr(d.options).map(o => ({ key: o.key, text: o.text }));
|
||||
const safe = SHELL_SAFE.test(d.id) && ORG_ID.test(id);
|
||||
return {
|
||||
org: id, id: d.id, question: d.question ?? null, class: d.class ?? null, blocking: Boolean(d.blocking), raisedBy: d.raised_by_role ?? null,
|
||||
taskRef: d.task_ref ?? null, task: task ? { ref: task.ref, title: task.title } : null, project: task?.project ?? null,
|
||||
session: s ? { role: s.role, run: s.run } : null, at: d.at ?? null, options, console: Boolean(d.console),
|
||||
// A shared view mixes organizations, so a copied command always names the org's bus.
|
||||
decide: options.map(o => ({ key: o.key, command: safe && SHELL_SAFE.test(String(o.key)) ? `mosaic decide ${d.id} ${o.key} --org ${id}` : null })),
|
||||
};
|
||||
});
|
||||
const projectRows = projects.map(p => {
|
||||
const mine = s => s.project?.id === p.id;
|
||||
const ps = sessions.filter(mine), needs = inbox.filter(mine), open = tasks.filter(t => !t.done && t.project?.id === p.id);
|
||||
const count = state => ps.filter(s => s.state === state).length;
|
||||
const counts = { working: count('working') + count('live'), waiting: count('waiting'), idle: count('idle') };
|
||||
const entry = typeof p.repo === 'string' ? doc?.projects?.[p.repo] : undefined;
|
||||
return {
|
||||
org: id, id: p.id, key: p.repo ?? null, name: p.name ?? null, status: p.archived ? 'archived' : 'active',
|
||||
state: counts.working ? 'working' : counts.waiting || needs.length ? 'waiting' : counts.idle ? 'idle' : 'none',
|
||||
git: entry ? (entry.git?.mode ?? (entry.remote ? 'remote' : null)) : null,
|
||||
sessions: counts, openTasks: open.length, needsYou: needs.length,
|
||||
lastActivity: latest([p.at, ...tasks.filter(t => t.project?.id === p.id).map(t => t.changedAt), ...needs.map(d => d.at), ...ps.map(s => s.claimedAt)]),
|
||||
};
|
||||
});
|
||||
const decided = arr(data.decisions).filter(d => d?.closed?.op === 'resolved' && typeof d.at === 'string' && typeof d.closed.at === 'string')
|
||||
.map(d => Date.parse(d.closed.at) - Date.parse(d.at)).filter(ms => Number.isFinite(ms) && ms >= 0);
|
||||
const roleCount = state => sessions.filter(s => s.kind === 'role' && s.state === state).length;
|
||||
const metrics = {
|
||||
org: id, needsYou: inbox.length, oldestNeedsYouAt: inbox.map(d => d.at).filter(Boolean).sort()[0] ?? null,
|
||||
sessions: { working: roleCount('working'), waiting: roleCount('waiting'), idle: roleCount('idle'), live: sessions.filter(s => s.kind === 'thread').length },
|
||||
openTasks: tasks.filter(t => !t.done).length,
|
||||
doneThisWeek: tasks.filter(t => t.done && t.changedAt && nowMs - Date.parse(t.changedAt) <= WEEK_MS).length,
|
||||
projects: { active: projectRows.filter(p => p.status === 'active').length, archived: projectRows.filter(p => p.status === 'archived').length },
|
||||
usage: data.usage?.total ?? null, limits: arr(data.limits?.tokens), decisionWaitMedianMs: median(decided),
|
||||
};
|
||||
return { projects: projectRows, sessions, inbox, tasks, metrics };
|
||||
}
|
||||
|
||||
function sortProjects(rows, { sort, order }) {
|
||||
const activity = (a, b) => (a.lastActivity ?? '').localeCompare(b.lastActivity ?? '');
|
||||
const name = (a, b) => (a.name ?? '').localeCompare(b.name ?? '') || a.id.localeCompare(b.id);
|
||||
const by = {
|
||||
activity: (a, b) => activity(a, b) || name(a, b),
|
||||
name,
|
||||
org: (a, b) => a.org.localeCompare(b.org) || name(a, b),
|
||||
status: (a, b) => PROJECT_STATUSES.indexOf(a.status) - PROJECT_STATUSES.indexOf(b.status) || PROJECT_STATES.indexOf(a.state) - PROJECT_STATES.indexOf(b.state) || name(a, b),
|
||||
}[sort];
|
||||
const sign = order === 'desc' ? -1 : 1;
|
||||
return [...rows].sort((a, b) => sign * by(a, b));
|
||||
}
|
||||
|
||||
// `organizations.members(session, { timeoutMs })` gives the orgs this session belongs to, each
|
||||
// `{ id, doc, status(), options() }` or `{ id, invalid, reason }`. A registry that cannot be read
|
||||
// throws, and the HTTP handler answers a fixed message.
|
||||
export async function readWorkspace({ organizations, session, query = parseWorkspaceQuery(''), timeoutMs = WORKSPACE_TIMEOUT_MS, now = Date.now, ctx = {} }) {
|
||||
const members = (await organizations.members(session, { timeoutMs })).filter(m => !query.orgs || query.orgs.has(m.id));
|
||||
const needs = [...new Set([...query.sections].flatMap(s => NEEDS[s]))];
|
||||
const read = await Promise.all(members.map(m => readOrg(m, needs, { timeoutMs, now, ctx })));
|
||||
const nowMs = now();
|
||||
const merged = { projects: [], sessions: [], inbox: [], tasks: [], metrics: [] };
|
||||
for (const r of read) {
|
||||
if (r.org.state !== 'ok') continue;
|
||||
const rows = r.rows;
|
||||
for (const key of Object.keys(merged)) merged[key].push(...(key === 'metrics' ? [rows.metrics] : rows[key]));
|
||||
}
|
||||
const body = {
|
||||
readAt: iso(nowMs), pollMs: WORKSPACE_POLL_MS, timeoutMs,
|
||||
summary: { read: read.filter(r => r.org.state === 'ok').length, of: read.length },
|
||||
orgs: read.map(r => r.org),
|
||||
};
|
||||
if (query.sections.has('projects')) {
|
||||
const rows = merged.projects.filter(p => query.status.has(p.status) && (!query.state || query.state.has(p.state)) && (!query.name || (p.name ?? '').toLowerCase().includes(query.name)));
|
||||
body.projects = sortProjects(rows, query);
|
||||
}
|
||||
if (query.sections.has('sessions')) body.sessions = merged.sessions.sort((a, b) => a.org.localeCompare(b.org) || (b.since ?? '').localeCompare(a.since ?? ''));
|
||||
if (query.sections.has('inbox')) body.inbox = merged.inbox.sort((a, b) => (a.class === 'gated' ? 0 : 1) - (b.class === 'gated' ? 0 : 1) || (a.at ?? '').localeCompare(b.at ?? ''));
|
||||
if (query.sections.has('tasks')) {
|
||||
const p = query.project;
|
||||
body.tasks = merged.tasks.filter(t => !p || (t.org === p.org && (t.project?.id === p.key || t.project?.key === p.key)))
|
||||
.sort((a, b) => a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref));
|
||||
}
|
||||
if (query.sections.has('metrics')) {
|
||||
const m = merged.metrics;
|
||||
const sum = f => m.reduce((n, x) => n + f(x), 0);
|
||||
body.metrics = {
|
||||
orgs: m,
|
||||
totals: {
|
||||
needsYou: sum(x => x.needsYou), oldestNeedsYouAt: m.map(x => x.oldestNeedsYouAt).filter(Boolean).sort()[0] ?? null,
|
||||
sessions: { working: sum(x => x.sessions.working), waiting: sum(x => x.sessions.waiting), idle: sum(x => x.sessions.idle), live: sum(x => x.sessions.live) },
|
||||
openTasks: sum(x => x.openTasks), doneThisWeek: sum(x => x.doneThisWeek),
|
||||
projects: { active: sum(x => x.projects.active), archived: sum(x => x.projects.archived) },
|
||||
},
|
||||
};
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
const json = (res, status, body) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
// GET /api/workspace. The hook fence has required a signed-in session on the page's own name.
|
||||
// Only the owner reads it: a granted user or the tester sees no organizations today, so they get
|
||||
// owner-only here too, and nothing about any org.
|
||||
export async function workspaceHTTP(req, res, { session = null, owner = false, organizations = null, search = '', timeoutMs = WORKSPACE_TIMEOUT_MS, now = Date.now, ctx = {}, log = () => {} }) {
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
if (!organizations || typeof organizations.members !== 'function') return json(res, 404, { error: 'org-unavailable' });
|
||||
if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'the cross-organization view is the owner\'s' });
|
||||
let query;
|
||||
try { query = parseWorkspaceQuery(search); }
|
||||
catch (e) { if (e instanceof WorkspaceQueryError) return json(res, 400, { error: 'invalid-request', message: e.message }); throw e; }
|
||||
let body;
|
||||
try { body = await readWorkspace({ organizations, session, query, timeoutMs, now, ctx }); }
|
||||
catch { log('workspace: organization registry could not be read'); return json(res, 503, { error: 'org-unavailable', message: 'Organization registry could not be read' }); }
|
||||
return json(res, 200, body);
|
||||
}
|
||||
@@ -218,7 +218,8 @@ test('row 194 N5: agents.js is served as JavaScript and index.html loads it once
|
||||
const s = await setup(t);
|
||||
const js = await get(s, '/agents.js');
|
||||
assert.equal(js.status, 200); assert.match(js.type, /^text\/javascript/);
|
||||
assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section \}\)/);
|
||||
// F3 adds the agent restart receipt and the thread test that Settings > Routing uses.
|
||||
assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section, restartMarkup, isThread: definition => threadDefinitions\.has\(definition\) \}\)/);
|
||||
assert.match(js.text, /Agent actions are unavailable here\. Use mosaic org instance from the instance CLI\./);
|
||||
const html = await get(s, '/');
|
||||
assert.equal(html.text.match(/<script src="\/agents\.js" defer><\/script>/g)?.length, 1);
|
||||
|
||||
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// F3: the owner restarts the Console from Settings > Console. The real supervisor in
|
||||
// `mosaic console start` relaunches the Console on the same address, and the open page shows the
|
||||
// restart and comes back by itself, with no manual reload. A page holding an unsaved draft keeps
|
||||
// it and offers the reload instead of taking it.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
|
||||
import { AGENT_MARKERS } from '../../cli/src/transport.mjs';
|
||||
import { consoleStart, consoleStop } from '../../cli/src/org-console.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
|
||||
const REPO = new URL('../../../', import.meta.url).pathname;
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// The page reloads on its own, so a check can land between two documents; it is retried.
|
||||
async function until(b, expression, ms = 30000) {
|
||||
const deadline = Date.now() + ms;
|
||||
for (;;) {
|
||||
try { if (await b.evaluate(`!!(${expression})`)) return; } catch { /* between documents */ }
|
||||
if (Date.now() > deadline) throw new Error(`timed out: ${expression}`);
|
||||
await sleep(50);
|
||||
}
|
||||
}
|
||||
// Every banner the page shows is kept in sessionStorage, which outlives the page's own reload.
|
||||
const WATCH = `(() => {
|
||||
const seen = JSON.parse(sessionStorage.getItem('f3-seen') || '[]');
|
||||
new MutationObserver(() => {
|
||||
const box = document.getElementById('console-reconnect');
|
||||
if (box && !box.hidden && seen.at(-1) !== box.textContent) { seen.push(box.textContent); sessionStorage.setItem('f3-seen', JSON.stringify(seen)); }
|
||||
}).observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
|
||||
return true;
|
||||
})()`;
|
||||
|
||||
test('Settings > Console restarts the Console under its supervisor and the open page reconnects by itself', { timeout: 120000 }, async t => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'f3-'));
|
||||
const env = { ...process.env, HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-f3test/config.json') };
|
||||
for (const k of AGENT_MARKERS) delete env[k];
|
||||
delete env.NODE_TEST_CONTEXT;
|
||||
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'f3test'], { env, encoding: 'utf8' });
|
||||
assert.equal(setup.status, 0, setup.stderr);
|
||||
const ctx = { configDir: join(home, '.config/mosaic-f3test'), dataRoot: join(home, '.mosaic-f3test'), instance: 'f3test', env };
|
||||
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
|
||||
const warnings = [];
|
||||
const run = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) });
|
||||
t.after(async () => { run.stop(); await run.done; try { await consoleStop(ctx, { warn: () => {} }); } catch {} rmSync(home, { recursive: true, force: true }); });
|
||||
const stateFile = join(ctx.dataRoot, 'console/console.json');
|
||||
const pidNow = () => JSON.parse(readFileSync(stateFile, 'utf8')).pid;
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`${run.url}#/settings/console`);
|
||||
await until(b, 'document.getElementById("console-restart")');
|
||||
const boot1 = await b.evaluate('window.MosaicConsole.config.boot');
|
||||
assert.equal(typeof boot1, 'string');
|
||||
assert.match(await b.evaluate('document.getElementById("console-restart-title").closest("section").textContent'), /Organization buses are separate processes and keep running/);
|
||||
await b.evaluate(`window.__f3 = 'first'`); await b.evaluate(WATCH);
|
||||
|
||||
// The button asks first; Cancel restarts nothing.
|
||||
await b.evaluate('document.getElementById("console-restart").click()');
|
||||
await until(b, 'document.getElementById("console-restart-cancel")');
|
||||
await b.evaluate('document.getElementById("console-restart-cancel").click()');
|
||||
await until(b, '!document.getElementById("console-restart-go") && !document.getElementById("console-restart").hidden');
|
||||
assert.equal(pidNow(), run.pid);
|
||||
|
||||
await b.evaluate('document.getElementById("console-restart").click()');
|
||||
await until(b, 'document.getElementById("console-restart-go")');
|
||||
await b.evaluate('document.getElementById("console-restart-go").click()');
|
||||
// The page reloads itself once the new Console answers: the marker is gone, the boot is new.
|
||||
await until(b, `window.__f3 === undefined && window.MosaicConsole?.config?.boot && window.MosaicConsole.config.boot !== ${JSON.stringify(boot1)}`);
|
||||
const seen1 = JSON.parse(await b.evaluate(`sessionStorage.getItem('f3-seen')`));
|
||||
assert.equal(seen1[0], 'Console restarting. This page reconnects when it is back.');
|
||||
assert.equal(await b.evaluate('location.hash'), '#/settings/console');
|
||||
await until(b, 'document.getElementById("console-restart")');
|
||||
const pid2 = pidNow();
|
||||
assert.notEqual(pid2, run.pid);
|
||||
assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).url, run.url);
|
||||
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
|
||||
const trail = readFileSync(join(ctx.dataRoot, 'console/actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
|
||||
assert.deepEqual(trail.map(e => [e.action, e.outcome, e.by.provider, e.boot]), [['console.restart', 'restarting', 'local-dev', boot1]]);
|
||||
|
||||
// An unsaved draft (a reply being written) is not thrown away: the page says the Console is
|
||||
// back and offers the reload. The textarea stands in for the chat reply box.
|
||||
const boot2 = await b.evaluate('window.MosaicConsole.config.boot');
|
||||
await b.evaluate(`(() => { window.__f3 = 'second'; sessionStorage.removeItem('f3-seen'); const t = document.createElement('textarea'); t.id = 'reply'; t.value = 'half a reply'; document.body.append(t); return true; })()`);
|
||||
await b.evaluate(WATCH);
|
||||
await b.evaluate('document.getElementById("console-restart").click()');
|
||||
await until(b, 'document.getElementById("console-restart-go")');
|
||||
await b.evaluate('document.getElementById("console-restart-go").click()');
|
||||
await until(b, 'document.getElementById("console-reconnect-reload")');
|
||||
assert.equal(await b.evaluate('window.__f3'), 'second');
|
||||
assert.equal(await b.evaluate('document.getElementById("reply").value'), 'half a reply');
|
||||
assert.deepEqual(JSON.parse(await b.evaluate(`sessionStorage.getItem('f3-seen')`)), ['Console restarting. This page reconnects when it is back.', 'The Console is back. Your unsaved draft is kept on this page. Reload now']);
|
||||
assert.notEqual(await b.evaluate('window.MosaicConsole.config.boot'), boot2);
|
||||
assert.notEqual(pidNow(), pid2);
|
||||
await b.evaluate('document.getElementById("console-reconnect-reload").click()');
|
||||
await until(b, `window.__f3 === undefined && document.getElementById("console-restart")`);
|
||||
assert.equal(readFileSync(join(ctx.dataRoot, 'console/actions.jsonl'), 'utf8').split('\n').filter(Boolean).length, 2);
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
// F3: the owner's Console restart and the organization settings writes, through the real server.
|
||||
// Each control is refused without the human, refused from another origin, works for the dev local
|
||||
// owner and writes the action trail. No test reaches a live Console, a real config or a secret.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { actionTrail } from '../src/action-trail.mjs';
|
||||
import { consoleAccess } from '../src/access.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { createSsoSession } from './helpers/sso-session.mjs';
|
||||
|
||||
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
|
||||
const REVISION = 'a'.repeat(64);
|
||||
const REQUEST = '10000000-0000-4000-8000-000000000001';
|
||||
const ROUTES = { assistant: { harness: 'claude', model: 'opus', thinking: 'high' } };
|
||||
const ROUTES_ANSWER = { revision: 'b'.repeat(64), harnesses: [], models: [], levels: {}, instances: { assistant: ROUTES.assistant } };
|
||||
|
||||
function call(target, { method = 'GET', path, headers = {}, body }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const all = { ...headers };
|
||||
for (const k of Object.keys(all)) if (all[k] === null) delete all[k];
|
||||
if (body !== undefined) all['content-length'] = Buffer.byteLength(body);
|
||||
const req = request({ ...target, method, path, headers: all }, res => {
|
||||
let raw = '';
|
||||
res.on('data', c => { raw += c; });
|
||||
res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch { /* not JSON */ } resolve({ status: res.statusCode, body: json, raw }); });
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
const scratch = t => { const root = mkdtempSync(join(tmpdir(), 'f3-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
|
||||
const lines = dir => (existsSync(join(dir, 'actions.jsonl')) ? readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []);
|
||||
const spy = () => { const calls = []; let hit; const once = new Promise(r => { hit = r; }); const fn = () => { calls.push(Date.now()); hit(); }; return { fn, calls, once }; };
|
||||
|
||||
// A loopback Console under the dev local owner with --send; `opts` overrides any option.
|
||||
async function devConsole(t, opts = {}) {
|
||||
const root = scratch(t), dir = join(root, 'console'), restart = spy();
|
||||
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), log: () => {}, restart: restart.fn, trail: actionTrail(dir), ...opts });
|
||||
t.after(() => close(server));
|
||||
const port = server.address().port, host = `127.0.0.1:${port}`, origin = `http://${host}`;
|
||||
const write = (path, body = '{}', headers = {}) => call({ host: '127.0.0.1', port }, { method: 'POST', path, body, headers: { host, origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...headers } });
|
||||
const read = async path => (await call({ host: '127.0.0.1', port }, { path, headers: { host } })).body;
|
||||
return { port, host, origin, dir, root, restart, write, read };
|
||||
}
|
||||
const FENCE = port => [
|
||||
[{ origin: null }, 'cross-origin request refused'],
|
||||
[{ origin: 'http://evil.example' }, 'cross-origin request refused'],
|
||||
[{ origin: `http://localhost:${port + 1}` }, 'cross-origin request refused'],
|
||||
[{ 'sec-fetch-site': null }, 'cross-site request refused'],
|
||||
[{ 'sec-fetch-site': 'cross-site' }, 'cross-site request refused'],
|
||||
[{ 'sec-fetch-site': 'same-site' }, 'cross-site request refused'],
|
||||
[{ host: `evil.example:${port}` }, 'non-local Host refused'],
|
||||
[{ 'x-mosaic-tester': '1' }, 'tester listener required'],
|
||||
];
|
||||
|
||||
test('the dev owner restarts a supervised Console once, after the answer, and the trail names them', async t => {
|
||||
const c = await devConsole(t);
|
||||
const config = await c.read('/api/config');
|
||||
assert.equal(config.consoleRestart, true);
|
||||
assert.match(config.boot, /^[0-9a-f-]{36}$/);
|
||||
const r = await c.write('/api/console/restart');
|
||||
assert.equal(r.status, 202, r.raw);
|
||||
assert.deepEqual(r.body, { outcome: 'restarting', boot: config.boot });
|
||||
await c.restart.once;
|
||||
assert.equal(c.restart.calls.length, 1);
|
||||
const trail = lines(c.dir);
|
||||
assert.equal(trail.length, 1);
|
||||
const [{ at, ...entry }] = trail;
|
||||
assert.ok(Number.isFinite(Date.parse(at)));
|
||||
assert.deepEqual(entry, { by: DEV, action: 'console.restart', outcome: 'restarting', boot: config.boot });
|
||||
assert.equal(statSync(join(c.dir, 'actions.jsonl')).mode & 0o777, 0o600);
|
||||
assert.equal(statSync(c.dir).mode & 0o777, 0o700);
|
||||
});
|
||||
|
||||
test('a Console restart from another origin, site, Host or the tester header is refused and leaves no trail', async t => {
|
||||
const c = await devConsole(t);
|
||||
for (const [headers, error] of FENCE(c.port)) {
|
||||
const r = await c.write('/api/console/restart', '{}', headers);
|
||||
assert.equal(r.status, 403, JSON.stringify(headers));
|
||||
assert.equal(r.body.error, error, JSON.stringify(headers));
|
||||
}
|
||||
assert.equal(c.restart.calls.length, 0);
|
||||
assert.deepEqual(lines(c.dir), []);
|
||||
});
|
||||
|
||||
test('without the signed-in owner there is no Console restart', async t => {
|
||||
const root = scratch(t), dir = join(root, 'console'), restart = spy();
|
||||
// A loopback Console with no sign-in has no owner at all.
|
||||
const anon = await startServer({ port: 0, board: 'http://127.0.0.1:9', log: () => {}, restart: restart.fn, trail: actionTrail(dir) });
|
||||
t.after(() => close(anon));
|
||||
const ap = anon.address().port, ah = `127.0.0.1:${ap}`;
|
||||
const a = await call({ host: '127.0.0.1', port: ap }, { method: 'POST', path: '/api/console/restart', body: '{}', headers: { host: ah, origin: `http://${ah}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
|
||||
assert.equal(a.status, 403); assert.equal(a.body.error, 'owner-only');
|
||||
assert.equal((await call({ host: '127.0.0.1', port: ap }, { path: '/api/config', headers: { host: ah } })).body.consoleRestart, false);
|
||||
|
||||
// Under --sso: signed out, a granted user, then the signed-in owner.
|
||||
const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
|
||||
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
|
||||
const s = await createSsoSession(t, { users: consoleAccess({ load: () => access, business: () => 'demo' }) });
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: s.sso, send: async () => ({}), log: () => {}, restart: restart.fn, trail: actionTrail(dir) });
|
||||
t.after(() => close(web));
|
||||
const port = web.address().port, target = { host: '127.0.0.1', port };
|
||||
const post = cookie => call(target, { method: 'POST', path: '/api/console/restart', body: '{}', headers: s.writeHeaders(cookie) });
|
||||
const out = await post(null);
|
||||
assert.equal(out.status, 401); assert.equal(out.body.error, 'sign-in required');
|
||||
const alice = await s.signIn(port, { sub: 'alice-sub' });
|
||||
const granted = await post(alice);
|
||||
assert.equal(granted.status, 403); assert.equal(granted.body.error, 'owner-only');
|
||||
assert.equal(restart.calls.length, 0);
|
||||
assert.deepEqual(lines(dir), []);
|
||||
const owner = await s.signIn(port);
|
||||
assert.equal((await call(target, { path: '/api/config', headers: s.readHeaders(owner) })).body.consoleRestart, true);
|
||||
const ok = await post(owner);
|
||||
assert.equal(ok.status, 202, ok.raw);
|
||||
await restart.once;
|
||||
const [entry] = lines(dir);
|
||||
assert.equal(entry.by.provider, 'writer'); assert.equal(entry.by.sub, 'owner-sub'); assert.equal(entry.by.name, 'jason');
|
||||
assert.equal(entry.action, 'console.restart');
|
||||
});
|
||||
|
||||
test('the tester listener never restarts the Console', async t => {
|
||||
const root = scratch(t), dir = join(root, 'console'), restart = spy(), socketPath = join(root, 'tester.sock');
|
||||
const server = await startServer({ tester: true, testerSocket: socketPath, board: 'http://127.0.0.1:9', log: () => {}, restart: restart.fn, trail: actionTrail(dir) });
|
||||
t.after(() => close(server));
|
||||
const headers = { host: 'mosaic-tester.invalid', origin: 'http://mosaic-tester.invalid', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||
const r = await call({ socketPath }, { method: 'POST', path: '/api/console/restart', body: '{}', headers });
|
||||
assert.equal(r.status, 403); assert.equal(r.body.error, 'owner-only');
|
||||
assert.equal((await call({ socketPath }, { path: '/api/config', headers: { host: 'mosaic-tester.invalid' } })).body.consoleRestart, false);
|
||||
assert.equal(restart.calls.length, 0);
|
||||
assert.deepEqual(lines(dir), []);
|
||||
});
|
||||
|
||||
test('a Console without --send, its supervisor or a safe trail says so and stays up', async t => {
|
||||
const readOnly = await devConsole(t, { send: null });
|
||||
assert.equal((await readOnly.read('/api/config')).consoleRestart, false);
|
||||
const ro = await readOnly.write('/api/console/restart');
|
||||
assert.equal(ro.status, 403); assert.equal(ro.body.error, 'read-only');
|
||||
|
||||
const detached = await devConsole(t, { restart: null });
|
||||
assert.equal((await detached.read('/api/config')).consoleRestart, false);
|
||||
const d = await detached.write('/api/console/restart');
|
||||
assert.equal(d.status, 409); assert.equal(d.body.error, 'not-supervised');
|
||||
assert.deepEqual(lines(detached.dir), []);
|
||||
|
||||
const noTrail = await devConsole(t, { trail: null });
|
||||
assert.equal((await noTrail.read('/api/config')).consoleRestart, false);
|
||||
const n = await noTrail.write('/api/console/restart');
|
||||
assert.equal(n.status, 503); assert.equal(n.body.error, 'trail-unavailable');
|
||||
|
||||
const broken = await devConsole(t, { trail: () => { throw new Error('disk full'); } });
|
||||
const b = await broken.write('/api/console/restart');
|
||||
assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable');
|
||||
|
||||
// A trail directory that is a symlink is unsafe; the restart is refused, nothing is written.
|
||||
const root = scratch(t), real = join(root, 'real');
|
||||
mkdirSync(real, { mode: 0o700 }); symlinkSync(real, join(root, 'console'));
|
||||
const linked = await devConsole(t, { trail: actionTrail(join(root, 'console')) });
|
||||
const l = await linked.write('/api/console/restart');
|
||||
assert.equal(l.status, 500); assert.equal(l.body.error, 'trail-unavailable');
|
||||
assert.equal(existsSync(join(real, 'actions.jsonl')), false);
|
||||
|
||||
for (const c of [readOnly, detached, noTrail, broken, linked]) assert.equal(c.restart.calls.length, 0);
|
||||
});
|
||||
|
||||
test('the Console restart body is exactly {} as JSON', async t => {
|
||||
const c = await devConsole(t);
|
||||
for (const [body, headers, status] of [
|
||||
['{}', { 'content-type': 'text/plain' }, 415],
|
||||
['{}', { 'content-type': null }, 415],
|
||||
['{"now":true}', {}, 400],
|
||||
['[]', {}, 400],
|
||||
['nope', {}, 400],
|
||||
['', {}, 400],
|
||||
]) {
|
||||
const r = await c.write('/api/console/restart', body, headers);
|
||||
assert.equal(r.status, status, `${body} ${JSON.stringify(headers)}`);
|
||||
}
|
||||
assert.equal(c.restart.calls.length, 0);
|
||||
assert.deepEqual(lines(c.dir), []);
|
||||
});
|
||||
|
||||
// The organization settings writes. `alpha` answers; `beta` is offline.
|
||||
function orgs() {
|
||||
const routed = [], launched = [], changed = [];
|
||||
const organizations = {
|
||||
list: async () => [{ id: 'alpha', label: 'Alpha', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: false }],
|
||||
resolve: async id => (['alpha', 'beta'].includes(id) ? {
|
||||
busHealthy: id === 'alpha', send: async () => ({}),
|
||||
routesWrite: async args => { routed.push([id, args]); return ROUTES_ANSWER; },
|
||||
agentWrite: async (verb, args) => { launched.push([id, verb, args]); return { request: REQUEST }; },
|
||||
instancesWrite: async args => { changed.push([id, args]); return { revision: REVISION, restart: false, definitions: {}, instances: { coder: {} } }; },
|
||||
} : null),
|
||||
};
|
||||
return { organizations, routed, launched, changed };
|
||||
}
|
||||
|
||||
test('the dev owner changes an organization agent route and restarts it, and the trail names the organization', async t => {
|
||||
const o = orgs();
|
||||
const c = await devConsole(t, { organizations: o.organizations });
|
||||
const alpha = await c.read('/api/orgs/alpha/config');
|
||||
assert.equal(alpha.send, false); assert.equal(alpha.settingsSend, true); assert.equal(alpha.orgSetup, true);
|
||||
assert.equal((await c.read('/api/orgs/beta/config')).settingsSend, false, 'an offline organization offers no settings writes');
|
||||
const r = await c.write('/api/orgs/alpha/routes', JSON.stringify({ revision: REVISION, routes: ROUTES }));
|
||||
assert.equal(r.status, 200, r.raw);
|
||||
assert.deepEqual(o.routed, [['alpha', { revision: REVISION, routes: ROUTES }]]);
|
||||
const launch = await c.write('/api/orgs/alpha/agent/launch', JSON.stringify({ instance: 'assistant' }));
|
||||
assert.equal(launch.status, 200, launch.raw);
|
||||
assert.deepEqual(o.launched, [['alpha', 'instance.launch', { instance: 'assistant' }]]);
|
||||
const trail = lines(c.dir).map(({ at, ...e }) => e);
|
||||
assert.deepEqual(trail, [
|
||||
{ by: DEV, org: 'alpha', action: 'routes.set', instances: ['assistant'], outcome: 'done' },
|
||||
{ by: DEV, org: 'alpha', action: 'instance.launch', instance: 'assistant', outcome: `done request=${REQUEST}` },
|
||||
]);
|
||||
// The limits route passes the gate and is refused by its own check: alpha has no settings transport.
|
||||
const limits = await c.write('/api/orgs/alpha/settings', '{}');
|
||||
assert.equal(limits.status, 403); assert.equal(limits.body.error, 'read-only');
|
||||
assert.equal((await c.write('/api/orgs/missing/routes', JSON.stringify({ revision: REVISION, routes: ROUTES }))).status, 404);
|
||||
});
|
||||
|
||||
test('F3 review P3: an organization instance change appends its trail line, as the README promises', async t => {
|
||||
const o = orgs();
|
||||
const c = await devConsole(t, { organizations: o.organizations });
|
||||
const r = await c.write('/api/orgs/alpha/instances', JSON.stringify({ revision: REVISION, instance: 'coder', op: 'change', vars: {} }));
|
||||
assert.equal(r.status, 200, r.raw);
|
||||
assert.deepEqual(o.changed, [['alpha', { revision: REVISION, instance: 'coder', op: 'change', vars: {} }]]);
|
||||
assert.deepEqual(lines(c.dir).map(({ at, ...e }) => e), [
|
||||
{ by: DEV, org: 'alpha', action: 'instances.set', op: 'change', instance: 'coder', outcome: 'done restart=false' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('organization settings writes from another origin or site are refused before the bus', async t => {
|
||||
const o = orgs();
|
||||
const c = await devConsole(t, { organizations: o.organizations });
|
||||
const body = JSON.stringify({ revision: REVISION, routes: ROUTES });
|
||||
for (const [headers] of FENCE(c.port)) {
|
||||
for (const path of ['/api/orgs/alpha/routes', '/api/orgs/alpha/agent/terminate']) {
|
||||
const r = await c.write(path, path.endsWith('routes') ? body : JSON.stringify({ instance: 'assistant' }), headers);
|
||||
assert.equal(r.status, 403, `${path} ${JSON.stringify(headers)}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(o.routed, []); assert.deepEqual(o.launched, []);
|
||||
});
|
||||
|
||||
test('organization settings writes need the signed-in owner: no sign-in, the tester listener and a granted user are refused', async t => {
|
||||
const o = orgs(), root = scratch(t), dir = join(root, 'console');
|
||||
const body = JSON.stringify({ revision: REVISION, routes: ROUTES });
|
||||
const anon = await startServer({ port: 0, board: 'http://127.0.0.1:9', organizations: o.organizations, log: () => {}, trail: actionTrail(dir) });
|
||||
t.after(() => close(anon));
|
||||
const ap = anon.address().port, ah = `127.0.0.1:${ap}`;
|
||||
const a = await call({ host: '127.0.0.1', port: ap }, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: { host: ah, origin: `http://${ah}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
|
||||
assert.equal(a.status, 404); assert.equal(a.body.error, 'org-unavailable');
|
||||
|
||||
const socketPath = join(root, 'tester.sock');
|
||||
const tester = await startServer({ tester: true, testerSocket: socketPath, board: 'http://127.0.0.1:9', organizations: o.organizations, log: () => {}, trail: actionTrail(dir) });
|
||||
t.after(() => close(tester));
|
||||
const tr = await call({ socketPath }, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: { host: 'mosaic-tester.invalid', origin: 'http://mosaic-tester.invalid', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
|
||||
assert.equal(tr.status, 404); assert.equal(tr.body.error, 'org-unavailable');
|
||||
|
||||
const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
|
||||
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
|
||||
const s = await createSsoSession(t, { users: consoleAccess({ load: () => access, business: () => 'demo' }) });
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: s.sso, send: async () => ({}), organizations: o.organizations, log: () => {}, trail: actionTrail(dir) });
|
||||
t.after(() => close(web));
|
||||
const port = web.address().port, target = { host: '127.0.0.1', port };
|
||||
const alice = await s.signIn(port, { sub: 'alice-sub' });
|
||||
const g = await call(target, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: s.writeHeaders(alice) });
|
||||
assert.equal(g.status, 403); assert.equal(g.body.error, 'owner-only');
|
||||
assert.deepEqual(o.routed, []);
|
||||
assert.deepEqual(lines(dir), []);
|
||||
|
||||
// The signed-in owner on a deployed (non-dev) Console reaches the organization the same way.
|
||||
const owner = await s.signIn(port);
|
||||
const ok = await call(target, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: s.writeHeaders(owner) });
|
||||
assert.equal(ok.status, 200, ok.raw);
|
||||
assert.equal(o.routed.length, 1);
|
||||
const [entry] = lines(dir);
|
||||
assert.equal(entry.by.sub, 'owner-sub'); assert.equal(entry.org, 'alpha'); assert.equal(entry.action, 'routes.set');
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { createOrgAdmin, orgAdminHTTP, browserProblems, pathFromMessage, toCanonical, forBrowser, plainMessage } from '../src/org-admin.mjs';
|
||||
import { actionTrail } from '../src/action-trail.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
class OrgError extends Error { constructor(code, message = code) { super(message); this.code = code; } }
|
||||
@@ -37,6 +38,7 @@ function fakes() {
|
||||
busStatus: async (ctx, id) => { get(id); return { org: id, instance: 'test', root: `/data/org-runtime/${id}`, socket: '/run/s', healthy: running === id, pid: running === id ? 4242 : null }; },
|
||||
busStart: async (ctx, id, opts) => { calls.push(['busStart', id, opts]); running = id; return { org: id, pid: 4242, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
|
||||
busStopForDelete: async (ctx, id) => { calls.push(['busStopForDelete', id]); running = null; return { stopped: false, reason: 'no host runs' }; },
|
||||
busRestart: async (ctx, id) => { calls.push(['busRestart', id]); running = id; return { org: id, pid: 4343, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
|
||||
};
|
||||
const admin = createOrgAdmin({ service, registry, bus, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
|
||||
return { admin, orgs, calls, fail, service };
|
||||
@@ -479,3 +481,87 @@ test('archive requires the injected bus stop and both lifecycle routes require o
|
||||
for (const step of ['archive', 'restore']) assert.equal((await readonly.call('POST', `/api/orgs/acme/${step}`, { revision: 1 })).status, 403);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
// F3 (REQ-WEB-3): the owner restarts an organization's bus from the page, with a done receipt.
|
||||
const trailIn = t => { const root = mkdtempSync(join(tmpdir(), 'f3-trail-')); t.after(() => rmSync(root, { recursive: true, force: true })); const dir = join(root, 'console'); return { dir, trail: actionTrail(dir), lines: () => { try { return readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.ok(Number.isFinite(Date.parse(at))); return e; }); } catch (e) { if (e.code === 'ENOENT') return []; throw e; } } }; };
|
||||
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
|
||||
|
||||
test('the owner starts a stopped bus and restarts a running one, and the trail records each', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
assert.equal((await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') })).status, 201);
|
||||
let r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(r.status, 200, r.raw);
|
||||
assert.deepEqual(r.body, { outcome: 'done', step: 'bus-restart', restarted: false, healthy: true, receipt: { org: 'acme', pid: 4242, socket: '<host path>', root: '<data root>/org-runtime/acme' }, verb: 'mosaic bus start acme --dev-local-owner --detach' });
|
||||
// A stopped bus starts as setup starts it, keeping the dev local owner.
|
||||
assert.deepEqual(c.calls.filter(x => x[0] === 'busStart').map(x => x.slice(1)), [['acme', { detach: true, devLocalOwner: true, pm: false }]]);
|
||||
r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(r.status, 200, r.raw);
|
||||
assert.equal(r.body.restarted, true); assert.equal(r.body.healthy, true); assert.equal(r.body.verb, 'mosaic bus restart acme');
|
||||
assert.equal(r.body.receipt.pid, 4343);
|
||||
assert.deepEqual(c.calls.filter(x => x[0] === 'busRestart'), [['busRestart', 'acme']]);
|
||||
assert.deepEqual(tr.lines(), [
|
||||
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: false },
|
||||
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
|
||||
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: true },
|
||||
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a bus restart from another origin or site, without the owner or --send, or for no organization is refused', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
for (const headers of [{ origin: null }, { origin: 'http://evil.example' }, { origin: `http://localhost:${c.port + 1}` }, { 'sec-fetch-site': null }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': 'same-site' }, { 'x-mosaic-tester': '1' }]) {
|
||||
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {}, headers);
|
||||
assert.equal(r.status, 403, JSON.stringify(headers));
|
||||
}
|
||||
assert.equal((await c.call('POST', '/api/orgs/acme/restart/bus', {}, { 'content-type': 'text/plain' })).status, 415);
|
||||
assert.equal((await c.call('POST', '/api/orgs/acme/restart/bus', { now: true })).status, 400);
|
||||
assert.equal((await c.call('GET', '/api/orgs/acme/restart/bus')).status, 404);
|
||||
assert.equal((await c.call('POST', '/api/orgs/acme/restart/queue', {})).status, 404);
|
||||
const unknown = await c.call('POST', '/api/orgs/ghost/restart/bus', {});
|
||||
assert.equal(unknown.status, 404); assert.equal(unknown.body.error, 'org-unavailable');
|
||||
const readOnly = await console_(t, { trail: tr.trail, send: null });
|
||||
const ro = await readOnly.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(ro.status, 403); assert.equal(ro.body.error, 'read-only');
|
||||
const anon = await console_(t, { trail: tr.trail, sso: null, send: null });
|
||||
const a = await anon.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(a.status, 403); assert.equal(a.body.error, 'owner-only');
|
||||
assert.equal(c.calls.filter(x => ['busStart', 'busRestart'].includes(x[0])).length, 0);
|
||||
assert.deepEqual(tr.lines(), []);
|
||||
});
|
||||
|
||||
test('a bus restart with no trail, or a trail that cannot be written, changes nothing', async t => {
|
||||
const c = await console_(t);
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(r.status, 503); assert.equal(r.body.error, 'trail-unavailable');
|
||||
const broken = await console_(t, { trail: () => { throw new Error('disk full'); } });
|
||||
await broken.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
const b = await broken.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable');
|
||||
for (const x of [c, broken]) assert.equal(x.calls.filter(y => ['busStart', 'busRestart'].includes(y[0])).length, 0);
|
||||
});
|
||||
|
||||
test('a bus that refuses to restart is a visible failure and the trail says refused', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
await c.call('POST', '/api/orgs/acme/setup/bus', {});
|
||||
const bus = await c.admin.bus();
|
||||
bus.busRestart = async () => { throw Object.assign(new Error('bus failed to start; see /data/org-runtime/acme/host/host.log'), { name: 'CliError', exitCode: 3 }); };
|
||||
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
|
||||
assert.equal(r.body.message, 'bus failed to start; see <data root>/org-runtime/acme/host/host.log');
|
||||
assert.deepEqual(tr.lines().map(e => e.outcome), ['requested', 'refused']);
|
||||
// A host whose status cannot be read (a stale lock) is refused before anything changes; the
|
||||
// attempt is still in the trail, and nothing restarts or starts.
|
||||
bus.busStatus = async () => { throw Object.assign(new Error('stale bus host lock'), { name: 'CliError', exitCode: 3 }); };
|
||||
const before = c.calls.length;
|
||||
const s = await c.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.equal(s.status, 409); assert.equal(s.body.error, 'bus-refused');
|
||||
assert.equal(s.body.message, 'stale bus host lock. Inspect it with mosaic bus status acme; mosaic bus recover acme clears a host that died.');
|
||||
assert.deepEqual(tr.lines().slice(2), [{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'refused', running: null }]);
|
||||
assert.equal(c.calls.slice(before).filter(y => ['busStart', 'busRestart'].includes(y[0])).length, 0);
|
||||
});
|
||||
@@ -54,7 +54,10 @@ test('organization routes bind reads and writes to the authorized organization',
|
||||
assert.deepEqual(calls, [['alpha', { thread, body: 'hello' }]]);
|
||||
assert.equal((await post(`/api/orgs/beta/chat/threads/${thread}/messages`, { body: 'hello' }, { origin: 'http://evil.example' }))[0], 403);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal((await post('/api/orgs/alpha/settings', {}))[1].error, 'unavailable');
|
||||
// F3: the owner's settings writes now reach the selected organization; this one offers no
|
||||
// settings writer, so the route itself answers read-only and nothing is written.
|
||||
assert.equal((await post('/api/orgs/alpha/settings', {}))[1].error, 'read-only');
|
||||
assert.equal((await post('/api/orgs/alpha/settings', {}, { origin: 'http://evil.example' }))[0], 403);
|
||||
assert.equal((await post('/api/settings', {}))[1].error, 'org-unavailable');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// F3: after a route save, Settings > Routing says how the change reaches each saved agent, read
|
||||
// from the instances: the launcher and a stopped agent take it at their next start, a running
|
||||
// session keeps its old route until it ends (a thread when it goes idle), and a running agent
|
||||
// offers "Restart now". Restart now terminates with the reason "route change", waits for the
|
||||
// session to end and launches it again; a thread is only terminated. Real server under the dev
|
||||
// local owner, a stub bus, every write gated by the browser's own Origin and recorded in the trail.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { browser } from './browser.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { actionTrail } from '../src/action-trail.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const REV = 'a'.repeat(64), REV2 = 'b'.repeat(64);
|
||||
const END = '10000000-0000-4000-8000-0000000000e1', LAUNCH = '10000000-0000-4000-8000-0000000000a1', END2 = '10000000-0000-4000-8000-0000000000e2';
|
||||
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
|
||||
const route = thinking => ({ harness: 'claude', model: 'opus', thinking, fallback: null });
|
||||
|
||||
test('F3 Settings > Routing: a save lists how the change reaches each agent, and Restart now moves a running one', { timeout: 120000 }, async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'f3r-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const entry = (definition, extra) => ({ definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [], alive: [], ...extra });
|
||||
const instances = {
|
||||
chief: entry('chief', { launcher: true }),
|
||||
coder: entry('coder', { running: ['run-1'], alive: ['run-1'] }),
|
||||
assistant: entry('assistant', { running: ['run-a'], alive: ['run-a'] }),
|
||||
reviewer: entry('reviewer', { launched: false }),
|
||||
};
|
||||
const view = () => structuredClone({ business: 'acme', revision: REV, launch: true, noHire: [], retired: [], instances,
|
||||
definitions: Object.fromEntries(Object.values(instances).map(e => [e.definition, { services: [], accepted: true }])) });
|
||||
let routes = { revision: REV, harnesses: ['claude'], models: ['opus'], levels: { claude: ['low', 'high'] },
|
||||
instances: Object.fromEntries(Object.keys(instances).map(n => [n, route('high')])) };
|
||||
const asked = [], requests = new Map();
|
||||
const bus = { messages: async () => ({ messages: [], unread: 0 }), usage: async () => { throw Error('no usage'); }, instances: async () => view(), routes: async () => structuredClone(routes) };
|
||||
const routesWrite = async args => {
|
||||
asked.push(['routes.set', args]);
|
||||
routes = { ...routes, revision: REV2, instances: { ...routes.instances, ...structuredClone(args.routes) } };
|
||||
return structuredClone(routes);
|
||||
};
|
||||
const agentWrite = async (verb, args) => {
|
||||
asked.push([verb, args]);
|
||||
if (verb === 'instance.end') {
|
||||
const id = args.instance === 'coder' ? END : END2;
|
||||
requests.set(id, { instance: args.instance, action: 'end' });
|
||||
// The session ends a moment after the request closes, so the page has to wait for it.
|
||||
setTimeout(() => { instances[args.instance].running = []; instances[args.instance].alive = []; }, 400);
|
||||
return { request: id, runs: [...instances[args.instance].running], cancelled: [], taken: [] };
|
||||
}
|
||||
if (verb === 'instance.launch') { requests.set(LAUNCH, { instance: args.instance, action: 'launch' }); instances[args.instance].running = ['run-2']; return { request: LAUNCH }; }
|
||||
if (verb === 'instance.request') {
|
||||
const r = requests.get(args.request);
|
||||
return { request: args.request, instance: r.instance, action: r.action, state: 'closed', done: { run: r.action === 'launch' ? 'run-2' : null, transaction: null, refusal: null }, cancelled: null };
|
||||
}
|
||||
throw Error(`unexpected ${verb}`);
|
||||
};
|
||||
const dir = join(root, 'console');
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), bus, routesWrite, agentWrite,
|
||||
reads: { settings: () => ({ business: 'acme', release: 'fixture', notifier: {} }) }, trail: actionTrail(dir), log: () => {} });
|
||||
t.after(() => close(web));
|
||||
const b = await browser(); t.after(() => b.close());
|
||||
const wait = (expr, ms = 20000) => b.evaluate(`(async()=>{const end=Date.now()+${ms};for(;;){if(${expr})return true;if(Date.now()>end)throw Error('wait failed: '+${JSON.stringify(expr)});await new Promise(r=>setTimeout(r,30));}})()`);
|
||||
const cell = (name, key) => `[...document.querySelectorAll('[data-route]')].find(e=>e.dataset.route===${JSON.stringify(JSON.stringify([name, key]))})`;
|
||||
const items = () => b.evaluate(`[...document.querySelectorAll('.routes-apply li')].map(li=>{const c=li.cloneNode(true);c.querySelector('.agent-restart')?.remove();return c.textContent.trim();})`);
|
||||
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`);
|
||||
await wait(`${cell('coder', 'thinking')}&&!${cell('coder', 'thinking')}.disabled`);
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('.routes-apply')`), false, 'nothing is listed before a save');
|
||||
for (const name of ['chief', 'coder', 'assistant', 'reviewer']) await b.evaluate(`(()=>{const s=${cell(name, 'thinking')};s.value='low';s.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`);
|
||||
await b.evaluate(`document.getElementById('routes-form').requestSubmit();true`);
|
||||
await wait(`document.querySelector('.routes-apply')`);
|
||||
assert.deepEqual(asked, [['routes.set', { revision: REV, routes: { chief: route('low'), coder: route('low'), assistant: route('low'), reviewer: route('low') } }]]);
|
||||
assert.equal(await b.evaluate(`document.getElementById('routes-receipt').textContent`), 'Routes saved for chief, coder, assistant, reviewer. The next launch of each uses the new route.');
|
||||
assert.deepEqual(await items(), [
|
||||
'chief: The launcher. It takes the new route at its next start.',
|
||||
'coder: Running. Its session keeps the old route until it ends. Restart it to move it now.',
|
||||
'assistant: Running. Its session keeps the old route until the thread goes idle; the next message after that starts it on the new route. Restart it to move it now.',
|
||||
'reviewer: Not running. Its next start uses the new route.',
|
||||
]);
|
||||
// Only the running agents offer the restart, and the thread is marked as one.
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('.routes-apply [data-agent-restart]')].map(e=>[e.dataset.agentRestart,e.dataset.agentThread,e.textContent])`),
|
||||
[['coder', 'no', 'Restart coder now'], ['assistant', 'yes', 'Restart assistant now']]);
|
||||
|
||||
// Restart now on a session: terminate with the reason, wait for it to end, then one launch.
|
||||
await b.evaluate(`document.querySelector('[data-agent-restart="coder"]').click();true`);
|
||||
await wait(`document.querySelector('[data-agent-restart-status="coder"]')?.textContent==='Restarted coder on the new route (run run-2).'`, 30000);
|
||||
assert.deepEqual(asked.slice(1).filter(([v]) => v !== 'instance.request'), [['instance.end', { instance: 'coder', reason: 'route change' }], ['instance.launch', { instance: 'coder' }]]);
|
||||
assert.equal(asked.filter(([v, a]) => v === 'instance.launch' && a.instance === 'coder').length, 1, 'one launch, after the session ended');
|
||||
// A thread is terminated, not launched: its next message starts it on the new route.
|
||||
await b.evaluate(`document.querySelector('[data-agent-restart="assistant"]').click();true`);
|
||||
await wait(`/^Terminated assistant at .+\\. Its next message starts it on the new route\\.$/.test(document.querySelector('[data-agent-restart-status="assistant"]')?.textContent??'')`, 30000);
|
||||
assert.equal(asked.filter(([v, a]) => v === 'instance.launch' && a.instance === 'assistant').length, 0);
|
||||
|
||||
// The trail names the dev owner for the save and each lifecycle write.
|
||||
const trail = readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.match(at, /^\d{4}-\d\d-\d\dT/); return e; });
|
||||
assert.deepEqual(trail.map(e => [e.action, e.by?.provider, e.outcome?.split(' ')[0]]), [
|
||||
['routes.set', 'local-dev', 'done'], ['instance.end', 'local-dev', 'done'], ['instance.launch', 'local-dev', 'done'], ['instance.end', 'local-dev', 'done'],
|
||||
]);
|
||||
assert.deepEqual(trail[0].by, DEV);
|
||||
await b.viewport(390, 844);
|
||||
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=390`), true, 'no sideways scroll at phone width');
|
||||
});
|
||||
@@ -122,5 +122,16 @@ test('an unconfigured proxy is one empty state without forms or a false read-onl
|
||||
await b.navigate(f.base+'/#/settings/model-proxy');await wait('document.querySelector("#proxy-message")?.textContent.includes("not configured")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-message").classList.contains("empty")'),true);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".settings-readonly")'),false);
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll(".settings-model-proxy .panel:not(#proxy-dev-setup)")].every(e=>e.hidden)'),true);
|
||||
// F3: a dev Console adds one note naming the terminal setup. It holds no form and no input: the
|
||||
// management key is never typed into this page.
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-dev-setup code")?.textContent'),'mosaic proxy fetch\nmosaic proxy init');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#proxy-dev-setup form,#proxy-dev-setup input,#proxy-dev-setup textarea").length'),0);
|
||||
});
|
||||
|
||||
test('an unconfigured proxy without the dev owner shows no setup note', {timeout:90000}, async t => {
|
||||
const {f,b,wait}=await w15Fixture(t,{scenario:{'/api/config':{send:true},'GET /api/proxy/accounts':{status:503,body:{error:'not-configured'}},'GET /api/proxy/keys':{status:503,body:{error:'not-configured'}}}});
|
||||
await b.navigate(f.base+'/#/settings/model-proxy');await wait('document.querySelector("#proxy-message")?.textContent.includes("not configured")');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#proxy-dev-setup")'),false);
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll(".settings-model-proxy .panel")].every(e=>e.hidden)'),true);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { createServer } from 'node:net';
|
||||
import * as orgBus from '../../cli/src/org-bus.mjs';
|
||||
import { busCaller } from '../src/bus.mjs';
|
||||
import { consoleBus } from '../src/console-bus.mjs';
|
||||
import { organizationConsole } from '../src/organizations.mjs';
|
||||
import { readWorkspace, workspaceHTTP, parseWorkspaceQuery } from '../src/workspace-read.mjs';
|
||||
|
||||
const session = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
|
||||
const goodOptions = overrides => ({
|
||||
bus: { tasks: async () => [], agents: async () => [], inbox: async () => [], 'thread.claims': async () => [], usage: async () => ({}), limits: async () => ({}), decisions: async () => [], ...overrides?.bus },
|
||||
chat: { projects: async () => [{ id: 'p', name: 'Healthy' }], threads: async () => [], ...overrides?.chat },
|
||||
});
|
||||
const member = (id, overrides) => ({ id, doc: {}, status: async () => ({ healthy: true }), options: () => goodOptions(overrides) });
|
||||
const orgs = (...members) => ({ members: async () => members });
|
||||
const response = () => ({ writeHead(status) { this.status = status; }, end(body) { this.body = JSON.parse(body); } });
|
||||
|
||||
for (const [name, overrides] of [
|
||||
['null thread', { chat: { threads: async () => [null] } }],
|
||||
['null inbox option', { bus: { inbox: async () => [{ id: 'd', options: [null] }] } }],
|
||||
['invalid project name', { chat: { projects: async () => [{ id: 'bad', name: 42 }] } }],
|
||||
['non-array tasks', { bus: { tasks: async () => ({ tasks: [] }) } }],
|
||||
['invalid usage total', { bus: { usage: async () => ({ total: 42 }) } }],
|
||||
]) {
|
||||
test(`malformed ${name} is isolated from a healthy organization`, async () => {
|
||||
const res = response();
|
||||
await workspaceHTTP({ method: 'GET' }, res, { organizations: orgs(member('good'), member('bad', overrides)), session, owner: true });
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body.projects.map(p => p.org), ['good']);
|
||||
assert.deepEqual(res.body.summary, { read: 1, of: 2 });
|
||||
const bad = res.body.orgs.find(o => o.org === 'bad');
|
||||
assert.equal(bad.state, 'invalid');
|
||||
assert.equal(bad.error, 'invalid-response');
|
||||
assert.ok(Number.isFinite(Date.parse(bad.at)));
|
||||
});
|
||||
}
|
||||
|
||||
test('options construction failure is isolated from a healthy organization', async () => {
|
||||
const bad = { ...member('bad'), options() { throw new Error('private detail'); } };
|
||||
const res = response();
|
||||
await workspaceHTTP({ method: 'GET' }, res, { organizations: orgs(member('good'), bad), session, owner: true });
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.body.projects.map(p => p.org), ['good']);
|
||||
assert.deepEqual(res.body.summary, { read: 1, of: 2 });
|
||||
assert.equal(res.body.orgs[1].state, 'invalid');
|
||||
assert.equal(res.body.orgs[1].error, 'invalid-response');
|
||||
assert.ok(Number.isFinite(Date.parse(res.body.orgs[1].at)));
|
||||
});
|
||||
|
||||
for (const [name, statusDelay, pluginDelay] of [['in-flight child', 0, 100], ['late channel continuation', 220, 300]]) {
|
||||
test(`deadline cancels ${name} and prevents any later child`, async t => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'ws-'));
|
||||
t.after(() => rm(dir, { recursive: true, force: true }));
|
||||
const cli = join(dir, 'cli.mjs'), log = join(dir, 'log');
|
||||
await writeFile(cli, `import {appendFileSync} from 'node:fs';let input='';process.stdin.on('data',x=>input+=x);process.stdin.on('end',()=>{const q=JSON.parse(input);appendFileSync(${JSON.stringify(log)},JSON.stringify({pid:process.pid,verb:q.verb})+'\\n');if(q.verb==='plugins')setTimeout(()=>process.stdout.write(JSON.stringify({plugins:{channel:['webui']}})),${pluginDelay});else if(q.verb==='project.list')setInterval(()=>{},1000);else process.stdout.write('[]');});`);
|
||||
const timeoutMs = 500;
|
||||
const organizations = organizationConsole({ ctx: {}, listOrgs: async () => ['good', 'slow'].map(id => ({ id, state: 'active' })),
|
||||
showOrg: async () => ({ doc: {} }), resolveOrg: async (_, id) => ({ id, state: 'active', root: dir }), orgPaths: () => ({ socket: join(dir, 'unused.sock') }),
|
||||
busStatus: async (_, id) => { if (id === 'slow') await delay(statusDelay); return { healthy: true }; },
|
||||
options: ({ id, ...bound }) => id === 'good' ? goodOptions() : consoleBus(busCaller({ dataRoot: dir, socket: bound.paths.socket, business: id, cli, timeoutMs, ...bound })) });
|
||||
const body = await readWorkspace({ organizations, session, timeoutMs, query: parseWorkspaceQuery('sections=tasks') });
|
||||
assert.equal(body.orgs.find(o => o.org === 'slow').state, 'timeout');
|
||||
assert.deepEqual(body.summary, { read: 1, of: 2 });
|
||||
const rows = async () => (await readFile(log, 'utf8')).trim().split('\n').map(JSON.parse);
|
||||
const atReturn = await rows();
|
||||
if (!statusDelay) {
|
||||
await delay(30);
|
||||
for (const { pid } of atReturn) assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }, `transport ${pid} still alive`);
|
||||
}
|
||||
await delay(350);
|
||||
const later = await rows();
|
||||
assert.deepEqual(later, atReturn, 'no transport starts after timeout');
|
||||
assert.ok(atReturn.some(r => r.verb === (statusDelay ? 'plugins' : 'project.list')));
|
||||
for (const { pid } of later) assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }, `transport ${pid} still alive`);
|
||||
});
|
||||
}
|
||||
|
||||
// The real status probe must release its socket as soon as its org deadline expires.
|
||||
test('status probe closes its in-flight socket on cancellation', async t => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'sock-')), path = join(dir, 's');
|
||||
const server = createServer(socket => { socket.on('data', () => {}); });
|
||||
await new Promise(resolve => server.listen(path, resolve));
|
||||
t.after(async () => { await new Promise(resolve => server.close(resolve)); await rm(dir, { recursive: true, force: true }); });
|
||||
const controller = new AbortController();
|
||||
assert.equal(typeof orgBus.probeBusSocket, 'function');
|
||||
const connected = new Promise(resolve => server.once('connection', socket => socket.once('close', resolve)));
|
||||
const result = orgBus.probeBusSocket(path, { signal: controller.signal, deadline: Date.now() + 100 });
|
||||
await delay(30);
|
||||
controller.abort();
|
||||
assert.equal(await result, false);
|
||||
await Promise.race([connected, delay(250).then(() => { throw new Error('status socket still open'); })]);
|
||||
});
|
||||
|
||||
test('workspace deadline reaches the organization status socket', async t => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'sock-')), path = join(dir, 's');
|
||||
let closed;
|
||||
const socketClosed = new Promise(resolve => { closed = resolve; });
|
||||
const server = createServer(socket => { socket.on('data', () => {}); socket.once('close', closed); });
|
||||
await new Promise(resolve => server.listen(path, resolve));
|
||||
t.after(async () => { await new Promise(resolve => server.close(resolve)); await rm(dir, { recursive: true, force: true }); });
|
||||
const organizations = organizationConsole({ ctx: {}, listOrgs: async () => ['good', 'slow'].map(id => ({ id, state: 'active' })),
|
||||
showOrg: async () => ({ doc: {} }), resolveOrg: async (_, id) => ({ id, state: 'active', root: dir }), orgPaths: () => ({ socket: path }),
|
||||
busStatus: async (_, id, bound) => id === 'good' ? { healthy: true } : { healthy: await orgBus.probeBusSocket(path, bound) },
|
||||
options: () => goodOptions() });
|
||||
const body = await readWorkspace({ organizations, session, timeoutMs: 150 });
|
||||
assert.equal(body.orgs.find(o => o.org === 'slow').state, 'timeout');
|
||||
assert.deepEqual(body.summary, { read: 1, of: 2 });
|
||||
await Promise.race([socketClosed, delay(250).then(() => { throw new Error('status socket still open'); })]);
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
// U1 (#1873, PRD 1.20 REQ-WS-1..8): the cross-organization read behind GET /api/workspace.
|
||||
// Merge, membership, partial failure, timeout, sort and filter, the copied decide command, the
|
||||
// route through startServer, and the assistant-read stop. Every bus here is a fake options
|
||||
// object or a real org registry in this test's own scratch; no test reaches a live bus.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createOrg, showOrg, templateRender } from '../../organization/src/org-service.mjs';
|
||||
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { organizationConsole } from '../src/organizations.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { parseWorkspaceQuery, readWorkspace, workspaceHTTP, WorkspaceQueryError, WORKSPACE_POLL_MS } from '../src/workspace-read.mjs';
|
||||
import * as broker from '../../bus/src/broker.mjs';
|
||||
|
||||
const principal = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
|
||||
const NOW = Date.parse('2026-11-09T13:00:00.000Z');
|
||||
const now = () => NOW;
|
||||
const never = () => new Promise(() => {});
|
||||
|
||||
// One org's bus as the org pages see it: options().bus and options().chat. `over` replaces reads.
|
||||
function options(data, over = {}) {
|
||||
const value = name => (over[name] ? over[name]() : Promise.resolve(data[name] ?? (['usage', 'limits'].includes(name) ? {} : [])));
|
||||
return {
|
||||
bus: { inbox: () => value('inbox'), agents: () => value('agents'), 'thread.claims': () => value('claims'), tasks: () => value('tasks'),
|
||||
usage: () => value('usage'), limits: () => value('limits'), decisions: () => value('decisions') },
|
||||
chat: { threads: () => value('threads'), projects: () => value('projects') },
|
||||
};
|
||||
}
|
||||
const healthy = async () => ({ healthy: true, pid: 4242, boot: null });
|
||||
function member(id, data, { status = healthy, over = {}, doc = {} } = {}) {
|
||||
return { id, doc: { appearance: { theme: `${id}-theme` }, ...doc }, status, options: () => options(data, over) };
|
||||
}
|
||||
const organizationsOf = (...members) => ({ members: async () => members, list: async () => [], resolve: async () => null });
|
||||
|
||||
const ACME = {
|
||||
projects: [
|
||||
{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: '2026-11-09T10:00:00.000Z' },
|
||||
{ id: 'p-old', repo: 'old', name: 'Old brochure', archived: true, at: '2026-10-01T00:00:00.000Z' },
|
||||
],
|
||||
tasks: [
|
||||
{ task_ref: 'vk:1', project_key: 'site', priority: 2, at: '2026-11-09T11:00:00.000Z', fields: { title: 'Hero copy', done: false, assignees: [7, 99], bucket: 'Doing', labels: ['web'] } },
|
||||
{ task_ref: 'vk:2', project_key: 'site', at: '2026-11-08T00:00:00.000Z', fields: { title: 'Logo', done: true } },
|
||||
{ task_ref: 'vk:3', project_key: 'site', at: '2026-11-08T00:00:00.000Z', fields: { title: 'Removed', gone: true } },
|
||||
],
|
||||
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'waiting', at: '2026-11-09T09:00:00.000Z', inputNeeded: [{ task_ref: 'vk:1', at: '2026-11-09T12:00:00.000Z' }], stranded: [] }],
|
||||
claims: [{ thread: 't-1', instance: 'assistant', holder_run: 'run-2', harness: 'claude', at: '2026-11-09T08:00:00.000Z' }],
|
||||
threads: [{ id: 't-1', project: 'p-site' }],
|
||||
inbox: [{ id: 'd1234567', question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', task_ref: 'vk:1', at: '2026-11-09T12:00:00.000Z', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }] }],
|
||||
usage: { total: { tokens: 1200 } },
|
||||
limits: { tokens: [{ scope: 'org', used: 1200, cap: 5000 }] },
|
||||
decisions: [{ at: '2026-11-09T00:00:00.000Z', closed: { op: 'resolved', at: '2026-11-09T00:10:00.000Z' } }],
|
||||
};
|
||||
const ACME_DOC = { tracker: { kind: 'vikunja' }, roles: { coder: { tracker: { botId: 7 } }, assistant: {} } };
|
||||
const BETA = {
|
||||
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: '2026-11-07T00:00:00.000Z' }],
|
||||
tasks: [{ task_ref: 'local:1', project_key: 'shop', at: '2026-11-07T00:00:00.000Z', fields: { title: 'Price list', done: false } }],
|
||||
};
|
||||
|
||||
test('merge: every item from two buses carries its org, and the snapshot names both orgs', async () => {
|
||||
const body = await readWorkspace({ organizations: organizationsOf(member('acme', ACME, { doc: ACME_DOC }), member('beta', BETA)), session: principal, now });
|
||||
assert.equal(body.pollMs, WORKSPACE_POLL_MS);
|
||||
assert.deepEqual(body.summary, { read: 2, of: 2 });
|
||||
assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker, o.theme]), [['acme', 'ok', 'vikunja', 'acme-theme'], ['beta', 'ok', 'built-in', 'beta-theme']]);
|
||||
assert.deepEqual(body.orgs[0].roles, ['assistant', 'coder']);
|
||||
for (const key of ['projects', 'sessions', 'inbox', 'tasks']) for (const row of body[key]) assert.ok(['acme', 'beta'].includes(row.org), `${key} row without org`);
|
||||
assert.deepEqual(body.metrics.orgs.map(m => m.org), ['acme', 'beta']);
|
||||
// Archived projects are hidden by default; the default sort is newest activity first.
|
||||
assert.deepEqual(body.projects.map(p => `${p.org}/${p.key}`), ['acme/site', 'beta/shop']);
|
||||
const site = body.projects[0];
|
||||
assert.deepEqual({ ...site }, { org: 'acme', id: 'p-site', key: 'site', name: 'Website', status: 'active', state: 'working', git: null,
|
||||
sessions: { working: 1, waiting: 1, idle: 0 }, openTasks: 1, needsYou: 1, lastActivity: '2026-11-09T12:00:00.000Z' });
|
||||
const role = body.sessions.find(s => s.kind === 'role'), thread = body.sessions.find(s => s.kind === 'thread');
|
||||
assert.equal(role.org, 'acme'); assert.equal(role.role, 'coder'); assert.equal(role.state, 'waiting'); assert.equal(role.inputNeeded, 1);
|
||||
assert.deepEqual(role.task, { ref: 'vk:1', title: 'Hero copy' }); assert.equal(role.project.key, 'site'); assert.equal(role.projectFrom, 'task');
|
||||
assert.equal(thread.thread, 't-1'); assert.equal(thread.project.key, 'site'); assert.equal(thread.projectFrom, 'thread');
|
||||
const ask = body.inbox[0];
|
||||
assert.equal(ask.org, 'acme'); assert.equal(ask.raisedBy, 'coder'); assert.deepEqual(ask.session, { role: 'coder', run: 'run-1' }); assert.equal(ask.project.key, 'site');
|
||||
// The gone task is dropped; only bot ids from the org file become role names.
|
||||
assert.deepEqual(body.tasks.map(t => `${t.org}/${t.ref}`), ['acme/vk:1', 'acme/vk:2', 'beta/local:1']);
|
||||
assert.deepEqual(body.tasks[0].assignees, ['coder']); assert.equal(body.tasks[0].bucket, 'Doing'); assert.equal(body.tasks[2].tracker, 'built-in');
|
||||
assert.deepEqual(body.metrics.orgs[0], { org: 'acme', needsYou: 1, oldestNeedsYouAt: '2026-11-09T12:00:00.000Z', sessions: { working: 0, waiting: 1, idle: 0, live: 1 },
|
||||
openTasks: 1, doneThisWeek: 1, projects: { active: 1, archived: 1 }, usage: { tokens: 1200 }, limits: [{ scope: 'org', used: 1200, cap: 5000 }], decisionWaitMedianMs: 600000 });
|
||||
assert.deepEqual(body.metrics.totals, { needsYou: 1, oldestNeedsYouAt: '2026-11-09T12:00:00.000Z', sessions: { working: 0, waiting: 1, idle: 0, live: 1 }, openTasks: 2, doneThisWeek: 1, projects: { active: 2, archived: 1 } });
|
||||
});
|
||||
|
||||
test('sections: only the asked sections are read and returned', async () => {
|
||||
const asked = [];
|
||||
const tracked = member('acme', ACME, { over: Object.fromEntries(['inbox', 'agents', 'claims', 'threads', 'tasks', 'projects', 'usage', 'limits', 'decisions'].map(n => [n, () => { asked.push(n); return Promise.resolve(ACME[n] ?? (['usage', 'limits'].includes(n) ? {} : [])); }])) });
|
||||
const body = await readWorkspace({ organizations: organizationsOf(tracked), session: principal, query: parseWorkspaceQuery('sections=tasks'), now });
|
||||
assert.deepEqual(Object.keys(body).filter(k => ['projects', 'sessions', 'inbox', 'tasks', 'metrics'].includes(k)), ['tasks']);
|
||||
assert.deepEqual(asked.sort(), ['inbox', 'projects', 'tasks']);
|
||||
});
|
||||
|
||||
test('membership: a foreign org is never read or named; a broken own org shows as invalid; options get the timeout', async t => {
|
||||
const root = await fs.mkdtemp(join(tmpdir(), 'wsr-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test', principal };
|
||||
await fs.mkdir(ctx.configDir, { mode: 0o700 }); await fs.mkdir(ctx.dataRoot, { mode: 0o700 });
|
||||
for (const id of ['alpha', 'beta', 'private-tenant']) await createOrg(ctx, id, await templateRender(id));
|
||||
await fs.writeFile(join(ctx.dataRoot, 'org-runtime', 'private-tenant', 'owner.json'), JSON.stringify({ ...principal, sub: 'different-owner' }));
|
||||
const statusAsked = [], optionsAsked = [];
|
||||
const orgs = organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPaths: () => ({}),
|
||||
busStatus: async (_ctx, id) => { statusAsked.push(id); return id === 'beta' ? { healthy: false, pid: null, boot: { ok: false, at: '2026-11-09T12:59:00.000Z', reason: `config missing at ${ctx.dataRoot}/org-runtime/beta/x.json` } } : { healthy: true, pid: 7 }; },
|
||||
options: o => { optionsAsked.push({ id: o.id, timeoutMs: o.timeoutMs }); return options(o.id === 'alpha' ? BETA : {}); } });
|
||||
const body = await readWorkspace({ organizations: orgs, session: principal, timeoutMs: 1500, now, ctx });
|
||||
assert.deepEqual(body.orgs.map(o => [o.org, o.state]), [['alpha', 'ok'], ['beta', 'down']]);
|
||||
assert.equal(JSON.stringify(body).includes('private-tenant'), false);
|
||||
assert.deepEqual(statusAsked.sort(), ['alpha', 'beta']);
|
||||
assert.deepEqual(optionsAsked, [{ id: 'alpha', timeoutMs: 1500 }]);
|
||||
// The boot refusal is shown with the data root made relative.
|
||||
assert.equal(body.orgs[1].reason, 'the bus refused to start: config missing at <data root>/org-runtime/beta/x.json');
|
||||
assert.equal(JSON.stringify(body).includes(root), false);
|
||||
// A different principal sees none of these orgs.
|
||||
const outsider = await readWorkspace({ organizations: orgs, session: { ...principal, sub: 'outsider' }, now, ctx });
|
||||
assert.deepEqual(outsider.orgs, []);
|
||||
// The owner of a broken org sees it as invalid, with no host path.
|
||||
await fs.unlink(join(ctx.configDir, 'businesses', 'alpha.json'));
|
||||
const broken = await readWorkspace({ organizations: orgs, session: principal, now, ctx });
|
||||
assert.equal(broken.orgs[0].org, 'alpha'); assert.equal(broken.orgs[0].state, 'invalid');
|
||||
assert.equal(broken.orgs[0].reason.includes(ctx.configDir), false);
|
||||
assert.deepEqual(broken.summary, { read: 0, of: 2 });
|
||||
});
|
||||
|
||||
test('partial failure: down, starting and a status that throws each get a state and a time; the others return', async () => {
|
||||
const ctx = { dataRoot: '/srv/fake-data', configDir: '/srv/fake-config' };
|
||||
const body = await readWorkspace({ now, ctx, session: principal, organizations: organizationsOf(
|
||||
member('acme', ACME, { doc: ACME_DOC }),
|
||||
member('down', BETA, { status: async () => ({ healthy: false, pid: null, boot: { ok: true, at: '2026-11-09T12:00:00.000Z' } }) }),
|
||||
member('starting', BETA, { status: async () => ({ healthy: false, pid: 991, boot: null }) }),
|
||||
member('stale', BETA, { status: async () => { throw new Error('host state names /srv/fake-data/org-runtime/stale/bus-host and /etc/secret-place'); } }),
|
||||
member('reads-fail', BETA, { over: { tasks: () => Promise.reject(Object.assign(new Error('nope'), { code: 'bus-unavailable' })), projects: () => Promise.reject(new Error('raw text /etc/x')) } }),
|
||||
) });
|
||||
const byOrg = Object.fromEntries(body.orgs.map(o => [o.org, o]));
|
||||
assert.equal(byOrg.acme.state, 'ok');
|
||||
assert.equal(byOrg.down.state, 'down'); assert.equal(byOrg.down.reason, 'the bus is not running');
|
||||
assert.equal(byOrg.starting.state, 'starting'); assert.equal(byOrg.starting.reason, 'the bus host is running but does not answer yet');
|
||||
assert.equal(byOrg.stale.state, 'down');
|
||||
assert.equal(byOrg.stale.reason, 'host state names <data root>/org-runtime/stale/bus-host and <host path>');
|
||||
// A read that fails inside an answering bus is that read's error code, never its message.
|
||||
assert.equal(byOrg['reads-fail'].state, 'ok');
|
||||
assert.deepEqual(byOrg['reads-fail'].errors, { tasks: 'bus-unavailable', projects: 'read-failed' });
|
||||
for (const o of body.orgs) { assert.equal(o.at, new Date(NOW).toISOString()); assert.equal(typeof o.readMs, 'number'); }
|
||||
assert.deepEqual(body.summary, { read: 2, of: 5 });
|
||||
assert.ok(body.projects.every(p => p.org === 'acme'));
|
||||
});
|
||||
|
||||
test('timeout: a bus that never answers, or whose state is never read, is marked timeout within the bound', async () => {
|
||||
const started = Date.now();
|
||||
const body = await readWorkspace({ timeoutMs: 150, session: principal, organizations: organizationsOf(
|
||||
member('acme', ACME, { doc: ACME_DOC }),
|
||||
member('slow', BETA, { over: { tasks: never } }),
|
||||
member('stuck', BETA, { status: never }),
|
||||
) });
|
||||
const elapsed = Date.now() - started;
|
||||
assert.ok(elapsed < 1500, `took ${elapsed} ms`);
|
||||
const byOrg = Object.fromEntries(body.orgs.map(o => [o.org, o]));
|
||||
assert.equal(byOrg.acme.state, 'ok');
|
||||
assert.equal(byOrg.slow.state, 'timeout'); assert.equal(byOrg.slow.reason, 'the bus did not answer within 150 ms');
|
||||
assert.equal(byOrg.stuck.state, 'timeout'); assert.equal(byOrg.stuck.reason, 'the bus state was not read within 150 ms');
|
||||
assert.equal(body.timeoutMs, 150);
|
||||
assert.ok(body.tasks.every(t => t.org === 'acme'));
|
||||
});
|
||||
|
||||
test('query: strict keys and values', () => {
|
||||
const q = parseWorkspaceQuery('?sections=projects,tasks&org=acme,beta&status=active,archived&state=working&name=Web&sort=name&project=acme:site');
|
||||
assert.deepEqual([...q.sections], ['projects', 'tasks']); assert.deepEqual([...q.orgs], ['acme', 'beta']);
|
||||
assert.deepEqual([...q.status], ['active', 'archived']); assert.deepEqual([...q.state], ['working']);
|
||||
assert.equal(q.name, 'web'); assert.equal(q.sort, 'name'); assert.equal(q.order, 'asc'); assert.deepEqual(q.project, { org: 'acme', key: 'site' });
|
||||
assert.equal(parseWorkspaceQuery('').order, 'desc');
|
||||
for (const bad of ['secton=projects', 'org=acme&org=beta', 'sections=', 'sections=chat', 'org=Bad Org', 'status=paused', 'state=busy', `name=${'x'.repeat(201)}`, 'name=%20', 'sort=size', 'order=up', 'project=acme', 'project=acme:a/b'])
|
||||
assert.throws(() => parseWorkspaceQuery(bad), WorkspaceQueryError, bad);
|
||||
});
|
||||
|
||||
test('sort and filter: projects by org, status, state, name and activity; tasks by org and project', async () => {
|
||||
const organizations = organizationsOf(member('acme', ACME, { doc: ACME_DOC }), member('beta', BETA));
|
||||
const read = search => readWorkspace({ organizations, session: principal, now, query: parseWorkspaceQuery(search) });
|
||||
const keys = rows => rows.map(p => `${p.org}/${p.key}`);
|
||||
assert.deepEqual(keys((await read('status=active,archived')).projects), ['acme/site', 'beta/shop', 'acme/old']);
|
||||
assert.deepEqual(keys((await read('status=active,archived&sort=activity&order=asc')).projects), ['acme/old', 'beta/shop', 'acme/site']);
|
||||
assert.deepEqual(keys((await read('status=active,archived&sort=name')).projects), ['acme/old', 'beta/shop', 'acme/site']);
|
||||
assert.deepEqual(keys((await read('status=active,archived&sort=org&order=desc')).projects), ['beta/shop', 'acme/site', 'acme/old']);
|
||||
assert.deepEqual(keys((await read('status=active,archived&sort=status')).projects), ['acme/site', 'beta/shop', 'acme/old']);
|
||||
assert.deepEqual(keys((await read('status=archived')).projects), ['acme/old']);
|
||||
assert.deepEqual(keys((await read('state=none')).projects), ['beta/shop']);
|
||||
assert.deepEqual(keys((await read('name=SHO')).projects), ['beta/shop']);
|
||||
const onlyBeta = await read('org=beta');
|
||||
assert.deepEqual(onlyBeta.orgs.map(o => o.org), ['beta']); assert.deepEqual(keys(onlyBeta.projects), ['beta/shop']);
|
||||
// An org filter naming an org the viewer is not in returns nothing, not an error.
|
||||
assert.deepEqual((await read('org=nobody')).orgs, []);
|
||||
assert.deepEqual((await read('project=acme:site')).tasks.map(t => t.ref), ['vk:1', 'vk:2']);
|
||||
assert.deepEqual((await read('project=acme:p-site')).tasks.map(t => t.ref), ['vk:1', 'vk:2']);
|
||||
assert.deepEqual((await read('project=beta:site')).tasks, []);
|
||||
});
|
||||
|
||||
test('decide: a copied command names the org; an unsafe id or key gets none', async () => {
|
||||
const data = { ...ACME, inbox: [...ACME.inbox, { id: 'd99 ; rm -rf ~', question: 'x', options: [{ key: 'ok', text: 'ok' }] }, { id: 'e7654321', question: 'y', options: [{ key: 'a b', text: 'a b' }] }] };
|
||||
const body = await readWorkspace({ organizations: organizationsOf(member('acme', data, { doc: ACME_DOC })), session: principal, now, query: parseWorkspaceQuery('sections=inbox') });
|
||||
const byId = Object.fromEntries(body.inbox.map(d => [d.id, d]));
|
||||
assert.deepEqual(byId.d1234567.decide, [{ key: 'yes', command: 'mosaic decide d1234567 yes --org acme' }, { key: 'no', command: 'mosaic decide d1234567 no --org acme' }]);
|
||||
assert.deepEqual(byId['d99 ; rm -rf ~'].decide, [{ key: 'ok', command: null }]);
|
||||
assert.deepEqual(byId.e7654321.decide, [{ key: 'a b', command: null }]);
|
||||
// Gated first, then oldest first.
|
||||
assert.equal(body.inbox[0].id, 'd1234567');
|
||||
});
|
||||
|
||||
function fakeRes() {
|
||||
return { status: null, body: null, writeHead(status) { this.status = status; }, end(text) { this.body = JSON.parse(text); } };
|
||||
}
|
||||
test('handler: non-GET, no registry, non-owner, bad query and an unreadable registry', async () => {
|
||||
const organizations = organizationsOf(member('acme', ACME));
|
||||
const run = async (opts, method = 'GET') => { const res = fakeRes(); await workspaceHTTP({ method }, res, { session: principal, owner: true, organizations, ...opts }); return res; };
|
||||
assert.equal((await run({}, 'POST')).status, 405);
|
||||
assert.deepEqual((await run({ organizations: null })).body, { error: 'org-unavailable' });
|
||||
// A granted user or the tester reaches the hook with owner false and no owner session.
|
||||
for (const who of [{ owner: false }, { session: null }]) {
|
||||
const res = await run(who);
|
||||
assert.equal(res.status, 403); assert.deepEqual(res.body, { error: 'owner-only', message: 'the cross-organization view is the owner\'s' });
|
||||
}
|
||||
const bad = await run({ search: '?org=acme&org=acme' });
|
||||
assert.equal(bad.status, 400); assert.equal(bad.body.error, 'invalid-request');
|
||||
const logged = [];
|
||||
const down = await run({ organizations: { members: async () => { throw new Error('/srv/x/org-runtime.json: Unexpected token'); } }, log: line => logged.push(line) });
|
||||
assert.equal(down.status, 503); assert.deepEqual(down.body, { error: 'org-unavailable', message: 'Organization registry could not be read' });
|
||||
assert.deepEqual(logged, ['workspace: organization registry could not be read']);
|
||||
});
|
||||
|
||||
async function serve(t, organizations, sso = createDevOwner()) {
|
||||
const s = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, organizations, log: () => {}, workspaceTimeoutMs: 200 });
|
||||
t.after(() => new Promise(r => { s.close(r); s.closeIdleConnections(); }));
|
||||
return `http://127.0.0.1:${s.address().port}`;
|
||||
}
|
||||
test('route: the dev owner reads GET /api/workspace; a granted user gets owner-only; the timeout option applies', async t => {
|
||||
const seen = [];
|
||||
const organizations = { list: async () => [], resolve: async () => null, members: async (session, opts) => { seen.push({ sub: session.sub, timeoutMs: opts.timeoutMs }); return [member('acme', ACME, { doc: ACME_DOC }), member('slow', BETA, { status: never })]; } };
|
||||
const base = await serve(t, organizations);
|
||||
const response = await fetch(`${base}/api/workspace?sections=projects,metrics`), body = await response.json();
|
||||
assert.equal(response.status, 200); assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
assert.deepEqual(body.orgs.map(o => [o.org, o.state]), [['acme', 'ok'], ['slow', 'timeout']]);
|
||||
assert.equal(body.timeoutMs, 200); assert.deepEqual(seen, [{ sub: 'local-dev-owner', timeoutMs: 200 }]);
|
||||
assert.equal((await fetch(`${base}/api/workspace?bogus=1`)).status, 400);
|
||||
// POST has no route here: with no Origin the dev fence refuses it, and with the page's Origin the
|
||||
// organizations fence answers as for any unknown /api path.
|
||||
assert.equal((await fetch(`${base}/api/workspace`, { method: 'POST' })).status, 403);
|
||||
const post = await fetch(`${base}/api/workspace`, { method: 'POST', headers: { origin: base, 'content-type': 'application/json' }, body: '{}' });
|
||||
assert.equal(post.status, 404); assert.deepEqual(await post.json(), { error: 'org-unavailable' });
|
||||
// A granted user's session (session.user set) is fenced before any org is read.
|
||||
const granted = { ...createDevOwner(), session: () => ({ ...principal, sub: 'alice-sub', user: 'alice', name: 'Alice' }) };
|
||||
const userBase = await serve(t, organizations, granted);
|
||||
const refused = await fetch(`${userBase}/api/workspace`);
|
||||
assert.equal(refused.status, 403); assert.equal((await refused.json()).error, 'owner-only');
|
||||
assert.equal(seen.length, 1);
|
||||
});
|
||||
|
||||
test('assistant read is designed, not built: no workspace broker verb, assistant authority unchanged', () => {
|
||||
const role = JSON.parse(readFileSync(new URL('../../../roles/assistant.json', import.meta.url), 'utf8'));
|
||||
assert.deepEqual(role.authority, { withinRole: ['message.send', 'record.write'], crossRole: [] });
|
||||
const verbs = new Set(Object.values(broker).filter(Array.isArray).flat().filter(v => typeof v === 'string'));
|
||||
assert.ok(verbs.has('inbox'), 'the verb lists were read');
|
||||
assert.deepEqual([...verbs].filter(v => /^(workspace|orgs?)\b/.test(v)), []);
|
||||
});
|
||||
@@ -421,7 +421,7 @@ plugin_checks() {
|
||||
# Row 181: the tables a bus writer fills without an event in the same transaction, in schema
|
||||
# order. A restore drops their rows after STAMP as it drops events, so the guard counts them too.
|
||||
# BUILD.md for row 181 names each writer; the T46 guard classifies every table in the schema.
|
||||
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects project_archives threads sessions session_defaults thread_opens thread_shares instance_names usage_records session_ops message_cancels request_events request_effects request_snapshots request_snapshot_events request_project_events"
|
||||
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects project_archives threads sessions session_defaults thread_opens thread_shares instance_names usage_records session_ops message_cancels request_events request_effects request_snapshots request_snapshot_events request_project_events project_system_types"
|
||||
|
||||
STORE_JS='
|
||||
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");
|
||||
|
||||
Reference in new issue
Block a user