Compare commits

...
Author SHA1 Message Date
jason.woltje 2f14a6180e test(organization): pin route refusal variable names (#1873) 2026-11-10 15:20:09 -06:00
jason.woltje b46b301dab fix(organization): keep var values out of refusals (#1873) 2026-11-10 15:00:20 -06:00
jason.woltje 50be333552 fix(organization): address accounts review and rollback edge cases (#1873) 2026-11-10 20:43:00 +00:00
jason.woltje 3bb0f70acc feat(organization): add locked instance accounts store and CLI (#1873) 2026-11-10 20:43:00 +00:00
jason.woltje 52a82a4e56 test(webui): U5 Agents panes keep their own project choice (#1873)
The rebase onto U3 brought in A1's "Available in a project" chooser. Its
change handler kept one renderer and wrote into the first table in the
document, keyed by the page's organization. The rebase resolution keys
the renderer and the choice by each grid's organization and redraws the
table in the chooser's own section. This test covers it: before that
change, pane b's choice never redrew pane b.
2026-11-10 14:07:05 -06:00
jason.woltje 5f3706e807 fix(webui): U5 pane view state survives a project repaint (#1873)
A pane keeps its request choice and PRD disclosure on the pane, per
organization, instead of on the project content element. A new
conversation changes the project header, which replaces that element;
the choice now survives it.
2026-11-10 14:07:05 -06:00
jason.woltje 5f57008aed fix(webui): U5 review fixes for side by side panes (#1873)
One observer per pane element, disconnected on leaving the page, so a
closed and reopened pane rewrites links for its current organization.
Project reads check the pane generation and organization before any
DOM write. Request selection and expanded PRDs are kept per pane.
A late navigation focuses its heading only when focus has not moved to
the other pane. Project pages in a pane use a compact layout with
container queries.
2026-11-10 14:07:05 -06:00
jason.woltje bc3e0a9f49 feat(webui): U5 side by side, two panes across organizations and projects (#1873)
The Side by side page now opens two panes. Each pane shows an organization
dashboard, an agents grid or a project page, chosen from a picker that lists
every organization and project. Pane state lives in the URL and is kept per
viewer. A separator resizes the panes by pointer or arrow keys, closing a
pane goes back to one, and each pane keeps its own focus and scroll through
polling. A pane whose organization's bus is down says so while the other
pane keeps working. On a phone a two-way switch shows one pane at a time.

Panes are read-only. Conversation, room, settings, queue, docs and inbox
pages open in full view; chat.js is a single-instance page with global ids.
2026-11-10 14:07:05 -06:00
jason.woltje a71da41146 fix(webui): U4b re-check fixes, conflict recovery and partial empty state (#1873)
A conflict on a task another writer moved out of the board's server
filter now reads that task from its organization unfiltered, so the
dialog reopens on the current task. An empty result names the
organizations whose tasks were not read instead of saying there are
no tasks.
2026-11-10 20:04:49 +00:00
jason.woltje 4c74e7ea49 fix(webui): U4b review fixes for the task board (#1873)
- Edit and archive require the digest the board read (expect); the route
  refuses a write without it.
- The edit dialog saves against the task it showed, so a poll that reads a
  newer task turns a stale save into task-conflict; the dialog reopens on the
  current task.
- An answer the board cannot read, or one marked sent=unknown, is shown as an
  open outcome, not as a refusal.
- The per-org cap applies after the filters and keeps the most recently
  changed tasks; projects, metrics and the inbox count every task. The board
  sends project, state, assignee and label filters to the server.
- A poll keeps the focus, caret and scroll the person has at the moment it
  answers, not when it started.
- An org whose tasks read fails shows "tasks not read" and is not writable.
- browser.test.mjs pins tasks-board.js in the script order.
2026-11-10 20:04:49 +00:00
jason.woltje ac3a863bec feat(webui): U4b one task board across organizations (#1873)
Replaces the Tasks placeholder with sample 06: every organization's
tasks read through its own bus and tagged with the org, Kanban and List
with the layout remembered per viewer, and org, project, state,
assignee and label filters. A down org keeps its U1 state.

Writes (create, move, edit, archive) go to POST /api/workspace/tasks,
behind the hook fence, owner only and --send only. Each write resolves
only the org it names and calls that org's bus with the person's store
verbs, so the bus's human check, tracker check and digest check all
apply. Refusals reach the person with what was and was not written.

A Vikunja organization's tasks are read through the tasks adapter and
shown read only; the store verbs refuse them with tracker-not-store.
2026-11-10 20:04:49 +00:00
jason.woltje af7d330101 docs: clarify host trust boundary 2026-11-10 19:57:49 +00:00
jason.woltje db75994f82 docs: define accounts host trust boundary 2026-11-10 19:57:49 +00:00
jason.woltje ac977d833b test(webui): retry the projects grouping click while the nav repaints (#1873)
The workspace nav repaints after async organization and project reads.
Under load (gate at load 14) a repaint landed between the flat-grouping
wait and the org click, and the click found no button. The click now
retries inside the wait until the page shows org grouping.
2026-11-10 13:46:18 -06:00
jason.woltje aa087e7380 fix(webui): U3 review fixes, figures from failed reads say not read (#1873)
A project row's sessions, open tasks and needs-you figures come from
reads beyond the project list; when those failed in the row's
organization the cells now say not read instead of none or 0, and a
note names the organization. Dashboard per-organization figures and
Done this week count only organizations that made the tile's reads, a
dashboard with no answering organization shows no totals, and empty
lists say not read when no organization could make the read. Sessions
now names each session's organization for screen readers. The
org-archive test waits for the setup steps, which render after the
editor title.
2026-11-10 13:35:49 -06:00
jason.woltje 68fd66903b feat(webui): U3 dashboard, projects and inbox across organizations (#1873)
In organization mode #/ is the cross-organization dashboard (sample 02),
#/projects lists every organization's projects with sort, filters and the
shared By org / Flat grouping (sample 04), and #/inbox lists decisions
across organizations with per-organization decide paths and terminal
commands carrying --org and the full id (sample 05). The per-organization
board moves to #/board and the organization's own project list to #/chat,
which keeps the project surface. Reads go through workspace-data.js; a
read an organization could not make says not read, never zero. Polling
repaints in place and keeps focus, caret and scroll.
2026-11-10 13:35:49 -06:00
jason.woltje a7b2199390 test(bus): pin person handlers to their vocabulary (#1873) 2026-11-10 13:32:55 -06:00
jason.woltje 4c495647cd feat(organization): define org rights and role bundles (#1873) 2026-11-10 13:32:55 -06:00
jason.woltje f5cc712a59 feat(bus): export and pin person task verbs (#1873) 2026-11-10 13:32:55 -06:00
jason.woltje 821fbe8e9b test(webui): wait for the bus run button after open (#1873)
open() waits only for the editor title; the setup step enables the bus run
button after its async /setup read. Under load the assert ran first and
failed (A1 final gate, 2 of 4 runs). Wait for the enabled button instead.
2026-11-10 13:30:30 -06:00
jason.woltje d247b4eb61 fix(agents): A1 review fixes for scope, opt-outs and refusal text (#1873)
Two independent reviews (Sol, daybreak-blue) asked for changes.

- An organization's own assistant instance is scoped to that
  organization. Only the root assistant, which no union holds, is
  cross-organization.
- A root opt-out or project exclude whose name a more specific level
  defines is no longer listed as unavailable.
- A malformed root agents.json refuses with "not valid JSON" only; the
  parser's text, which can quote the file's bytes, is dropped.
- A refused root or project var names its key, never its value; an id
  check's quote of a root value is dropped too. A value can be a secret
  typed in the wrong place, and these refusals reach the Console.
- The root assistant's row shows the model the root file sets.

Tests cover each case, with a sentinel value checked absent from the
route body. The design doc records the behavior.
2026-11-10 13:29:11 -06:00
jason.woltje e358886826 feat(agents): A1 agent levels, a read-only union and the Agents grid (#1873)
Phase A slice A1, no authority change. Agents are defined at three levels:
the user's root (<configDir>/agents.json), the organization (roles) and the
project (projects.<p>.agents in the organization file). A project's agents
are their union, the most specific level winning.

- organization: agent-levels.mjs loads and checks the root file (no link,
  owner uid, no group or other write) and project entries (define, narrow
  intersect keys only, exclude), and resolves the union. The organization
  file checks project agents for every reader and leaves them out of the
  loaded business, so buses see what they saw before. Old-path re-export
  in packages/business.
- webui: GET /api/agents/levels, owner only, with no host path, credential
  reference or var other than the model in its body. display.js leaves it
  unscoped. SERVER_PROJECT_KEYS holds agents.
- Agents grid: the level band, a project picker, and per agent its level,
  the file that defines it, memory, status and model, with narrowed keys,
  overrides and what the project does not get.
- Design: docs/plans/2026-11-10_agent-levels-design.md. The roles/ change is
  a proposal outside this branch.
2026-11-10 13:29:11 -06:00
jason.woltje 01b79e1db4 docs(plans): accounts design draft 3, threat row count (#1873) 2026-11-10 13:03:07 -06:00
jason.woltje 0a75cc82f3 docs(plans): accounts design draft 3, luna review fixes (#1873)
Member task writes use the person verbs tasks.* instead of the agent task
path. A member's console cap never carries the org's human, and only the
decide handlers and request reads gain a member branch. VERB_RIGHTS pins
every BOARD_VERBS entry. request.write is reserved, decide excludes
credential mints, and the lead-confirm list becomes decided choices.
2026-11-10 13:02:37 -06:00
jason.woltje c66cd58846 docs(plans): accounts design draft 3, rights-based membership (#1873)
Applies Jason's three rulings of 2026-11-10. Named rights, role bundles
and per-member grants replace the fixed owner and member split. A member
holds org.view, thread.read and board.read by default. decide and
task.write are grantable. The rights table lands first as slice M1.
2026-11-10 12:46:18 -06:00
58 changed files with 6514 additions and 396 deletions

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,361 @@
# Agent levels and the cross-organization assistant (Phase A design, #1873)
Status: design for lane org-a1, 2026-11-10. Slice A1 (read-only store, union
resolution, Agents grid) is built on branch `org-a1`. Everything that gives an
agent a new capability is in the roles proposal and the later slices listed in
section 9; none of it is applied.
Direction: PRD 1.20 draft 2 (REQ-ASSIST-1, REQ-USER-1 amendment, REQ-AGENT-1
to REQ-AGENT-3, REQ-COS-3, REQ-DEC-2, REQ-ROLE-3), owner direction rulings 4
and 11, the U0 approval and the addendum in
`docs/plans/2026-11-09_unified-workspace-owner-direction.md`, and the role model
in `roles/` and `docs/plans/2026-09-02_atomic-mosaic-foundation.md`.
Owner words: "Agents are configured at three levels: the user's root, the
organization and the project. A project's agents are the union of all three.
The user's main interaction agent (the assistant) is the one exception to
isolation. [...] Every other agent is isolated to the organization it works
in." And: "the COS should be able to hire and fire agents to fulfill the
user's needs."
## 1. Terms
- **Definition**: a named agent entry at one level. It names a role
definition in `roles/` (`definition`) and may carry vars. It never names a
credential, a holder, a tracker bot, an identity or a publish grant at the
root or project level.
- **Role definition**: `roles/<name>.json`, versioned and changed only by
reviewed commits (invariant 7). A level chooses among role definitions and
never creates one, so no level can grant an authority that `roles/` does
not already hold.
- **Copy**: a root definition as it runs inside one organization. Each
organization runs its own copy, and each copy has that organization's
memory, files and credentials.
- **Union**: the agents available in one project of one organization.
## 2. Where each level lives
| Level | File | Writer today | Owner |
|---|---|---|---|
| root | `<configDir>/agents.json`, next to the instance's system config | the user, by hand (no writer in A1) | the instance's OS user |
| org | `roles` in `<configDir>/businesses/<org>.json` (unchanged) | existing org writers | the organization |
| project | `projects.<project>.agents` in the same organization file | the owner's `updateOrg` only; no agent writer can reach it | the organization |
### Root: `<configDir>/agents.json`
`configDir` is the directory of the instance's system config
(`~/.config/mosaic-<instance>/`). The file sits outside the data root, so it is
outside every organization root (`<dataRoot>/org-runtime/<org>`) and every
bus, and no bus or sandbox mount reaches it. It is per instance, as the system
config is (invariant 2). When accounts arrive (Phase M), it moves to a
per-principal path under the same directory; the format stays.
```json
{
"agentsVersion": 1,
"agents": {
"assistant": { "definition": "assistant", "vars": { "model": "opus-5.5" } },
"reviewer": { "definition": "reviewer", "excludeOrgs": ["home"] },
"researcher": { "definition": "researcher" }
}
}
```
- Keys: `definition` (required), `vars` (agent layer, `checkVars(..., "agent")`),
`excludeOrgs` (distinct organization ids; "reviewer turned off for Home" in
sample 08). Any other key refuses.
- The file must be a regular file opened without following a link, owned by
the process uid and not writable by group or other, the same checks as the
organization file. A missing file means no root agents. Anything else that
is wrong refuses the whole root level (section 6).
- Refused at the root: a role definition that is version 1 or missing, the
`chief-of-staff` definition (each organization's chief is its own,
REQ-COS-3), an `assistant` definition under any name but `assistant`, and
`credentials`, `holder`, `tracker`, `identity`, `publish`, `hosts`.
### Organization: `roles`
Unchanged. Every organization instance stays as it is, validated by
`validateBusinessDocument`, with its credential map, tracker bot and identity.
### Project: `projects.<project>.agents`
The project level is in the organization file, not in the project repository.
The repository's `.mosaic/project.json` can be written by any agent that can
push to the project, so a definition there would let an agent create agents.
The organization file is reachable only by the trusted writer, and the
existing projects fence (`organization-projects-protected` in
`organization.mjs`) already refuses every general write that changes
`projects`. No agent writer can add, change or remove a project agent. The
owner's `updateOrg` can, and the Console's organization editor holds the key
on the server (`SERVER_PROJECT_KEYS`), so the browser draft cannot change it in
A1.
```json
"projects": {
"spring-launch": {
"root": "...",
"agents": {
"social-scheduler": { "definition": "pm", "vars": { "model": "haiku-5.5" } },
"coder": { "vars": { "limits.network": "none" } },
"reviewer": { "exclude": true }
}
}
}
```
Each entry has exactly one form:
- **define**: `{ "definition", "vars"? }`. Agent-layer vars. Not
`chief-of-staff`, not `assistant`.
- **narrow**: `{ "vars" }` only. Every key must be an intersect key
(`limits.tools`, `limits.network`, `limits.authority`), checked at the
project layer. A project narrows an inherited agent and never widens it.
- **exclude**: `{ "exclude": true }`. The inherited agent is not available in
this project.
`validateBusinessDocument` checks each entry's shape and role definition for
every reader (bus, Console, CLI). It does not copy `agents` into the loaded
business object, so a bus built from the file sees exactly what it saw before.
A project that is deleted takes its agents with it, and so does a deleted
organization.
Library projects (`kind: "library"`) keep their own mapping check and take no
`agents` key.
This is an additive, optional key in the organization file. It is not a bus
store (SQLite) schema change: no migration, no new table, no bus verb.
## 3. Union resolution
For organization O and project P:
1. Start with the root agents, minus any whose `excludeOrgs` names O.
2. Lay the organization's `roles` over them. An org instance with the same
name as a root agent replaces it entirely; the root entry is recorded as an
override of that agent.
3. Lay P's entries over the result:
- **define** replaces any inherited agent of that name; the inherited
entry is recorded as an override.
- **narrow** merges vars through `mergeVars`, inherited layer first and the
project layer after it, so limits only intersect. The keys it narrowed
are recorded.
- **exclude** removes the agent and records who removed it.
Rules:
- The most specific level that defines a name wins, and the whole entry
wins: definition and vars, never a field-by-field mix.
- A narrow or exclude entry naming an agent that no lower level provides
refuses. A typo never silently does nothing.
- A project entry of any form naming the organization's chief (an instance
whose definition is `chief-of-staff`) or the assistant refuses. A project
cannot remove, narrow or replace the chief or the assistant.
- Two levels never merge vars, apart from a project narrowing. Root vars stay
with the root definition, and an org instance of the same name brings its
own.
- The result for each agent: name, definition, level, source (which file),
the overrides it shadows, the keys narrowed, and its scope. Every agent in a
union is scoped to O, an organization's own `assistant` instance included.
Only the root assistant, which no union holds, is cross-organization.
- The union also lists what O or P does not get: root agents turned off for
O and agents P excluded. A name that a more specific level then defines is
available under that definition and is not listed.
Implementation: `packages/organization/src/agent-levels.mjs`
(`loadRootAgents`, `checkRootAgents`, `checkProjectAgents`,
`resolveAgentLevels`, `projectUnion`). It is pure apart from the one root file
read.
## 4. Isolation of every non-assistant agent
Every agent except the root assistant resolves to scope `{ org: O }` in the
organization it runs in, whichever level defined it. The scope fixes three
things:
- **Memory and records**: under the organization's root,
`<dataRoot>/org-runtime/O/...`, on that organization's bus, as every org
instance has today. A root agent's copy in O and its copy in O2 share
nothing: no thread, no record, no session file.
- **Files**: the project workspaces of O only. The sandbox mounts come from
O's project mapping, never from another organization's.
- **Credentials**: only from O's credential map. A root definition carries
none (refused, section 2). In A1 a root or project agent is shown as
unbound: it has no credential and cannot be launched, because the bus
builds instances from `roles` alone and the loaded business object does not
carry project agents.
When root copies run (slice A3), each copy becomes a bus instance in O under
O's writer, with O's credential bindings, through the same `checkRoles` path an
org instance takes. A copy never reads the root file at run time; it gets its
definition when O's bus boots.
## 5. The assistant across organizations
### Reads
The assistant reads another organization only through the Console's
membership-checked cross-organization read: `organizations.members(session)`
(`packages/webui/src/organizations.mjs`), the read `GET /api/workspace` uses.
Each organization is admitted by `showOrg` (owner check, registry digest,
document validation) and `resolveOrg` (marker, modes) before any transport is
built, and each bus read goes over that organization's own socket with a
deadline. The assistant process gets no org root path, no bus socket path and
no file mount of any organization. It calls a read broker that the Console
holds for the owner's session. A direct read of another organization's bus
files does not exist as a code path: the assistant sandbox mounts only its own
memory directory.
### Shared memory
The assistant's one memory lives at `<dataRoot>/assistant/` (mode 0700, owned
by the instance user). It sits inside the instance data root and outside every
`org-runtime/<org>` root, so deleting or archiving an organization never
touches it, and no organization's agents can mount it. It holds the
assistant's notes and session files. It never holds a copy of an
organization's records; what the assistant learns from an organization stays
in that organization, plus whatever the assistant chose to write in its own
memory.
### Authority
REQ-ASSIST-1: the assistant talks with the user and hands each organization's
work to that organization's chief. It does not direct agents, create tasks or
launch sessions. Its role keeps `message.send` and `record.write` only. The
roles proposal (section 8) adds no action to it.
### A1 reality
Today each organization file declares its own `assistant` instance. A1 shows
those as org agents. A root `assistant` entry, if the user writes one, shows in
the band as the root assistant with cross-organization scope. Nothing reads
across organizations for it yet.
## 6. Fail-closed boundaries
| Boundary | A1 behavior | Later slices |
|---|---|---|
| Root file (new) | `O_NOFOLLOW` regular file, process uid, no group or other write; any problem refuses the whole root level and the grid says so; a missing file is "no root agents". The refusal names the file and the key at fault, never a value from the file: parser text and a var check's quote of the refused value are dropped, since a value can be a secret typed in the wrong place. Project var refusals drop the value the same way. | The root writer accepts only the human session (section 7). |
| Organization file `projects.<p>.agents` (new key) | Checked by `validateBusinessDocument` for every reader; a bad entry refuses the whole organization file, as any bad key does. Not copied into the loaded business, so buses gain nothing. The projects fence keeps every agent writer out. | A project-agent writer verb, with its own guard, like `addProject`. |
| Union | A narrow or exclude naming nothing refuses that project; touching the chief or assistant refuses that project; the grid shows the refusal for that project. | A launch resolver refuses to launch any agent in a project whose union refuses. |
| Console read `GET /api/agents/levels` (new) | Owner only (granted users and the tester get `owner-only`, 403); organizations come only from `organizations.members(session)`; the body carries ids, names, role definition names, model var and level. It never carries a path, a credential reference or a var other than `model` and the narrowed key names. | Unchanged. |
| Org isolation | Root and project agents are not instances on any bus; nothing reads across organizations except the existing Console read. | Each copy is materialized by O's own writer and runs under O's credentials. |
| Assistant memory | Not built. | `<dataRoot>/assistant/` with 0700; the sandbox mounts it and nothing else. |
| Roles | No change in commits. | The proposal patch, applied only after Jason approves it. |
A root file that refuses hides the root level everywhere rather than showing a
partial list: an exclusion that did not parse must not let an excluded agent
appear.
## 7. Chief-of-staff hire and fire, per level
Today `roles/chief-of-staff.json` lists `role.hire` and `role.fire` within its
role, with `"hireable": []`: it can hire nothing. Each level adds a write path;
none exists in A1.
- **Organization level** (REQ-COS-3): the chief hires or fires an
organization instance through the organization's trusted writer, for a role
in its `hireable` list. Each hire lands in the action trail. The user can
override any setting at any time through REQ-ROLE-3.
- **Project level**: the chief adds or removes a project entry through a new
writer verb that changes only `projects.<p>.agents` (a guard like
`addProject`'s), for a role in `hireable`. The projects fence still refuses
every other change.
- **Root level** (U0 ruling): the chief cannot write the root file. It raises
an inbox decision (REQ-DEC-2) in its own organization that names the role
definition, the agent name and the reason. The user approves or denies it in
the Console inbox. On approval the Console, acting with the human's session,
writes the root file through a root writer that refuses any actor other than
the signed-in owner (`human-required`). A denied or expired decision changes
nothing. A fire works the same way. The chief never learns the file's path.
`hireable` decides what can be hired at all; widening it is a `roles/` change
and needs Jason's reviewed commit. The proposal (section 8) names the roles.
## 8. Authority changes proposed, not applied
The patch is at `/mnt/storage/scratch/mosaic-lead/reports/org-a1-roles-proposal.patch`.
It is never applied on this branch. It applies with `git apply` on
`origin/refactor` and needs a reviewed commit that Jason approves.
What it changes:
- `roles/chief-of-staff.json`: `hireable` goes from `[]` to `coder` and the
five advisory chiefs (`chief-executive-officer`, `chief-financial-officer`,
`chief-information-officer`, `chief-information-security-officer`,
`chief-marketing-officer`). These are the only shipped definitions that pass
today's hire check: version 2, not creative, and every action on
`HIRE_ALLOWED`. The chief can then hire at the organization level with the
existing `hire` verb. The project level (A4) uses the same list.
- `roles/chief-of-staff.md`: drops "your empty hireable list", names the list,
says a hired coder has no credential until the human binds one, and says the
chief hires only in its own organization, never at the root.
- `packages/organization/tests/chief-of-staff.test.mjs` and `role.test.mjs`:
the tests that pin the shipped chief file move to the new list, and the
contract test pins the new sentence. Two bus test comments that said "shipped chief hireable:[]" are
reworded. These go in the same commit, or the organization suite fails.
What it leaves alone, and why:
- `roles/assistant.json`: no change. The cross-organization read is the
Console's read broker acting for the owner's session (section 5), not a
role action, so the assistant needs no new grant. It keeps `message.send`
and `record.write`. Adding an action would widen it for no gain.
- `roles/assistant.md`: no change yet. Its "one user in one business" text
stays true until A6 builds the root assistant; the contract text for the
cross-organization read and the shared memory lands with A6, in that
slice's own reviewed commit.
- Root-level hire text in `chief-of-staff.md` lands with A5, when the inbox
decision exists. A contract sentence for a tool the chief doesn't have would
invite it to try.
- `HIRE_ALLOWED`: unchanged. `reviewer`, `security-reviewer` (`review.verdict`),
`pm` (`task.create`), `cto` and `merge-gate` stay unhireable. Making any of
them hireable is a separate vocabulary decision, not part of this proposal.
## 9. Phase A slices after the roles change is approved
1. **A2, root and project writers.** Owner-only root file writer (human
session, trail entry, revision check) and the project-agent writer verb
under the projects fence, both refusing every agent actor. Settings,
Agents gets the edit forms.
2. **A3, root copies run.** Each organization's bus boot materializes its
root copies as instances through `checkRoles`, with the organization's
credential bindings; unbound copies stay unlaunchable and say why. Project
agents launch only in their project.
3. **A4, chief hire and fire at org and project level**, bounded by the
approved `hireable` list, trailed.
4. **A5, root-level hire and fire as inbox decisions**, with the owner's
approval writing the root file through A2's writer.
5. **A6, the root assistant.** `<dataRoot>/assistant/` memory, the Console
read broker over `organizations.members`, and the per-organization
`assistant` instances retired in favor of the one root assistant.
6. **A7, the Agents grid's write controls** (model Change, override markers,
turn off for an organization), each a trailed human action.
## 10. Slice A1 as built
- **Store and union**: `packages/organization/src/agent-levels.mjs`, with the
old-path re-export `packages/business/src/agent-levels.mjs` that the rename
r2 stage B test requires of every organization module.
`validateBusinessDocument` checks `projects.<p>.agents` and drops it from
the loaded business.
- **Read**: `GET /api/agents/levels` (`packages/webui/src/agent-levels-read.mjs`),
wired in `cli.mjs` with `loadRoot` reading `<configDir>/agents.json`. The
route is instance-wide, like `/api/workspace`, so `display.js` leaves it
unscoped: its fetch wrapper would otherwise rewrite it to
`/api/orgs/<id>/agents/levels`, which does not exist.
- **Console editor**: `SERVER_PROJECT_KEYS` holds `agents`, so a browser draft
of the organization file keeps the server's value. The CLI's full-document
path (`mosaic org update --from`) is the owner's and can set project agents;
A1 adds no other writer.
- **Agents grid** (sample 08): the level band shows the root agents and their
per-organization opt-outs, each organization's instances, and each
project's own definitions. Below it, a project picker chooses which union
the table shows: agent, level, defined in, memory, status, model. Root and
project agents read "not running", because nothing launches them yet. The
root assistant's status reads "status not reported" and its model is the
one the root file sets, shown as configured. A
narrowed agent names the keys, an override names what it replaces, and a
line under the table lists what this project does not get and why. A
refused root file, organization or project shows its reason in place of
its rows. The grid has no write controls; those are A7.
+4
View File
@@ -185,6 +185,8 @@ const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collabora
// Row 275 (row 193 design 4.3): the owner's board reads, which a reader session may call. A user,
// collaborator or viewer, is refused every one at the USER_VERBS gate.
export const BOARD_VERBS = Object.freeze(['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
// The person's task-store writes, distinct from the agent's singular task.* actions.
export const PERSON_TASK_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete']);
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
@@ -6915,6 +6917,8 @@ export class Broker {
#dispatch(s, verb, a, spec = null, binding = null) {
const b = this.#business(s.business),
by = s.human || s.run;
// Preserve the common request gates and refuse additions until the vocabulary names them.
if (verb.startsWith('tasks.') && !PERSON_TASK_VERBS.includes(verb)) fail('unknown-verb');
switch (verb) {
case 'role.claim': {
keys(a, []);
@@ -0,0 +1,54 @@
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 * as bus from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'pt-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const broker = new bus.Broker({ store, businesses: {
acme: { id: 'acme', human: 'jason', roles: {
pm: { authority: { withinRole: [], crossRole: [] } },
cto: { authority: { withinRole: [], crossRole: [] } },
}, arbiters: { technical: 'cto', delivery: 'pm' } },
} });
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
return { broker, human };
}
test('person task vocabulary pins exactly the five frozen store verbs', () => {
assert.deepEqual(bus.PERSON_TASK_VERBS, [
'tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete',
]);
assert.ok(Object.isFrozen(bus.PERSON_TASK_VERBS));
});
test('every person task handler is declared in the vocabulary, even if the dispatch guard regresses', () => {
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
const handlers = [...source.matchAll(/^\s*case\s+(['"])(tasks\.[^'"]+)\1\s*:/gm)].map(match => match[2]);
assert.deepEqual(handlers.sort(), [...bus.PERSON_TASK_VERBS].sort());
});
test('every listed person task verb reaches its handler, while an unlisted verb is unknown', t => {
const { broker, human } = fixture(t);
assert.ok(Array.isArray(bus.PERSON_TASK_VERBS));
for (const verb of bus.PERSON_TASK_VERBS) {
// Missing required fields reaches the handler's field validation, not unknown-verb.
assert.throws(() => broker.request(human, { verb, args: {} }), { code: 'invalid-request' }, verb);
}
assert.throws(() => broker.request(human, { verb: 'tasks.foo', args: {} }), { code: 'unknown-verb' });
});
test('unknown person task verbs retain common argument and reader refusal precedence', t => {
const { broker, human } = fixture(t);
assert.throws(() => broker.request(human, { verb: 'tasks.foo', args: [] }), { code: 'invalid-request' });
const reader = broker.bindReader({ business: 'acme' });
assert.throws(() => broker.request(reader, { verb: 'tasks.foo' }), { code: 'read-only' });
for (const verb of ['tasks.foo', 'tasks.create']) {
assert.throws(() => broker.request(reader, { verb }), { code: 'read-only' }, verb);
}
});
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/accounts.mjs (rename design r2, stage B).
export * from "../../organization/src/accounts.mjs";
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/agent-levels.mjs (rename design r2, stage B).
export * from "../../organization/src/agent-levels.mjs";
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/org-rights.mjs (rename design r2, stage B).
export * from "../../organization/src/org-rights.mjs";
+29
View File
@@ -1298,3 +1298,32 @@ 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.
## Accounts
`mosaic accounts` administers the instance-level accounts store locally, as the
effective uid owning the config directory. It requires a human shell, rejects `--tester`,
and takes no business selector or bus connection. It has no organization rights
verbs. Console sign-in, sessions and membership evaluation are later Phase M
slices; these commands alone do not turn accounts mode on.
```sh
mosaic accounts init --name Operator
mosaic accounts list --json
mosaic accounts user add --name Pat --subject pocket:verified-subject --revision 1
mosaic accounts user disable u-0123456789ab --revision 2
mosaic accounts user enable u-0123456789ab --revision 3
mosaic accounts user signout u-0123456789ab --revision 4
mosaic accounts user remove u-0123456789ab --revision 5
```
Init creates one operator account containing every configured `console.sso`
subject and refuses if accounts.json already exists. Add mints its id and defaults
to instance role `user`; `--role operator` creates another operator. Writes require
the file revision from list. Disable, enable and signout advance `authGen`, and
the last active operator cannot be disabled or removed. Removal scans ownership
while holding the registry then accounts locks, refuses if the account owns an
organization (including archived organizations) or any registry entry lacks its
owner metadata during creation, and removes its memberships in
the same write. List reports rollback rows needing repair; its JSON omits invite
bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets.
+109
View File
@@ -0,0 +1,109 @@
// Phase M2: the local, instance-uid account administration exception. No org
// rights commands and no bus transport: account init must work before orgs exist.
import { basename, join } from 'node:path';
import { configDir } from '../../organization/src/organization-config.mjs';
import { accountsRepairs, assertAccountsOwner, loadAccounts, mintAccountId, withAccounts } from '../../organization/src/accounts.mjs';
import { withRegistry, readRegistryLocked } from '../../organization/src/org-registry.mjs';
import { readJsonFile, rejectUnknownKeys, requireString } from '../../organization/src/util.mjs';
import { CliError } from './errors.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ACCOUNTS_USAGE = [
'mosaic accounts init [--name NAME]',
'mosaic accounts list [--json]',
'mosaic accounts user add --name NAME --subject PROVIDER:SUB [--role operator|user] --revision N',
'mosaic accounts user disable|enable|signout|remove ID --revision N',
].join('\n');
function parse(args) {
const words = [], values = {}, flags = new Set();
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (!arg.startsWith('--')) { words.push(arg); continue; }
if (flags.has(arg) || Object.hasOwn(values, arg)) throw new CliError(`duplicate ${arg}`, 4);
if (arg === '--tester') throw new CliError('refused: accounts takes the human shell, not --tester', 3);
if (arg === '--json') flags.add(arg);
else if (['--name', '--subject', '--role', '--revision'].includes(arg) && args[i + 1] && !args[i + 1].startsWith('--')) values[arg] = args[++i];
else throw new CliError(ACCOUNTS_USAGE, 4);
}
const verb = words[0] === 'user' ? `user.${words[1]}` : words[0];
const valid = ['init', 'list'].includes(verb) ? words.length === 1 : verb === 'user.add' ? words.length === 2 : ['user.disable', 'user.enable', 'user.signout', 'user.remove'].includes(verb) && words.length === 3;
if (!valid) throw new CliError(ACCOUNTS_USAGE, 4);
const allowed = verb === 'init' ? ['--name'] : verb === 'list' ? [] : verb === 'user.add' ? ['--name', '--subject', '--role', '--revision'] : ['--revision'];
if (Object.keys(values).some(k => !allowed.includes(k)) || (flags.size && verb !== 'list')) throw new CliError(ACCOUNTS_USAGE, 4);
const revision = verb === 'init' ? 0 : Number(values['--revision']);
if (verb !== 'list' && verb !== 'init' && (!/^[1-9][0-9]*$/.test(values['--revision'] ?? '') || !Number.isSafeInteger(revision))) throw new CliError('--revision requires the revision from accounts list', 4);
return { verb, values, flags, revision, id: words[2] };
}
function owns(user, owner, providers) {
return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss);
}
function readOwner(root) {
const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => {
if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3);
});
rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner');
for (const key of ['provider', 'iss', 'sub']) requireString(owner[key], `owner.${key}`, { max: 2048 });
return owner;
}
export async function accountsCommand(args, io, deps) {
refuseInsideAgent(io.env);
try {
const parsed = parse(args);
const dir = deps.configDir ?? configDir(io.env);
// This is deliberately before withRegistry as well as withAccounts.
assertAccountsOwner(dir, { uid: deps.uid ?? process.geteuid() });
const providers = deps.system.console?.sso?.providers ?? [];
const ctx = { configDir: dir, instance: deps.instance ?? basename(dir).replace(/^mosaic-/, ''), providers };
if (parsed.verb === 'list') {
const doc = loadAccounts(join(dir, 'accounts.json'), ctx);
const repairs = accountsRepairs(doc);
// Invites are not administered here. Never expose bindings or token hashes.
const listed = { ...doc, invites: doc.invites.map(({ tokenHash, bind, ...invite }) => invite), repairs };
io.stdout.write(parsed.flags.has('--json') ? JSON.stringify(listed, null, 2) + '\n' : [`accounts revision ${doc.revision}`, ...doc.users.map(u => `${u.id} ${u.name} ${u.instanceRole} ${u.state} authGen ${u.authGen}`), ...repairs.map(r => `${r.kind} ${r.org} needs repair: ${[r.unknownRole, ...r.unknownRights].filter(Boolean).join(', ')}`)].join('\n') + '\n');
return;
}
let added;
const mutate = doc => {
if (parsed.verb === 'init') {
const subjects = providers.flatMap(p => (p.subjects ?? []).map(s => ({ provider: p.id, sub: s.sub })));
if (!subjects.length) throw new CliError('refused: init requires console.sso subjects', 3);
added = { id: mintAccountId(), name: parsed.values['--name'] ?? 'Operator', subjects, instanceRole: 'operator', state: 'active', authGen: 1, created: new Date().toISOString() };
doc.users.push(added); return;
}
if (parsed.verb === 'user.add') {
const value = parsed.values['--subject'] ?? '', colon = value.indexOf(':');
if (!parsed.values['--name'] || colon < 1 || colon === value.length - 1) throw new CliError('user add needs --name and --subject PROVIDER:SUB', 4);
added = { id: mintAccountId(), name: parsed.values['--name'], subjects: [{ provider: value.slice(0, colon), sub: value.slice(colon + 1) }], instanceRole: parsed.values['--role'] ?? 'user', state: 'active', authGen: 1, created: new Date().toISOString() };
doc.users.push(added); return;
}
const u = doc.users.find(u => u.id === parsed.id);
if (!u) throw new CliError('refused: account not found', 2);
if (parsed.verb === 'user.remove') { doc.users = doc.users.filter(x => x !== u); doc.memberships = doc.memberships.filter(m => m.user !== u.id); }
else if (parsed.verb === 'user.signout') u.authGen++;
else { const state = parsed.verb === 'user.disable' ? 'disabled' : 'active'; if (u.state === state) throw new CliError('refused: account already has that state', 2); u.state = state; }
};
const opts = { revision: parsed.revision, verb: parsed.verb, init: parsed.verb === 'init' };
let result;
if (parsed.verb === 'user.remove') {
result = await withRegistry(ctx, registry => withAccounts(ctx, { ...opts, registry }, async doc => {
const user = doc.users.find(u => u.id === parsed.id);
if (!user) throw new CliError('refused: account not found', 2);
// Even disabled accounts keep ownership. A real create reserves before
// writing owner.json, so any missing owner must block removal too.
for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) {
let owner;
try { owner = readOwner(entry.root); }
catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; }
if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3);
}
mutate(doc);
}), { write: false });
} else result = await withAccounts(ctx, opts, mutate);
io.stdout.write(`accounts ${parsed.verb.replace('.', ' ')}: ${added?.id ?? parsed.id ?? ''}, revision ${result.revision}\n`);
} catch (e) {
if (e instanceof CliError) throw e;
const error = new CliError(`refused: ${e.code ? e.code + ': ' : ''}${e.message}${e.committed ? '; accounts write committed; durability or receipt step failed; inspect before retrying' : ''}`, 3);
if (e.committed) error.committed = true;
throw error;
}
}
+3
View File
@@ -24,6 +24,7 @@ import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
import { instancesCommand, orgInstancesCommand, ORG_INSTANCES_USAGE } from "./instances.mjs";
import { accessCommand, accessContextFor } from "./access.mjs";
import { accountsCommand, ACCOUNTS_USAGE } from "./accounts.mjs";
import { createChannelCaller } from "../../plugins/src/channel.mjs";
import { PluginError } from "../../plugins/src/kinds.mjs";
import { CliError, CliExit } from "./errors.mjs";
@@ -52,6 +53,7 @@ import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
export const USAGE = `usage:
${ACCOUNTS_USAGE}
${ORG_USAGE}
${USAGE_USAGE}
${LIMITS_USAGE}
@@ -745,6 +747,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
now: deps.now,
});
}
if (verb === 'accounts') return accountsCommand(rest, io, { system: deps.system ?? loadSystem({ env: io.env }), configDir: deps.accountsConfigDir });
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
+71
View File
@@ -0,0 +1,71 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { accountsCommand } from '../src/accounts.mjs';
import { main } from '../src/cli.mjs';
import { withRegistry, reserveOrg } from '../../organization/src/org-registry.mjs';
const providers = [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }, { sub: 'other-owner' }] }, { id: 'second', issuer: 'https://second.example', subjects: [{ sub: 'owner' }] }];
function setup(t) {
const configDir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(configDir, 0o700); t.after(() => rmSync(configDir, { recursive: true, force: true }));
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } }; let output = '';
const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
return { configDir, deps, io, run: args => accountsCommand(args, io, deps), read: () => JSON.parse(readFileSync(join(configDir, 'accounts.json'))), output: () => output };
}
test('init makes exactly one operator from all configured subjects, then refuses rerun', async t => {
const s = setup(t); await s.run(['init']); const d = s.read(); assert.equal(d.users.length, 1); assert.equal(d.users[0].instanceRole, 'operator'); assert.equal(d.users[0].subjects.length, 3); assert.match(d.users[0].id, /^u-[0-9a-f]{12}$/); assert.equal(d.revision, 1);
await assert.rejects(s.run(['init']), /already exists/); await s.run(['list', '--json']); assert.match(s.output(), /authGen/);
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'init'); assert.ok(!JSON.stringify(r).includes('other-owner'));
});
test('CLI add, disable, enable, signout and remove; last operator guard and stale revisions', async t => {
const s = setup(t); await s.run(['init']); const op = s.read().users[0].id;
await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat:sub', '--revision', '1']); const id = s.read().users[1].id;
for (const [verb, revision, generation] of [['disable', 2, 2], ['enable', 3, 3], ['signout', 4, 4]]) { await s.run(['user', verb, id, '--revision', String(revision)]); assert.equal(s.read().users[1].authGen, generation); }
await assert.rejects(s.run(['user', 'disable', op, '--revision', '5']), /active operator/);
await assert.rejects(s.run(['user', 'signout', id, '--revision', '1']), /revision-conflict/);
await s.run(['user', 'remove', id, '--revision', '5']); assert.equal(s.read().users.length, 1); assert.equal(s.read().revision, 6);
assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').length, 6);
});
test('host uid refusal happens before any lock, even on list or remove', async t => {
const s = setup(t); for (const args of [['list'], ['init'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
await assert.rejects(accountsCommand(args, s.io, { ...s.deps, uid: process.getuid() + 1 }), /uid/);
assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false); assert.equal(existsSync(join(s.configDir, 'org-runtime.lock')), false);
}
});
test('remove holds registry before accounts, refuses ownership of active or archived orgs, including a preceding create', async t => {
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
const root = join(s.configDir, 'acme'); mkdirSync(root, { mode: 0o700 });
let release, entered; const ready = new Promise(r => entered = r), hold = new Promise(r => release = r);
const create = withRegistry(s.deps, async registry => { entered(); await hold; registry.orgs.acme = { id: 'acme', root, state: 'archived', revision: 1, created: '2026-11-10T00:00:00Z' }; writeFileSync(join(root, 'owner.json'), JSON.stringify({ provider: 'pocket', iss: 'https://id.example', sub: 'pat' }), { mode: 0o600 }); });
await ready; const removing = s.run(['user', 'remove', id, '--revision', '2']); release(); await create; await assert.rejects(removing, /user-owns-org/); assert.equal(s.read().revision, 2);
});
test('remove drops memberships atomically, and malformed ownership metadata refuses', async t => {
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const d = s.read(), id = d.users[1].id;
d.memberships.push({ org: 'acme', incarnation: 'a'.repeat(32), user: id, role: 'member', rights: ['org.view'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, by: d.users[0].id, added: '2026-11-10T00:00:00Z', via: 'invite:abc' }); writeFileSync(join(s.configDir, 'accounts.json'), JSON.stringify(d));
await s.run(['user', 'remove', id, '--revision', '2']); assert.deepEqual(s.read().memberships, []);
const root = join(s.configDir, 'broken'); mkdirSync(root, { mode: 0o700 });
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{}', { mode: 0o600 }); });
await assert.rejects(s.run(['user', 'remove', s.read().users[0].id, '--revision', '3']), /owner/);
});
test('mosaic main wires accounts without a bus; tester, agent and unexpected args refuse', async t => {
const s = setup(t); await main(['accounts', 'init'], s.io, { system: s.deps.system, accountsConfigDir: s.configDir }); assert.equal(s.read().users.length, 1);
for (const args of [['list', '--tester'], ['list', '--subject', 'pocket:x'], ['user', 'add', 'chosen-id', '--name', 'Pat', '--subject', 'pocket:x', '--revision', '1'], ['list', '--json', '--json']]) await assert.rejects(s.run(args));
await assert.rejects(accountsCommand(['list'], { ...s.io, env: { MOSAIC_RUN_ID: 'run' } }, s.deps), /human shell/);
});
test('review P2-2: real reservation wins before owner.json exists, and removal refuses', async t => {
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
const dataRoot = join(s.configDir, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir: s.configDir, instance: 'test', dataRoot };
const org = await reserveOrg(ctx, 'acme'); assert.equal(existsSync(join(org.root, 'owner.json')), false);
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), /org-create-in-progress/); assert.equal(s.read().users.length, 2); assert.equal(s.read().revision, 2);
});
test('host boundary and receipt use effective uid, not real uid (current design clarification)', async t => {
const s = setup(t), realUid = process.getuid, effectiveUid = process.geteuid();
process.getuid = () => effectiveUid + 1;
try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); }
finally { process.getuid = realUid; }
});
+44 -2
View File
@@ -7,8 +7,9 @@ stay as the old names: each module there re-exports the module of the same name
here (`src/business.mjs` re-exports `src/organization-config.mjs`), and its
`src/cli.mjs` runs this package's `src/cli.mjs`. Brief: `docs/plans/2026-10-04_slice-1.md`,
row S1. Design: `agents/darkwing/work/slice1-data-model-2026-10-04.md` and its
addenda A and B. The package reads files and refuses on any problem. It
never writes, and it never opens a token file.
addenda A and B. Configuration readers refuse on any problem. Runtime
organization and accounts stores provide explicit locked writes; this package
never opens a token file.
```sh
scripts/mosaic org config validate mosaic-stack
@@ -583,3 +584,44 @@ The optional `plugins.system-type` list enables installed system-type modules fo
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.
## Instance accounts (Phase M2)
`src/accounts.mjs` validates `<configDir>/accounts.json` without a dependency or
JSON schema engine, as `access.mjs` does. `checkAccounts(document, {providers})`
and `loadAccounts(file, {providers})` return frozen documents. Providers are the
configured `console.sso.providers` objects (including issuer). Reads refuse an
unsafe config directory, unsafe file, unknown field or malformed row. Account
ids may be organization humans. `accountsRepairs(document)` reports unknown
roles and rights retained on read for rollback; these rows must grant nothing.
M4 supplies the authorization evaluator; M2 alone grants no Console or bus access.
`withAccounts(ctx, {revision, verb, actor?, registry?, init?}, mutate)` takes
`accounts.lock`, reloads, checks the expected file revision, awaits `mutate` on a
copy, maintains `authGen` and membership `rev`, validates and atomically replaces
0600 bytes. Init publishes with a no-clobber hard link; replacements use rename.
Both fsync the config directory before the receipt is appended. `ctx` contains `configDir`, `instance`, and `providers`. Init requires
revision 0 and an absent file. Other writes require an existing valid file. Writes
refuse unknown rights/roles on new or changed rows. Unchanged rollback rows pass
through exactly, so they cannot block account revocation or another row's repair. Role templates are never evaluated as authority by this store.
For a caller already holding accounts.lock,
`withAccountsLock(ctx, handle => withAccountsLocked(handle, options, mutate))`
reuses that live opaque handle. Never acquire an earlier lock from the callback.
The config path must have no symlink ancestors, including the home directory.
The order is lifecycle, registry, accounts. Registry-dependent writes pass the
live document from `withRegistry`; `readRegistryLocked(registry, ctx?)` verifies
the lock and rereads its persisted snapshot without relocking. A changed registry
or an instance mismatch refuses before account publication. Callbacks cannot
reuse a released handle, or run two writes at once through one handle.
Each committed write appends and fsyncs one receipt under the accounts lock in
`accounts-receipts.jsonl`: effective OS uid, actor id, verb, old/new file revisions, changed
account ids with changed field names, and before/after membership rights, scope
and revisions. No account
name, subject, invite binding, hash, raw token or cookie enters the receipt. The
store preflights the receipt destination before replacing accounts. As with
other two-file journals, a crash or IO failure between replacement and receipt
append can leave committed state without its receipt; an observed append failure
has `committed: true` and must be inspected before retrying. It never rolls back
committed authority silently or fabricates a success receipt.
+266
View File
@@ -0,0 +1,266 @@
// Phase M2 (#1873): instance accounts, independent of Console sessions and bus authority.
import { constants, lstatSync } from 'node:fs';
import * as fs from 'node:fs/promises';
import { dirname, join, parse, resolve } from 'node:path';
import { randomBytes } from 'node:crypto';
import { OrgError, readRegistryLocked } from './org-registry.mjs';
import { readJsonFile, requireObject, rejectUnknownKeys, requireString, requirePositiveInt, requireId, requireDistinctList, deepFreeze, canonicalJson } from './util.mjs';
import { RIGHTS, ROLE_RIGHTS, normalizeRights } from './org-rights.mjs';
const fail = (code, message = code) => { throw new OrgError(code, message); };
const bad = message => fail('accounts-invalid', message);
const USER_ID = /^u-[0-9a-f]{12}$/;
const HEX32 = /^[0-9a-f]{32}$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const same = (a, b) => canonicalJson(a) === canonicalJson(b);
const array = (x, at) => { if (!Array.isArray(x)) bad(`${at} must be an array`); return x; };
const text = (x, at) => {
requireString(x, at, { max: 255 });
if (/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/.test(x)) bad(`${at} must not contain control or bidi formatting characters`);
return x;
};
const keys = (x, allowed, at) => { requireObject(x, at); rejectUnknownKeys(x, allowed, at); };
const accountId = (x, at) => { if (typeof x !== 'string' || !USER_ID.test(x)) bad(`${at} must be u- plus 12 lowercase hex characters`); };
function time(x, at) {
if (typeof x !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(x) || !Number.isFinite(Date.parse(x)) || new Date(x).toISOString().slice(0, 19) !== x.slice(0, 19)) bad(`${at} must be a real ISO time`);
}
function providerMap(context) {
const providers = new Map();
for (const p of array(context?.providers, 'accounts context.providers')) {
text(p.id, 'provider.id');
if (p.id === 'local-dev' || typeof p.issuer !== 'string' || !p.issuer.startsWith('https://')) bad('provider must have an https issuer and must not be local-dev');
try { if (new URL(p.issuer).protocol !== 'https:') bad('invalid issuer'); } catch { bad('invalid issuer'); }
if (providers.has(p.id)) bad('duplicate configured provider');
providers.set(p.id, p);
}
return providers;
}
function subject(s, at, providers) {
keys(s, ['provider', 'sub'], at);
if (s.provider === 'local-dev' || !providers.has(s.provider)) bad(`${at}.provider must be a configured provider, never local-dev`);
text(s.sub, `${at}.sub`);
}
function scope(s, at) {
keys(s, ['projects', 'instances', 'expires'], at);
for (const key of ['projects', 'instances']) {
const entries = requireDistinctList(s[key], `${at}.${key}`, x => {
if (x === '*') return;
if (key === 'projects') { if (typeof x !== 'string' || !UUID.test(x)) bad(`${at}.projects must contain UUIDs`); }
else requireId(x, `${at}.instances`);
}, { nonEmpty: true });
if (entries.includes('*') && entries.length !== 1) bad(`${at}.${key} must use "*" alone`);
}
if (s.expires !== undefined) time(s.expires, `${at}.expires`);
}
function rowRights(row, at) {
requireId(row.role, `${at}.role`);
if (row.role === 'owner') bad(`${at}.role is not grantable`);
const rights = requireDistinctList(row.rights, `${at}.rights`, r => {
text(r, `${at}.rights`);
if (r === 'owner' || (Object.hasOwn(RIGHTS, r) && !RIGHTS[r].grantable)) bad(`${at}.rights is not grantable`);
if (!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*$/.test(r)) bad(`${at}.rights contains an invalid name`);
}, { nonEmpty: true });
if (!rights.includes('org.view') || !same(rights, [...rights].sort())) bad(`${at}.rights must be sorted and contain org.view`);
scope(row.scope, `${at}.scope`);
requireId(row.org, `${at}.org`);
if (typeof row.incarnation !== 'string' || !HEX32.test(row.incarnation)) bad(`${at}.incarnation must be 32 lowercase hex characters`);
}
// Pure whole-file validation. Unknown, well-formed rights/roles survive a rollback;
// accountsRepairs exposes them without changing the persisted schema.
export function checkAccounts(document, context) {
const providers = providerMap(context);
keys(document, ['accountsVersion', 'revision', 'users', 'memberships', 'invites'], 'accounts file');
if (document.accountsVersion !== 1) bad('accountsVersion must be 1');
requirePositiveInt(document.revision, 'revision');
const users = new Set(), subjects = new Set(); let operators = 0;
array(document.users, 'users').forEach((u, i) => {
const at = `users[${i}]`;
keys(u, ['id', 'subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'], at);
accountId(u.id, `${at}.id`); if (users.has(u.id)) bad('duplicate account id'); users.add(u.id);
text(u.name, `${at}.name`);
if (!['operator', 'user'].includes(u.instanceRole)) bad(`${at}.instanceRole must be operator or user`);
if (!['active', 'disabled'].includes(u.state)) bad(`${at}.state must be active or disabled`);
requirePositiveInt(u.authGen, `${at}.authGen`); time(u.created, `${at}.created`);
if (u.instanceRole === 'operator' && u.state === 'active') operators++;
array(u.subjects, `${at}.subjects`).forEach((s, j) => {
subject(s, `${at}.subjects[${j}]`, providers); const key = JSON.stringify([s.provider, s.sub]);
if (subjects.has(key)) bad('subject appears more than once in the file'); subjects.add(key);
});
if (!u.subjects.length) bad(`${at}.subjects must not be empty`);
});
if (!operators) bad('at least one active operator must exist');
const memberships = new Set();
array(document.memberships, 'memberships').forEach((m, i) => {
const at = `memberships[${i}]`;
keys(m, ['org', 'incarnation', 'user', 'role', 'rights', 'scope', 'rev', 'added', 'by', 'via'], at);
rowRights(m, at); accountId(m.user, `${at}.user`); if (!users.has(m.user)) bad(`${at}.user must name an account`);
accountId(m.by, `${at}.by`); text(m.via, `${at}.via`); time(m.added, `${at}.added`); requirePositiveInt(m.rev, `${at}.rev`);
const key = JSON.stringify([m.org, m.incarnation, m.user]); if (memberships.has(key)) bad('duplicate membership'); memberships.add(key);
});
const invites = new Set(), hashes = new Set();
array(document.invites, 'invites').forEach((invite, i) => {
const at = `invites[${i}]`;
keys(invite, ['id', 'org', 'incarnation', 'role', 'rights', 'scope', 'bind', 'tokenHash', 'by', 'created', 'expires', 'state', 'user', 'used'], at);
rowRights(invite, at); text(invite.id, `${at}.id`); if (!UUID.test(invite.id)) bad(`${at}.id must be a UUID`);
if (invites.has(invite.id)) bad('duplicate invite id'); invites.add(invite.id);
if (typeof invite.tokenHash !== 'string' || !/^[0-9a-f]{64}$/.test(invite.tokenHash) || hashes.has(invite.tokenHash)) bad(`${at}.tokenHash must be a unique sha256 hex hash`); hashes.add(invite.tokenHash);
accountId(invite.by, `${at}.by`); time(invite.created, `${at}.created`); time(invite.expires, `${at}.expires`);
if (Date.parse(invite.expires) <= Date.parse(invite.created)) bad(`${at}.expires must follow created`);
const ttl = Date.parse(invite.expires) - Date.parse(invite.created);
if (ttl < 3600000 || ttl > 168 * 3600000) bad(`${at} validity must be between 1 and 168 hours`);
if (!['open', 'used', 'revoked', 'declined', 'expired'].includes(invite.state)) bad(`${at}.state is invalid`);
requireObject(invite.bind, `${at}.bind`);
if (Object.hasOwn(invite.bind, 'email')) { keys(invite.bind, ['email'], `${at}.bind`); text(invite.bind.email, `${at}.bind.email`); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(invite.bind.email)) bad(`${at}.bind.email must be one address`); }
else subject(invite.bind, `${at}.bind`, providers);
if (invite.state === 'used') { accountId(invite.user, `${at}.user`); time(invite.used, `${at}.used`); }
else if (invite.user !== undefined || invite.used !== undefined) bad(`${at} unused invite has usage fields`);
});
return deepFreeze(structuredClone(document));
}
export function accountsRepairs(doc) {
const result = [];
for (const [kind, rows] of [['membership', doc.memberships], ['invite', doc.invites]]) for (const row of rows) {
const unknownRole = Object.hasOwn(ROLE_RIGHTS, row.role) ? undefined : row.role;
const unknownRights = row.rights.filter(r => !Object.hasOwn(RIGHTS, r));
if (unknownRole || unknownRights.length) result.push({ kind, org: row.org, ...(kind === 'membership' ? { user: row.user } : { id: row.id }), ...(unknownRole ? { unknownRole } : {}), unknownRights });
}
return result;
}
// Check the host boundary before any read or lock. Every ancestor must be a real
// directory; configDir itself belongs to this uid and is not publicly writable.
export function assertAccountsOwner(configDir, { uid = process.geteuid() } = {}) {
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('accounts-unsafe', 'config directory must be an absolute normalized path');
let current = parse(configDir).root;
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
current = join(current, part); const st = lstatSync(current);
if (!st.isDirectory() || st.isSymbolicLink()) fail('accounts-unsafe', 'config path contains a symlink or non-directory');
}
const st = lstatSync(configDir);
if (st.uid !== uid) fail('accounts-unsafe', `config directory must belong to uid ${uid}: ${configDir}`);
if (st.mode & 0o022) fail('accounts-unsafe', 'config directory must not be writable by group or other');
}
export function loadAccounts(file, context, { uid = process.geteuid() } = {}) {
assertAccountsOwner(dirname(file), { uid });
const doc = readJsonFile(file, 'accounts file', st => {
if (st.uid !== uid) fail('accounts-unsafe', `accounts file must belong to uid ${uid}: ${file}`);
if (st.mode & 0o022) fail('accounts-unsafe', 'accounts file must not be writable by group or other');
});
return checkAccounts(doc, context);
}
export const mintAccountId = () => `u-${randomBytes(6).toString('hex')}`;
const heldAccounts = new WeakMap();
// Low-level helper for callers already holding the earlier lifecycle/registry
// locks. A handle is valid only within this callback and serializes all writes.
export async function withAccountsLock(ctx, fn) {
assertAccountsOwner(ctx.configDir);
const lock = join(ctx.configDir, 'accounts.lock'); let fd;
for (let n = 0; n < 200; n++) {
try { fd = await fs.open(lock, 'wx', 0o600); break; }
catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); }
}
if (!fd) fail('accounts-locked', 'accounts lock is held; inspect it before retrying');
const handle = Object.freeze({}); heldAccounts.set(handle, { ctx, writing: false });
try { return await fn(handle); }
finally { heldAccounts.delete(handle); await fd.close(); await fs.unlink(lock); }
}
export async function withAccounts(ctx, options, fn) {
if (options.registry) await readRegistryLocked(options.registry, ctx);
return withAccountsLock(ctx, handle => withAccountsLocked(handle, options, fn));
}
const VERBS = new Set(['init', 'user.add', 'user.disable', 'user.enable', 'user.signout', 'user.remove', 'user.update', 'membership.add', 'membership.rights', 'membership.remove', 'membership.leave', 'invite.create', 'invite.accept', 'invite.decline', 'invite.revoke', 'org.delete', 'orphans.sweep']);
function generations(next, before, verb) {
for (const u of next.users) {
const old = before?.users.find(x => x.id === u.id);
if (!old) { u.authGen = 1; continue; }
const changed = u.state !== old.state || u.instanceRole !== old.instanceRole || !same(u.subjects, old.subjects);
if (changed) u.authGen = old.authGen + 1;
else if (u.authGen !== old.authGen && !(verb === 'user.signout' && u.authGen === old.authGen + 1)) bad('authGen may only advance one generation for signout or an authentication change');
if (u.created !== old.created) bad('account created time is immutable');
}
for (const m of next.memberships) {
const old = before?.memberships.find(x => x.org === m.org && x.incarnation === m.incarnation && x.user === m.user);
// A rollback row grants nothing, but must not block unrelated account
// revocation or the repair of another row. Preserve unchanged rows exactly.
if (old && same(m, old)) continue;
if (!Object.hasOwn(ROLE_RIGHTS, m.role) || m.role === 'owner') fail('unknown-role', 'unknown or ungrantable membership role');
m.rights = [...normalizeRights(m.rights)];
m.rev = old ? old.rev + (!same(old.rights, m.rights) || !same(old.scope, m.scope) ? 1 : 0) : 1;
}
for (const i of next.invites) {
const old = before?.invites.find(x => x.id === i.id);
if (old && same(i, old)) continue;
if (!Object.hasOwn(ROLE_RIGHTS, i.role) || i.role === 'owner') fail('unknown-role', 'unknown or ungrantable invite role');
i.rights = [...normalizeRights(i.rights)];
}
}
// Receipt construction has no free-form text: no names, subjects, bindings,
// tokens, hashes, caller-supplied objects, or cookies can enter the log.
function receipt(before, after, { verb, actor }, at) {
if (!VERBS.has(verb)) bad('unsupported receipt verb');
if (actor !== undefined && actor !== 'human') accountId(actor, 'receipt actor');
const membership = m => ({ org: m.org, incarnation: m.incarnation, user: m.user, role: Object.hasOwn(ROLE_RIGHTS, m.role) ? m.role : 'unknown', rights: m.rights, scope: m.scope, rev: m.rev });
return { at, uid: process.geteuid(), actor: actor ?? 'human', verb, revisionBefore: before?.revision ?? 0, revisionAfter: after.revision,
users: after.users.filter(u => !before || !same(u, before.users.find(x => x.id === u.id))).map(u => u.id),
userChanges: after.users.flatMap(u => {
const old = before?.users.find(x => x.id === u.id);
const changed = ['subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'].filter(key => !old || !same(u[key], old[key]));
return changed.length ? [{ id: u.id, changed }] : [];
}),
removedUsers: (before?.users ?? []).filter(u => !after.users.some(x => x.id === u.id)).map(u => u.id),
memberships: { before: (before?.memberships ?? []).map(membership), after: after.memberships.map(membership) } };
}
async function syncDirectory(dir) {
const fd = await fs.open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
try { await fd.sync(); } finally { await fd.close(); }
}
// Re-read under the lock, compare expected revision, validate and atomically
// replace 0600 bytes, then append and fsync exactly one receipt while still held.
// A post-rename receipt failure reports committed=true: retrying is not safe.
export async function withAccountsLocked(handle, options, fn) {
const held = heldAccounts.get(handle);
if (!held || held.writing) fail('accounts-locked', 'a live, unused accounts lock handle is required');
held.writing = true;
const { ctx } = held; const file = join(ctx.configDir, 'accounts.json');
let temp, receiptFd, committed = false;
try {
if (options.registry) await readRegistryLocked(options.registry, ctx);
assertAccountsOwner(ctx.configDir);
let before;
if (options.init) {
try { await fs.lstat(file); fail('accounts-exists', 'accounts.json already exists'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
if (options.revision !== 0) fail('revision-conflict');
} else before = loadAccounts(file, ctx);
if (!Number.isSafeInteger(options.revision) || options.revision !== (before?.revision ?? 0)) fail('revision-conflict', `revision-conflict: accounts revision is ${before?.revision ?? 0}`);
const next = before ? structuredClone(before) : { accountsVersion: 1, revision: 0, users: [], memberships: [], invites: [] };
await fn(next);
generations(next, before, options.verb); next.revision = (before?.revision ?? 0) + 1;
const checked = checkAccounts(next, ctx);
const line = receipt(before, checked, options, new Date().toISOString());
// Refuse unsafe receipt destinations before publishing the account change.
receiptFd = await fs.open(join(ctx.configDir, 'accounts-receipts.jsonl'), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
const st = await receiptFd.stat();
if (!st.isFile() || st.uid !== process.geteuid() || (st.mode & 0o022) || st.nlink !== 1) fail('accounts-unsafe', 'unsafe accounts receipt file');
temp = join(ctx.configDir, `.accounts-${randomBytes(12).toString('hex')}`);
const fd = await fs.open(temp, 'wx', 0o600);
try { await fd.writeFile(JSON.stringify(checked, null, 2) + '\n'); await fd.sync(); } finally { await fd.close(); }
await options.beforeReplace?.();
if (options.registry) await readRegistryLocked(options.registry, ctx);
// Defend against a non-cooperating writer even though cooperative writers
// already serialize through accounts.lock.
if (before) { if (!same(loadAccounts(file, ctx), before)) fail('revision-conflict', 'accounts changed before replace'); }
// Init must never replace a target, including one created by a writer that
// ignores accounts.lock. link publishes the complete inode or fails EEXIST.
if (before) await fs.rename(temp, file);
else await fs.link(temp, file);
committed = true;
if (!before) await fs.unlink(temp);
await (options.syncDirectory ?? syncDirectory)(ctx.configDir);
await receiptFd.writeFile(JSON.stringify(line) + '\n'); await receiptFd.sync();
return checked;
} catch (e) { if (committed) e.committed = true; throw e; }
finally { if (temp) await fs.rm(temp, { force: true }); if (receiptFd) await receiptFd.close(); held.writing = false; }
}
+209
View File
@@ -0,0 +1,209 @@
// Agent levels (#1873 Phase A, slice A1; design docs/plans/2026-11-10_agent-levels-design.md).
// Agents are defined at three levels: the user's root (<configDir>/agents.json), the
// organization (`roles` in the organization file) and the project (`projects.<p>.agents` in the
// same file). A project's agents are their union, the most specific level winning. This module
// reads and resolves only: nothing here writes, launches or binds a credential, and nothing a
// root or project definition says reaches a bus.
import { join } from "node:path";
import { refuse } from "./errors.mjs";
import { loadRole } from "./role.mjs";
import { checkVars, mergeVars, REGISTRY } from "./vars.mjs";
import { deepFreeze, isPlainObject, readJsonFile, rejectUnknownKeys, requireDistinctList, requireId, requireObject } from "./util.mjs";
export const AGENT_LEVELS = Object.freeze(["root", "org", "project"]);
export const ROOT_FILE = "agents.json";
const CHIEF = "chief-of-staff";
const ASSISTANT = "assistant";
// The keys an org instance has that bind it to one organization. A root or project definition
// names a role and its vars only; the organization it runs in supplies the rest.
const BOUND_KEYS = ["credentials", "holder", "tracker", "identity", "publish", "hosts"];
export function rootAgentsPath(configDir) {
return join(configDir, ROOT_FILE);
}
function definitionOf(name, at, rolesDir, cache) {
requireId(name, `${at}.definition`);
if (!cache.has(name)) {
// A role refusal names host paths; this message travels to the Console, so it names none.
try { cache.set(name, loadRole(rolesDir, name)); }
catch (error) { if (error?.name !== "BusinessError") throw error; refuse(`${at}.definition: no usable role definition named ${name}`); }
}
const definition = cache.get(name);
if (definition.roleVersion !== 2) refuse(`${at}: role definition ${name} is version 1 and can't back an agent`);
return definition;
}
function noBoundKeys(entry, at) {
for (const key of BOUND_KEYS) {
if (Object.hasOwn(entry, key)) refuse(`${at}.${key}: a ${at.startsWith("root") ? "root" : "project"} agent can't carry ${key}; the organization it runs in binds them`);
}
}
// The root file's document. `where` names it in messages, never with a host path.
export function checkRootAgents(document, { rolesDir, where = "root agents" } = {}) {
requireObject(document, where);
rejectUnknownKeys(document, ["agentsVersion", "agents"], where);
if (document.agentsVersion !== 1) refuse(`${where} requires "agentsVersion": 1`);
requireObject(document.agents, `${where} agents`);
const cache = new Map();
const agents = {};
let assistant = null;
for (const [name, entry] of Object.entries(document.agents)) {
const at = `root agents.${name}`;
requireId(name, `${where} agent name`);
requireObject(entry, at);
noBoundKeys(entry, at);
rejectUnknownKeys(entry, ["definition", "vars", "excludeOrgs"], at);
const definition = definitionOf(entry.definition, at, rolesDir, cache);
// Each organization's chief is its own (REQ-COS-3): never a root agent.
if (entry.definition === CHIEF) refuse(`${at}: ${CHIEF} can't be a root agent; each organization has its own chief`);
if (entry.definition === ASSISTANT) {
if (name !== ASSISTANT) refuse(`${at}: the assistant definition is the root agent named assistant only`);
assistant = name;
}
agents[name] = {
definition: entry.definition,
title: definition.title,
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
excludeOrgs: entry.excludeOrgs === undefined ? [] : requireDistinctList(entry.excludeOrgs, `${at}.excludeOrgs`, (id) => requireId(id, `${at}.excludeOrgs entry`)),
};
}
return deepFreeze({ agents, assistant });
}
// The root file. Missing means no root agents; anything else wrong refuses. The same descriptor
// checks as the organization file: no symbolic link, owned by this uid, not group or other writable.
export function loadRootAgents(configDir, { rolesDir } = {}) {
const file = rootAgentsPath(configDir);
let document;
try {
document = readJsonFile(file, "root agent file", (stat) => {
if (stat.uid !== process.getuid()) refuse(`root agent file must belong to uid ${process.getuid()}`);
if ((stat.mode & 0o022) !== 0) refuse(`root agent file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)})`);
});
} catch (error) {
if (error?.name !== "BusinessError") throw error;
if (error.exitCode === 4 && /not found/.test(error.message)) return deepFreeze({ state: "absent", agents: {}, assistant: null });
// The read's messages carry the host path; the Console shows this one, so it names the file only.
// A parse refusal also quotes the parser, which can quote the file's bytes: say only that it is not JSON.
if (/ is not valid JSON /.test(error.message)) refuse(`root agent file is not valid JSON (${ROOT_FILE})`, error.exitCode);
refuse(error.message.replaceAll(file, ROOT_FILE), error.exitCode);
}
try { return deepFreeze({ state: "ok", ...checkRootAgents(document, { rolesDir }) }); }
catch (error) {
// An id check quotes what it was given; drop the quote here too, so nothing typed into the file
// reaches the Console through a refusal.
if (error?.name !== "BusinessError") throw error;
refuse(error.message.replace(/ \(got [\s\S]*\)$/, ""), error.exitCode);
}
}
const INTERSECT = Object.keys(REGISTRY).filter((key) => REGISTRY[key].merge === "intersect");
// One project's `agents` map. Each entry is exactly one of define, narrow or exclude.
export function checkProjectAgents(agents, { rolesDir, where }) {
requireObject(agents, where);
const cache = new Map();
const out = {};
for (const [name, entry] of Object.entries(agents)) {
const at = `${where}.${name}`;
requireId(name, `${where} agent name`);
requireObject(entry, at);
noBoundKeys(entry, at.replace(/^.*?projects\./, "project "));
rejectUnknownKeys(entry, ["definition", "vars", "exclude"], at);
if (Object.hasOwn(entry, "exclude")) {
if (entry.exclude !== true || Object.keys(entry).length !== 1) refuse(`${at}: an exclude entry is exactly {"exclude": true}`);
out[name] = { form: "exclude" };
continue;
}
if (Object.hasOwn(entry, "definition")) {
const definition = definitionOf(entry.definition, at, rolesDir, cache);
if (entry.definition === CHIEF || entry.definition === ASSISTANT) refuse(`${at}: ${entry.definition} can't be a project agent`);
out[name] = { form: "define", definition: entry.definition, title: definition.title, vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`) };
continue;
}
if (!isPlainObject(entry.vars) || Object.keys(entry.vars).length === 0) refuse(`${at}: an entry defines (definition), narrows (vars) or excludes (exclude)`);
// A project narrows an inherited agent; it never widens one, so only intersect keys.
for (const key of Object.keys(entry.vars)) {
if (!INTERSECT.includes(key)) refuse(`${at}.vars: a project can only narrow ${INTERSECT.join(", ")}, not ${JSON.stringify(key)}`);
}
out[name] = { form: "narrow", vars: checkVars(entry.vars, "project", `${at}.vars`) };
}
return deepFreeze(out);
}
// The organization's levels from its raw document (already validated by the organization
// loader): its own instances and each project's entries. Library projects take no agents.
function orgLevels(doc, rolesDir) {
const org = {};
for (const [name, entry] of Object.entries(doc?.roles ?? {})) {
org[name] = { definition: entry.definition, vars: entry.vars ?? {} };
}
const projects = {};
for (const [id, entry] of Object.entries(doc?.projects ?? {})) {
if (entry?.kind === "library") continue;
projects[id] = entry?.agents === undefined ? {} : checkProjectAgents(entry.agents, { rolesDir, where: `projects.${id}.agents` });
}
return { org, projects };
}
// The agents available in project `project` of organization `orgId`: the union of the root
// agents (minus those excluded for this organization), the organization's instances and the
// project's entries. Refuses when a project entry names nothing it could act on, or touches the
// chief or the assistant.
export function projectUnion({ orgId, root, org, project, projectId }) {
requireId(orgId, "organization id");
const agents = new Map();
const excluded = [];
for (const [name, entry] of Object.entries(root.agents)) {
if (entry.definition === ASSISTANT) continue; // the root assistant is not one organization's
if (entry.excludeOrgs.includes(orgId)) { excluded.push({ name, level: "root", by: "root" }); continue; }
agents.set(name, { name, definition: entry.definition, level: "root", vars: entry.vars, overrides: [], narrowed: [] });
}
for (const [name, entry] of Object.entries(org)) {
const shadowed = agents.get(name);
agents.set(name, { name, definition: entry.definition, level: "org", vars: entry.vars, overrides: shadowed ? [{ level: shadowed.level, definition: shadowed.definition }] : [], narrowed: [] });
}
if (projectId !== undefined) {
for (const [name, entry] of Object.entries(project ?? {})) {
const inherited = agents.get(name);
const at = `projects.${projectId}.agents.${name}`;
if (inherited && (inherited.definition === CHIEF || inherited.definition === ASSISTANT)) refuse(`${at}: a project can't change the organization's ${inherited.definition}`);
if (entry.form === "define") {
agents.set(name, { name, definition: entry.definition, level: "project", project: projectId, vars: entry.vars,
overrides: inherited ? [...inherited.overrides, { level: inherited.level, definition: inherited.definition }] : [], narrowed: [] });
continue;
}
if (!inherited) refuse(`${at}: names no root or organization agent to ${entry.form}`);
if (entry.form === "exclude") { agents.delete(name); excluded.push({ name, level: inherited.level, by: "project" }); continue; }
// mergeVars applies the intersect rule; its registry defaults are not this agent's, so only
// the keys either side set are kept.
const merged = mergeVars([{ source: inherited.level, vars: inherited.vars }, { source: "project", vars: entry.vars }]).vars;
const keys = new Set([...Object.keys(inherited.vars), ...Object.keys(entry.vars)]);
const vars = Object.fromEntries(Object.entries(merged).filter(([key]) => keys.has(key)));
agents.set(name, { ...inherited, vars, narrowed: Object.keys(entry.vars) });
}
}
// Every agent in a union works inside this organization, an organization's own `assistant`
// instance included. Only the root assistant, which no union holds, works across organizations.
const list = [...agents.values()].map((a) => ({ ...a, scope: { kind: "org", org: orgId } }));
const order = { root: 0, org: 1, project: 2 };
list.sort((a, b) => order[a.level] - order[b.level] || a.name.localeCompare(b.name));
// A root agent turned off here, or excluded by the project, whose name a more specific level then
// defines is available under that definition, so it is not reported as excluded.
return deepFreeze({ org: orgId, project: projectId ?? null, agents: list, excluded: excluded.filter((e) => !agents.has(e.name)) });
}
// Every level for one organization, read from its validated document: the org instances, each
// project's raw entries and each project's union. One project that refuses refuses only itself.
export function resolveAgentLevels({ orgId, doc, root, rolesDir }) {
const { org, projects } = orgLevels(doc, rolesDir);
const unions = {};
for (const [projectId, entries] of Object.entries(projects)) {
try { unions[projectId] = { ok: true, ...projectUnion({ orgId, root, org, project: entries, projectId }) }; }
catch (error) { if (error?.name !== "BusinessError") throw error; unions[projectId] = { ok: false, reason: error.message }; }
}
return deepFreeze({ org: orgId, base: projectUnion({ orgId, root, org }), projects, unions });
}
+3
View File
@@ -19,3 +19,6 @@ export { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "./acces
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
export { loadSeatSets } from "./library-seats.mjs";
export {
AGENT_LEVELS, rootAgentsPath, checkRootAgents, loadRootAgents, checkProjectAgents, projectUnion, resolveAgentLevels,
} from "./agent-levels.mjs";
@@ -128,7 +128,7 @@ export function checkRoutes({ proposed, old = null, levels, catalogue = null })
for (const [harness, model, thinking] of ROUTES) {
// A missing model refuses no-route at launch, not here.
if (proposed.launch && Object.hasOwn(vars, model) && !Object.hasOwn(proposed.launch.models, vars[model])) {
throw new OrganizationError("unknown-model", `${instance} ${model} ${JSON.stringify(vars[model] ?? null)} isn't a launch.models key`);
throw new OrganizationError("unknown-model", `${instance} variable ${model} isn't a launch.models key`);
}
const currentEntry = proposed.launch?.models?.[vars[model]];
const previousEntry = old?.launch?.models?.[before[model]];
@@ -149,12 +149,12 @@ export function checkRoutes({ proposed, old = null, levels, catalogue = null })
}
if (!Object.hasOwn(vars, harness) || (vars[harness] === before[harness] && vars[thinking] === before[thinking])) continue;
const name = vars[harness];
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} ${harness} ${name} isn't switched on in plugins.harness`);
if (!on.includes(name)) throw new OrganizationError("harness-off", `${instance} variable ${harness} isn't switched on in plugins.harness`);
if (vars[thinking] === undefined) continue;
const applies = levels(name);
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `harness module ${name} didn't load, so its thinking levels are unknown`);
if (!Array.isArray(applies)) throw new OrganizationError("harness-refused", `${instance} variable ${harness}: its harness module didn't load, so its thinking levels are unknown`);
if (!applies.includes(vars[thinking])) {
throw new OrganizationError("thinking-unsupported", `harness ${name} doesn't apply thinking ${vars[thinking]} (${instance} ${thinking}); it applies ${applies.join(", ")}`);
throw new OrganizationError("thinking-unsupported", `${instance} variable ${thinking}: its harness doesn't apply this thinking level; it applies ${applies.join(", ")}`);
}
}
}
@@ -374,7 +374,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
const entry = offered ? { provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream } : null;
if (!existing && !offered && catalogue?.reason === 'model-key-collision') throw new OrganizationError('model-key-collision');
if (!existing && !offered && catalogue?.status === 'unavailable') throw new OrganizationError('model-catalogue-unavailable');
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} model isn't a launch.models key`);
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} variable ${prefix}model isn't a launch.models key`);
if (existing && offered && canonicalJson(existing) !== canonicalJson(entry) && !unchanged) throw new OrganizationError('model-key-collision');
if (!existing || existing.module) {
if (unchanged) continue;
+17 -2
View File
@@ -36,14 +36,29 @@ export async function readRegistry({ configDir }) {
return validateRegistry(doc, file);
}
export async function listOrgs(ctx) { const doc = await readRegistry(ctx); return doc ? Object.values(doc.orgs) : []; }
const heldRegistries = new WeakMap();
// Only a live withRegistry document may be reused by a later accounts-lock
// holder. Recheck the persisted snapshot without reacquiring this lock.
export async function readRegistryLocked(registry, ctx) {
const held = heldRegistries.get(registry);
if (!held) fail('org-locked', 'a live registry lock document is required');
if (ctx && (ctx.configDir !== held.ctx.configDir || ctx.instance !== held.ctx.instance)) fail('org-mismatch', 'registry lock belongs to another instance');
const current = await readRegistry(held.ctx);
if (JSON.stringify(current) !== held.persisted) fail('registry-changed', 'registry-changed before accounts replace');
return validateRegistry(registry, join(held.ctx.configDir, 'org-runtime.json'));
}
export async function withRegistry(ctx, fn, { write = true } = {}) {
await safeDir(ctx.configDir); await privateDir(ctx.configDir); const lock = join(ctx.configDir, 'org-runtime.lock'); let fd;
for (let n = 0; n < 200; n++) { try { fd = await fs.open(lock, 'wx', 0o600); break; } catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); } }
if (!fd) fail('org-locked', 'registry lock is held', lock);
try {
const registry = await readRegistry(ctx) ?? { version: 1, instance: ctx.instance, orgs: {} };
const persisted = await readRegistry(ctx);
const registry = persisted ?? { version: 1, instance: ctx.instance, orgs: {} };
if (registry.instance !== ctx.instance) fail('org-mismatch', 'registry instance mismatch');
const result = await fn(registry);
heldRegistries.set(registry, { ctx, persisted: JSON.stringify(persisted) });
let result;
try { result = await fn(registry); }
finally { heldRegistries.delete(registry); }
if (write) {
const file = join(ctx.configDir, 'org-runtime.json'); const temp = join(ctx.configDir, `.org-runtime-${randomBytes(12).toString('hex')}`);
try { await fs.writeFile(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); await fs.rename(temp, file); } finally { await fs.rm(temp, { force: true }); }
+129
View File
@@ -0,0 +1,129 @@
// Accounts design draft 3, section 3 / M1 (#1873). Pure org policy only:
// these tables do not grant access, resolve ownership, or inspect a caller.
const record = entries => Object.freeze(Object.assign(Object.create(null), entries));
const flag = grantable => Object.freeze({ grantable });
export const RIGHTS = record({
'org.view': flag(true),
'thread.read': flag(true),
'thread.write': flag(true),
'board.read': flag(true),
'task.write': flag(true),
decide: flag(true),
'request.write': flag(false),
'members.manage': flag(false),
'org.setup': flag(false),
'org.lifecycle': flag(false),
});
// Templates are expanded into stored rights, never evaluated as live roles.
// Only owner.json confers ownership; the owner bundle is never grantable.
export const ROLE_RIGHTS = record({
owner: Object.freeze(Object.keys(RIGHTS).sort()),
member: Object.freeze(['board.read', 'org.view', 'thread.read']),
collaborator: Object.freeze(['board.read', 'org.view', 'task.write', 'thread.read', 'thread.write']),
approver: Object.freeze(['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write']),
});
// A reserved right still names the owner's operation. Null marks an explicit
// owner-only operation with no member right. An unknown verb yields undefined.
// Null-prototype records keep inherited property names from becoming rights.
// M5 must separately exclude credential.mint from decide and assignees from
// member task writes; these names alone cannot express action/field restrictions.
export const VERB_RIGHTS = record({
'project.list': 'org.view',
names: 'org.view',
'thread.list': 'thread.read',
'thread.messages': 'thread.read',
plugins: 'thread.read',
'thread.open': 'thread.write',
'thread.create': 'thread.write',
'thread.send': 'thread.write',
'thread.share': 'thread.write',
'thread.unshare': 'thread.write',
'thread.shares': 'thread.write',
inbox: 'board.read',
agents: 'board.read',
'thread.claims': 'board.read',
tasks: 'board.read',
decisions: 'board.read',
usage: 'board.read',
limits: 'board.read',
'request.get.console': 'board.read',
'request.projects.console': 'board.read',
'request.artifact.console': 'board.read',
'request.workspace.console': 'board.read',
trail: null,
credentials: null,
replies: null,
messages: null,
state: null,
'tasks.create': 'task.write',
'tasks.edit': 'task.write',
'tasks.archive': 'task.write',
'tasks.unarchive': 'task.write',
'tasks.delete': null,
'decision.preview.console': 'decide',
'decision.resolve.console': 'decide',
'request.answer.console': 'request.write',
'request.attach.console': 'request.write',
'request.cancel.console': 'request.write',
'request.mode.console': 'request.write',
'request.draft.console': 'request.write',
'prd.revoke.console': 'request.write',
'plugins.set': 'org.setup',
'system-types.console': 'org.setup',
'project.remote.get.console': 'org.setup',
'project.remote.set.console': 'org.setup',
});
function refuse(code) {
const error = new Error(code);
error.code = code;
throw error;
}
const known = right => typeof right === 'string' && Object.hasOwn(RIGHTS, right);
// Only thread writes need the collaborator scope level. Unknown rights must
// refuse before the viewer fallback can run.
export function rightLevel(right) {
if (!known(right)) refuse('unknown-right');
return right === 'thread.write' ? 'collaborator' : 'viewer';
}
function memberRights(rights) {
if (!Array.isArray(rights)) refuse('invalid-rights');
for (const right of rights) {
if (right === 'owner') refuse('right-not-grantable');
if (!known(right)) refuse('unknown-right');
if (!RIGHTS[right].grantable) refuse('right-not-grantable');
}
}
// Normalize a membership write; never mutate its current rights or the role
// template. Explicit removal of the compulsory org.view right always refuses.
// When add and remove overlap, removal wins (except org.view, which refuses).
export function normalizeRights(rights, options = {}) {
if (options === null || typeof options !== 'object' || Array.isArray(options) ||
Object.keys(options).some(key => !['add', 'remove'].includes(key))) refuse('invalid-rights');
const add = Object.hasOwn(options, 'add') ? options.add : [];
const remove = Object.hasOwn(options, 'remove') ? options.remove : [];
memberRights(rights);
memberRights(add);
memberRights(remove);
if (remove.includes('org.view')) refuse('right-not-removable');
const result = new Set(['org.view', ...rights, ...add]);
for (const right of remove) result.delete(right);
return Object.freeze([...result].sort());
}
// Subset check only: the caller must independently establish the fresh owner's
// members.manage authority. A malformed or unknown granter set grants nothing.
export function canGrant(granterRights, rights) {
if (!Array.isArray(granterRights) || !Array.isArray(rights)) return false;
for (const right of granterRights) if (!known(right)) return false;
for (const right of rights) {
if (!known(right) || !RIGHTS[right].grantable || !granterRights.includes(right)) return false;
}
return true;
}
@@ -16,6 +16,7 @@ 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";
import { checkProjectAgents } from "./agent-levels.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs";
@@ -204,7 +205,7 @@ function checkLaunch(launch, roles, definitions, file) {
for (const name of Object.keys(limits.tokens?.daily ?? {})) {
if (name !== account?.name) refuse(`${where}.limits.tokens.daily names ${name}, which isn't ${where}.account.name`);
}
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`);
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} variable model selects an unknown model`);
// No upper bound: limits have no ceiling in code (Q150).
const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`);
// Row 194 (#1692, D2.4, D2.5): the human's per-agent forbid. keep names declared instances the
@@ -279,7 +280,10 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
projects[id] = checkMapping(entry, `${file} projects.${id}`, { fail: (code, message) => refuse(`${code}: ${message}`) });
continue;
}
rejectUnknownKeys(entry, ["root", "repo", "remote", "git"], `${file} projects.${id}`);
rejectUnknownKeys(entry, ["root", "repo", "remote", "git", "agents"], `${file} projects.${id}`);
// #1873 A1: the project's own agent entries. Checked so a bad entry refuses the whole file, but
// not copied below: nothing a bus loads sees them; only the Console's agent-levels read does.
if (Object.hasOwn(entry, "agents")) checkProjectAgents(entry.agents, { rolesDir, where: `projects.${id}.agents` });
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
}
+27 -13
View File
@@ -16,7 +16,7 @@
import { refuse } from "./errors.mjs";
import { ACTIONS, NETWORKS, TOOLS } from "./vocabulary.mjs";
import { isPlainObject, requireDistinctList, requirePositiveInt, requireString } from "./util.mjs";
import { isPlainObject, requireString } from "./util.mjs";
import { NAME as MODULE_NAME } from "../../plugins/src/kinds.mjs";
export const LAYERS = Object.freeze(["system", "business", "project", "agent"]);
@@ -29,7 +29,7 @@ function string(max = 200) {
function oneOf(values) {
return (v, key) => {
if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")} (got ${JSON.stringify(v)})`);
if (!values.includes(v)) refuse(`variable ${key} must be one of: ${values.join(", ")}`);
return v;
};
}
@@ -37,13 +37,13 @@ function oneOf(values) {
// A harness plug-in module's name, in the loader's pattern (row 65, REQ-PLUG-2). Whether the
// module exists or is switched on is checked at launch and at a write, never at load.
function moduleName(v, key) {
if (typeof v !== "string" || !MODULE_NAME.test(v)) refuse(`variable ${key} must be a harness module name (got ${JSON.stringify(v)})`);
if (typeof v !== "string" || !MODULE_NAME.test(v)) refuse(`variable ${key} must be a harness module name`);
return v;
}
function integer(min) {
return (v, key) => {
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min} (got ${JSON.stringify(v)})`);
if (!Number.isSafeInteger(v) || v < min) refuse(`variable ${key} must be an integer of at least ${min}`);
return v;
};
}
@@ -55,7 +55,7 @@ function baseUrl(v, key) {
try {
url = new URL(v);
} catch {
refuse(`variable ${key} must be an http or https URL (got ${JSON.stringify(v)})`);
refuse(`variable ${key} must be an http or https URL`);
}
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || v.endsWith("/")) {
refuse(`variable ${key} must be an http or https base URL with no credentials, query, fragment or trailing slash`);
@@ -70,21 +70,31 @@ function searxngUrl(v, key) {
baseUrl(v, key);
const url = new URL(v);
const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
if (url.protocol === "http:" && !loopback) refuse(`variable ${key} must be https, or http on loopback (got ${JSON.stringify(v)})`);
if (url.protocol === "http:" && !loopback) refuse(`variable ${key} must be https, or http on loopback`);
return v;
}
function branch(v, key) {
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name (got ${JSON.stringify(v)})`);
if (typeof v !== "string" || !BRANCH.test(v)) refuse(`variable ${key} must be a git branch name`);
return v;
}
function list(check, { nonEmpty = true } = {}) {
return (v, key) => requireDistinctList(v, `variable ${key}`, (item) => check(item, `${key} entry`), { nonEmpty });
return (v, key) => {
if (!Array.isArray(v)) refuse(`variable ${key} must be an array`);
if (nonEmpty && v.length === 0) refuse(`variable ${key} must not be empty`);
const seen = new Set();
for (const item of v) {
check(item, `${key} entry`);
if (seen.has(item)) refuse(`duplicate entry in variable ${key}`);
seen.add(item);
}
return [...seen];
};
}
function boolean(v, key) {
if (typeof v !== "boolean") refuse(`variable ${key} must be true or false (got ${JSON.stringify(v)})`);
if (typeof v !== "boolean") refuse(`variable ${key} must be true or false`);
return v;
}
@@ -125,7 +135,10 @@ export const REGISTRY = Object.freeze({
"bus.tester": def(["business"], boolean, { default: false }),
// Project.
"tracker.project": def(["project"], (v, key) => requirePositiveInt(v, `variable ${key}`)),
"tracker.project": def(["project"], (v, key) => {
if (!Number.isSafeInteger(v) || v < 1) refuse(`variable ${key} must be a positive integer`);
return v;
}),
// On: the tracker project is the root of a tree, and sync reads its descendants too.
"tracker.descendants": def(["project"], boolean, { default: false }),
"git.workingBranch": def(["project"], branch),
@@ -161,16 +174,17 @@ export const REGISTRY = Object.freeze({
// Limits narrow the role definition's ceilings. limits.authority is an
// allowlist: a role action it doesn't name becomes gated.
"limits.tools": def(["business", "project", "agent"], list((v, key) => {
if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool: ${JSON.stringify(v)}`);
if (!TOOLS.includes(v)) refuse(`variable ${key} names an unsupported tool`);
}, { nonEmpty: false }), { merge: "intersect" }),
"limits.network": def(["business", "project", "agent"], oneOf(NETWORKS), { merge: "intersect" }),
"limits.authority": def(["business", "project", "agent"], list((v, key) => {
if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action: ${JSON.stringify(v)}`);
if (!ACTIONS.includes(v)) refuse(`variable ${key} names an unknown action`);
}, { nonEmpty: false }), { merge: "intersect" }),
});
// Check one layer's vars object. `where` names the file and path for the
// message. Returns a frozen copy with checked values.
// message. Returns a frozen copy with checked values. Validators name the key
// and the rule, never a value: refusals can reach the Console and the trail.
export function checkVars(vars, layer, where) {
if (!LAYERS.includes(layer)) throw new Error(`unknown layer ${layer}`);
if (!isPlainObject(vars)) refuse(`${where} must be a JSON object`);
@@ -0,0 +1,180 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { checkAccounts, loadAccounts, accountsRepairs, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs';
import { templateRender } from '../src/org-service.mjs';
import { loadBusiness } from '../src/organization-config.mjs';
import { REPO_ROLES } from './helpers.mjs';
import { withRegistry, readRegistryLocked } from '../src/org-registry.mjs';
export const OP = 'u-000000000001', USER = 'u-000000000002';
export const context = { providers: [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }] }] };
export const user = (id = OP, sub = 'owner', instanceRole = 'operator') => ({ id, subjects: [{ provider: 'pocket', sub }], name: 'Person', instanceRole, state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' });
export const member = () => ({ org: 'acme', incarnation: 'a'.repeat(32), user: USER, role: 'member', rights: ['board.read', 'org.view', 'thread.read'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, added: '2026-11-10T00:00:00Z', by: OP, via: 'invite:abc' });
export const document = () => ({ accountsVersion: 1, revision: 1, users: [user(), user(USER, 'member', 'user')], memberships: [], invites: [] });
function setup(t, doc = document()) {
const dir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(dir, 0o700); t.after(() => rmSync(dir, { recursive: true, force: true }));
const file = join(dir, 'accounts.json'); writeFileSync(file, JSON.stringify(doc), { mode: 0o600 });
return { configDir: dir, instance: 'test', ...context, file };
}
const write = (ctx, revision, fn, rest = {}) => withAccounts(ctx, { revision, verb: 'user.signout', ...rest }, fn);
test('loader refuses every malformed document as a whole, including nested unknown keys', t => {
const cases = [d => d.accountsVersion = 2, d => d.revision = 0, d => d.cookie = 'secret', d => d.users[0].id = 'human', d => d.users[0].authGen = 0, d => d.users[0].authGen = 1.5, d => d.users[0].instanceRole = 'owner', d => d.users[0].state = 'disabled', d => d.users[0].subjects[0].provider = 'local-dev', d => d.users[0].subjects[0].provider = 'missing', d => d.users[1].subjects = d.users[0].subjects, d => d.users[0].subjects[0].iss = 'https://evil', d => d.users[0].created = '2026-02-30T00:00:00Z', d => d.memberships.push({}), d => { const m = member(); m.rights.push('org.setup'); d.memberships.push(m); }, d => { const m = member(); m.rights.reverse(); d.memberships.push(m); }, d => { const m = member(); m.scope.instances = ['*', 'coder']; d.memberships.push(m); }, d => { const m = member(); m.user = 'u-000000000003'; d.memberships.push(m); }];
for (const mutate of cases) { const d = document(); mutate(d); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
assert.throws(() => checkAccounts(document(), { providers: [{ id: 'pocket', issuer: 'local-dev' }] }));
const s = setup(t); assert.throws(() => loadAccounts(s.file, s, { uid: process.getuid() + 1 }), /uid/);
for (const mode of [0o620, 0o602]) { chmodSync(s.file, mode); assert.throws(() => loadAccounts(s.file, s), /writable/); }
chmodSync(s.file, 0o600); const target = join(s.configDir, 'real'); writeFileSync(target, JSON.stringify(document()), { mode: 0o600 }); unlinkSync(s.file); symlinkSync(target, s.file); assert.throws(() => loadAccounts(s.file, s), /symbolic/);
});
test('unknown role and right load as repair rows; account may be a business human', () => {
const d = document(); const m = member(); m.role = 'future'; m.rights.push('zz.future'); d.memberships.push(m);
const loaded = checkAccounts(d, { ...context, businesses: { acme: { human: OP } } });
assert.equal(loaded.users[0].id, OP); assert.ok(Object.isFrozen(loaded.users[0]));
assert.deepEqual(accountsRepairs(loaded), [{ kind: 'membership', org: 'acme', user: USER, unknownRole: 'future', unknownRights: ['zz.future'] }]);
});
test('writes refuse stale revision, unknown role/right and reserved rights without a receipt', async t => {
const s = setup(t); const before = readFileSync(s.file, 'utf8');
await assert.rejects(write(s, 9, () => {}), /revision-conflict/);
for (const change of [m => m.role = 'future', m => m.rights.push('zz.future'), m => m.rights.push('org.setup')]) {
await assert.rejects(write(s, 1, d => { const m = member(); change(m); d.memberships.push(m); }));
}
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(existsSync(join(s.configDir, 'accounts-receipts.jsonl')), false);
});
test('rights/scope revisions and security generations are maintained by the writer', async t => {
const d = document(); d.memberships.push(member()); const s = setup(t, d);
let rev = 1;
for (const change of [u => u.state = 'disabled', u => u.state = 'active', u => u.instanceRole = 'operator', u => u.subjects[0].sub = 'new-sub']) {
const result = await write(s, rev++, next => change(next.users[1])); assert.equal(result.users[1].authGen, rev);
}
let result = await write(s, rev++, next => next.users[1].authGen++); assert.equal(result.users[1].authGen, rev);
result = await write(s, rev++, next => next.memberships[0].rights.push('task.write')); assert.equal(result.memberships[0].rev, 2);
result = await write(s, rev++, next => next.memberships[0].scope.instances = ['coder']); assert.equal(result.memberships[0].rev, 3);
assert.equal(result.users[1].authGen, 6);
const lines = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(lines.length, 7); for (const r of lines) { assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'user.signout'); }
assert.equal(statSync(s.file).mode & 0o777, 0o600);
});
test('concurrent writes serialize and the later stale write changes nothing', async t => {
const s = setup(t); let release, entered; const ready = new Promise(r => entered = r); const wait = new Promise(r => release = r);
const first = write(s, 1, async d => { entered(); await wait; d.users[1].authGen++; }); await ready;
const second = write(s, 1, d => { d.users[1].authGen++; }); release();
const results = await Promise.allSettled([first, second]); assert.equal(results[0].status, 'fulfilled'); assert.equal(results[1].status, 'rejected'); assert.match(results[1].reason.message, /revision-conflict/);
assert.equal(loadAccounts(s.file, s).revision, 2);
});
test('locked helpers require live handles; registry dependency is rechecked before replace', async t => {
const s = setup(t); let held;
await assert.rejects(readRegistryLocked({}), /lock/);
await assert.rejects(withAccountsLocked({}, { revision: 1, verb: 'user.signout' }, () => {}), /lock/);
await withRegistry(s, async registry => {
assert.equal(await readRegistryLocked(registry), registry);
await withAccountsLock(s, async handle => { held = handle; await withAccountsLocked(handle, { revision: 1, verb: 'user.signout', registry }, d => d.users[1].authGen++); });
});
await assert.rejects(withAccountsLocked(held, { revision: 2, verb: 'user.signout' }, () => {}), /lock/);
const before = readFileSync(s.file, 'utf8');
await withRegistry(s, async registry => {
await assert.rejects(write(s, 2, d => { d.users[1].authGen++; writeFileSync(join(s.configDir, 'org-runtime.json'), JSON.stringify({ version: 1, instance: 'test', orgs: { changed: { id: 'changed', root: '/elsewhere', state: 'active', revision: 1, created: 'now' } } }), { mode: 0o600 }); }, { registry }), /registry-changed/);
}, { write: false });
assert.equal(readFileSync(s.file, 'utf8'), before);
});
test('failure before replace leaves old bytes, no receipt line and no temporary file', async t => {
const s = setup(t); const before = readFileSync(s.file, 'utf8');
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { throw new Error('injected failure'); } }), /injected failure/);
assert.equal(readFileSync(s.file, 'utf8'), before); assert.deepEqual(readdirSync(s.configDir).filter(f => f.startsWith('.accounts-')), []); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
});
test('receipts never serialize subject secrets, tokens, cookies, names or arbitrary actor text', async t => {
const s = setup(t); const secret = 'subject-secret';
await write(s, 1, d => { d.users[1].subjects[0].sub = secret; d.users[1].name = 'cookie-secret'; }, { actor: OP });
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(secret)); assert.ok(!raw.includes('cookie-secret'));
await assert.rejects(write(s, 2, () => {}, { verb: 'token-secret' }));
await assert.rejects(write(s, 2, () => {}, { actor: 'cookie-secret' }));
});
test('malformed membership rows refuse all of the file; reserved rights never load', () => {
const mutations = [m => m.role = 'owner', m => m.rev = 0, m => m.incarnation = 'old', m => m.rights = ['thread.read'], m => m.rights = ['org.view', 'org.view'], m => m.scope = { projects: [], instances: ['*'] }, m => m.scope.projects = ['not-a-uuid'], m => m.scope.expires = 'not-a-time', m => m.extra = true, m => m.by = 'human', m => m.via = '', m => m.added = 'bad'];
for (const mutate of mutations) { const d = document(), m = member(); mutate(m); d.memberships.push(m); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
for (const right of ['members.manage', 'org.setup', 'org.lifecycle', 'request.write', 'owner']) { const d = document(), m = member(); m.rights = ['org.view', right].sort(); d.memberships.push(m); assert.throws(() => checkAccounts(d, context)); }
});
function invitation() {
const { user: ignored, rev, added, via, ...row } = member();
return { ...row, id: '11111111-2222-3333-4444-555555555555', bind: { email: '[email protected]' }, tokenHash: 'b'.repeat(64), created: '2026-11-10T00:00:00Z', expires: '2026-11-13T00:00:00Z', state: 'open' };
}
test('invite schema refuses raw secrets, domain/bearer bindings, malformed hashes and unknown fields', () => {
const d = document(); d.invites.push(invitation()); assert.equal(checkAccounts(d, context).invites.length, 1);
for (const mutate of [i => i.token = 'raw-token', i => i.cookie = 'raw-cookie', i => i.bind = { domain: 'example.com' }, i => i.bind = {}, i => i.bind = { email: '@example.com' }, i => i.bind.sub = 'extra-binding', i => i.tokenHash = 'raw-token', i => i.state = 'other', i => i.rights = ['org.setup', 'org.view'], i => i.scope.level = 'collaborator']) {
const next = structuredClone(d); mutate(next.invites[0]); assert.throws(() => checkAccounts(next, context));
}
d.invites[0].role = 'future'; d.invites[0].rights.push('zz.future'); assert.equal(accountsRepairs(checkAccounts(d, context))[0].kind, 'invite');
});
test('unsafe receipt destination refuses before publication and leaves its target intact', async t => {
const s = setup(t), target = join(s.configDir, 'target'); writeFileSync(target, 'untouched', { mode: 0o600 }); symlinkSync(target, join(s.configDir, 'accounts-receipts.jsonl'));
const before = readFileSync(s.file, 'utf8'); await assert.rejects(write(s, 1, d => d.users[1].authGen++)); assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(target, 'utf8'), 'untouched');
});
test('account lock allows sequential writes and registry handles cannot cross instance boundaries', async t => {
const s = setup(t), other = setup(t);
await withAccountsLock(s, async handle => {
await withAccountsLocked(handle, { revision: 1, verb: 'user.signout' }, d => d.users[1].authGen++);
await withAccountsLocked(handle, { revision: 2, verb: 'user.signout' }, d => d.users[1].authGen++);
});
await withRegistry(other, async registry => { await assert.rejects(write(s, 3, () => {}, { registry }), /another instance/); });
assert.equal(loadAccounts(s.file, s).revision, 3);
});
test('invite token hashes and subject bindings never reach receipts', async t => {
const s = setup(t); const invite = invitation(); invite.bind = { provider: 'pocket', sub: 'subject-secret' };
await write(s, 1, d => d.invites.push(invite), { verb: 'invite.create' });
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(invite.tokenHash)); assert.ok(!raw.includes('subject-secret')); assert.equal(JSON.parse(raw).verb, 'invite.create');
});
test('a failed rename preserves published bytes and appends no receipt', async t => {
const s = setup(t), before = readFileSync(s.file, 'utf8');
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { const temp = readdirSync(s.configDir).find(f => f.startsWith('.accounts-')); unlinkSync(join(s.configDir, temp)); } }), { code: 'ENOENT' });
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), ''); assert.ok(!existsSync(join(s.configDir, 'accounts.lock')));
});
test('review P2-1: rollback rows survive unrelated disable/signout but cannot be added or edited', async t => {
const d = document(), m = member(), invite = invitation(); m.role = 'future'; m.rights.push('zz.future'); invite.rights.push('zz.future'); d.memberships.push(m); d.invites.push(invite); const s = setup(t, d);
const disabled = await write(s, 1, next => next.users[1].state = 'disabled', { verb: 'user.disable' });
assert.deepEqual(disabled.memberships, d.memberships); assert.deepEqual(disabled.invites, d.invites);
const signedOut = await write(s, 2, next => next.users[0].authGen++); assert.deepEqual(signedOut.memberships, d.memberships); assert.equal(signedOut.users[0].authGen, 2);
await assert.rejects(write(s, 3, next => next.memberships[0].scope.instances = ['coder']), /unknown/);
await assert.rejects(write(s, 3, next => next.invites[0].state = 'revoked'), /unknown/);
const repaired = await write(s, 3, next => { next.memberships[0].role = 'member'; next.memberships[0].rights = ['org.view']; }); assert.equal(repaired.memberships[0].rev, 2); assert.deepEqual(repaired.invites, d.invites);
});
test('review P3-1: control and bidi characters refuse in names, subjects and provenance', () => {
for (const control of ['\x1b', '\x07', '\r', '\n', '\x7f', '\u0085', '\u202e', '\u2066']) for (const field of ['name', 'sub', 'via']) {
const d = document(); d.memberships.push(member()); if (field === 'via') d.memberships[0].via += control; else if (field === 'sub') d.users[1].subjects[0].sub += control; else d.users[1].name += control;
assert.throws(() => checkAccounts(d, context));
}
});
test('review P3-2: invite validity is between one and 168 hours inclusive', () => {
for (const hours of [0.5, 169, 87600]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.throws(() => checkAccounts(d, context)); }
for (const hours of [1, 168]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.equal(checkAccounts(d, context).invites.length, 1); }
});
test('review P3-3: receipts record actual changed field names even for a misleading verb', async t => {
const s = setup(t); await write(s, 1, d => d.users[1].instanceRole = 'operator');
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.deepEqual(r.userChanges, [{ id: USER, changed: ['instanceRole', 'authGen'] }]);
});
test('review P3-4: directory durability failure reports committed state and appends no success receipt', async t => {
const s = setup(t); let error;
try { await write(s, 1, d => d.users[1].authGen++, { syncDirectory: async () => { throw new Error('injected directory sync failure'); } }); } catch (e) { error = e; }
assert.match(error?.message ?? '', /injected directory sync failure/); assert.equal(error.committed, true); assert.equal(loadAccounts(s.file, s).revision, 2); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
});
test('review P3-5: init cannot clobber a file created after its last absence check', async t => {
const s = setup(t); unlinkSync(s.file); const intruder = 'concurrent file';
await assert.rejects(withAccounts(s, { revision: 0, verb: 'init', init: true, beforeReplace: () => writeFileSync(s.file, intruder, { mode: 0o600 }) }, d => d.users.push(user())), { code: 'EEXIST' });
assert.equal(readFileSync(s.file, 'utf8'), intruder); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
});
test('review P3-6: a real business file and accounts file load with the same human id', async t => {
const s = setup(t), doc = await templateRender('acme'); doc.human = OP; mkdirSync(join(s.configDir, 'businesses')); writeFileSync(join(s.configDir, 'businesses', 'acme.json'), JSON.stringify(doc), { mode: 0o600 });
assert.equal(loadBusiness('acme', { dir: s.configDir, rolesDir: REPO_ROLES }).human, OP); assert.equal(loadAccounts(s.file, s).users[0].id, OP);
});
@@ -0,0 +1,194 @@
// #1873 Phase A slice A1: the three agent levels and their union (design
// docs/plans/2026-11-10_agent-levels-design.md sections 2, 3 and 6). Read-only: nothing here writes.
import test from "node:test";
import assert from "node:assert/strict";
import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
checkProjectAgents, checkRootAgents, loadRootAgents, projectUnion, resolveAgentLevels, validateOrganizationDocument,
} from "../src/index.mjs";
import { businessDoc, REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
const rolesDir = REPO_ROLES;
const NONE = Object.freeze({ state: "absent", agents: {}, assistant: null });
const root = (agents) => checkRootAgents({ agentsVersion: 1, agents }, { rolesDir });
const project = (agents) => checkProjectAgents(agents, { rolesDir, where: "projects.web.agents" });
const ORG = { pm: { definition: "pm", vars: {} }, reviewer: { definition: "reviewer", vars: {} }, chief: { definition: "chief-of-staff", vars: {} } };
const names = (union) => union.agents.map((a) => `${a.level}:${a.name}`);
const refuses = (fn, pattern) => assert.throws(fn, (error) => error.name === "BusinessError" && pattern.test(error.message));
test("root level alone: each root agent runs in the organization it is used from", () => {
const r = root({ researcher: { definition: "research", vars: { model: "claude-opus-5-5" } }, assistant: { definition: "assistant" } });
assert.equal(r.assistant, "assistant");
const union = projectUnion({ orgId: "acme", root: r, org: {}, project: {}, projectId: "web" });
assert.deepEqual(names(union), ["root:researcher"]);
assert.deepEqual(union.agents[0].scope, { kind: "org", org: "acme" });
assert.equal(union.agents[0].vars.model, "claude-opus-5-5");
// The same root agent used from another organization is scoped to that one, never to both.
assert.deepEqual(projectUnion({ orgId: "home", root: r, org: {} }).agents[0].scope, { kind: "org", org: "home" });
});
test("organization level alone: its instances, scoped to it", () => {
const union = projectUnion({ orgId: "acme", root: NONE, org: ORG });
assert.deepEqual(names(union), ["org:chief", "org:pm", "org:reviewer"]);
assert.ok(union.agents.every((a) => a.scope.org === "acme"));
assert.equal(union.project, null);
});
test("project level alone: a project-defined agent", () => {
const union = projectUnion({ orgId: "acme", root: NONE, org: {}, project: project({ checker: { definition: "fact-checker" } }), projectId: "web" });
assert.deepEqual(names(union), ["project:checker"]);
assert.equal(union.agents[0].project, "web");
assert.equal(union.agents[0].definition, "fact-checker");
});
test("clash: the most specific level wins and the shadowed definitions are recorded", () => {
const r = root({ reviewer: { definition: "reviewer", vars: { model: "glm-5.3-flash" } } });
const p = project({ reviewer: { definition: "security-reviewer" } });
const union = projectUnion({ orgId: "acme", root: r, org: ORG, project: p, projectId: "web" });
const reviewer = union.agents.find((a) => a.name === "reviewer");
assert.equal(reviewer.level, "project");
assert.equal(reviewer.definition, "security-reviewer");
assert.deepEqual(reviewer.overrides, [{ level: "root", definition: "reviewer" }, { level: "org", definition: "reviewer" }]);
// The whole entry wins: the root's model does not leak into the project definition.
assert.equal(reviewer.vars.model, undefined);
assert.equal(union.agents.filter((a) => a.name === "reviewer").length, 1);
});
test("project narrowing: intersect keys only, never wider than what it inherits", () => {
const org = { ...ORG, coder: { definition: "coder", vars: { model: "claude-sonnet-5-5", "limits.network": "api-only", "limits.tools": ["read", "bash"] } } };
const p = project({ coder: { vars: { "limits.network": "open", "limits.tools": ["read", "edit"] } } });
const coder = projectUnion({ orgId: "acme", root: NONE, org, project: p, projectId: "web" }).agents.find((a) => a.name === "coder");
assert.equal(coder.level, "org");
assert.equal(coder.vars["limits.network"], "api-only"); // asking for open stays at the narrower api-only
assert.deepEqual(coder.vars["limits.tools"], ["read"]);
assert.equal(coder.vars.model, "claude-sonnet-5-5");
assert.deepEqual(coder.narrowed, ["limits.network", "limits.tools"]);
assert.equal(coder.vars["tracker.kind"], undefined); // no registry defaults leak in
refuses(() => project({ coder: { vars: { model: "claude-opus-5-5" } } }), /can only narrow limits\.tools, limits\.network, limits\.authority, not "model"/);
});
test("exclusions: a root opt-out per organization and a project exclude", () => {
const r = root({ reviewer: { definition: "reviewer", excludeOrgs: ["home"] }, researcher: { definition: "research" } });
assert.deepEqual(names(projectUnion({ orgId: "home", root: r, org: {} })), ["root:researcher"]);
assert.deepEqual(projectUnion({ orgId: "home", root: r, org: {} }).excluded, [{ name: "reviewer", level: "root", by: "root" }]);
const union = projectUnion({ orgId: "acme", root: r, org: {}, project: project({ researcher: { exclude: true } }), projectId: "web" });
assert.deepEqual(names(union), ["root:reviewer"]);
assert.deepEqual(union.excluded, [{ name: "researcher", level: "root", by: "project" }]);
});
test("a name the root turned off, or a project excluded, that a more specific level defines is available, not excluded", () => {
const r = root({ reviewer: { definition: "reviewer", excludeOrgs: ["home"] } });
// The organization's own reviewer: available under the organization's definition, no opt-out listed.
const home = projectUnion({ orgId: "home", root: r, org: { reviewer: { definition: "reviewer", vars: {} } } });
assert.deepEqual(names(home), ["org:reviewer"]);
assert.deepEqual(home.excluded, []);
// A project that defines the name the root turned off for its organization.
const web = projectUnion({ orgId: "home", root: r, org: {}, project: project({ reviewer: { definition: "security-reviewer" } }), projectId: "web" });
assert.deepEqual(names(web), ["project:reviewer"]);
assert.deepEqual(web.excluded, []);
// Without a definition of its own, the opt-out is still reported.
assert.deepEqual(projectUnion({ orgId: "home", root: r, org: {}, project: {}, projectId: "web" }).excluded, [{ name: "reviewer", level: "root", by: "root" }]);
});
test("the root assistant is cross-organization and never in one organization's union", () => {
const r = root({ assistant: { definition: "assistant" } });
assert.deepEqual(projectUnion({ orgId: "acme", root: r, org: {} }).agents, []);
// An organization's own assistant instance works inside that organization only.
const own = projectUnion({ orgId: "acme", root: r, org: { assistant: { definition: "assistant", vars: {} } } });
assert.deepEqual(names(own), ["org:assistant"]);
assert.deepEqual(own.agents[0].scope, { kind: "org", org: "acme" });
refuses(() => root({ helper: { definition: "assistant" } }), /assistant definition is the root agent named assistant only/);
});
test("refusals in a project's union: unknown targets, the chief, the assistant", () => {
refuses(() => projectUnion({ orgId: "acme", root: NONE, org: ORG, project: project({ ghost: { exclude: true } }), projectId: "web" }), /names no root or organization agent to exclude/);
refuses(() => projectUnion({ orgId: "acme", root: NONE, org: ORG, project: project({ ghost: { vars: { "limits.network": "none" } } }), projectId: "web" }), /to narrow/);
refuses(() => projectUnion({ orgId: "acme", root: NONE, org: ORG, project: project({ chief: { exclude: true } }), projectId: "web" }), /can't change the organization's chief-of-staff/);
refuses(() => projectUnion({ orgId: "acme", root: NONE, org: ORG, project: project({ chief: { definition: "reviewer" } }), projectId: "web" }), /can't change the organization's chief-of-staff/);
});
test("malformed root definitions refuse", () => {
refuses(() => checkRootAgents({ agentsVersion: 2, agents: {} }, { rolesDir }), /agentsVersion/);
refuses(() => checkRootAgents({ agentsVersion: 1, agents: {}, extra: 1 }, { rolesDir }), /unsupported root agents key: "extra"/);
refuses(() => root({ boss: { definition: "chief-of-staff" } }), /can't be a root agent/);
refuses(() => root({ old: { definition: "researcher" } }), /version 1/);
refuses(() => root({ x: { definition: "no-such-role" } }), /no usable role definition named no-such-role/);
refuses(() => root({ x: { definition: "reviewer", credentials: {} } }), /can't carry credentials/);
refuses(() => root({ x: { definition: "reviewer", vars: { "tracker.project": 3 } } }), /can't be set at the agent layer/);
refuses(() => root({ x: { definition: "reviewer", excludeOrgs: ["a", "a"] } }), /duplicate entry/);
refuses(() => root({ "Bad Name": { definition: "reviewer" } }), /agent name must match/);
});
test("a refused var names its key, never its value", () => {
const secret = "sk-secret-marker-7731";
const hides = (fn, key) => assert.throws(fn, (error) => error.name === "BusinessError" && error.message.includes(`variable ${key}`) && !error.message.includes(secret));
hides(() => root({ x: { definition: "reviewer", vars: { thinking: secret } } }), "thinking");
hides(() => root({ x: { definition: "reviewer", vars: { model: "opus-5.5", harness: `${secret}!` } } }), "harness");
hides(() => root({ x: { definition: "reviewer", vars: { "limits.tools": ["read", secret] } } }), "limits.tools");
hides(() => project({ x: { definition: "reviewer", vars: { thinking: secret } } }), "thinking");
hides(() => project({ x: { vars: { "limits.network": secret } } }), "limits.network");
// A message that quotes no value is kept whole, so the owner still reads the rule.
refuses(() => root({ x: { definition: "reviewer", vars: { "tracker.project": 3 } } }), /variable tracker\.project can't be set at the agent layer/);
refuses(() => root({ x: { definition: "reviewer", vars: { "fallback.model": "m" } } }), /a fallback sets all of/);
});
test("malformed project definitions refuse", () => {
refuses(() => project({ x: { exclude: true, vars: {} } }), /exactly \{"exclude": true\}/);
refuses(() => project({ x: { exclude: false } }), /exactly/);
refuses(() => project({ x: {} }), /defines \(definition\), narrows \(vars\) or excludes/);
refuses(() => project({ x: { definition: "chief-of-staff" } }), /can't be a project agent/);
refuses(() => project({ x: { definition: "assistant" } }), /can't be a project agent/);
refuses(() => project({ x: { definition: "reviewer", hosts: [] } }), /can't carry hosts/);
refuses(() => project({ x: { definition: "reviewer", other: 1 } }), /unsupported projects\.web\.agents\.x key/);
});
test("root file: absent, owner and mode checks, symbolic link, bad JSON, and no host path in a message", () => {
const dir = tmp();
assert.equal(loadRootAgents(dir, { rolesDir }).state, "absent");
const file = writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { reviewer: { definition: "reviewer" } } });
const ok = loadRootAgents(dir, { rolesDir });
assert.equal(ok.state, "ok");
assert.deepEqual(Object.keys(ok.agents), ["reviewer"]);
chmodSync(file, 0o620);
refuses(() => loadRootAgents(dir, { rolesDir }), /writable by group or other/);
chmodSync(file, 0o600);
writeFileSync(file, "{ nope");
assert.throws(() => loadRootAgents(dir, { rolesDir }), (error) => /not valid JSON \(agents\.json\)/.test(error.message) && !error.message.includes(dir));
// The parser's own text can quote the file's bytes; the refusal never carries it.
writeFileSync(file, '{"agentsVersion": 1, "agents": {"x": secret-marker-7731}}');
assert.throws(() => loadRootAgents(dir, { rolesDir }), (error) => error.message === "root agent file is not valid JSON (agents.json)" && error.exitCode !== undefined);
// An id check's quote of what it was given is dropped too.
writeFileSync(file, JSON.stringify({ agentsVersion: 1, agents: { x: { definition: "Secret Marker 7731" } } }));
assert.throws(() => loadRootAgents(dir, { rolesDir }), (error) => /root agents\.x\.definition must match/.test(error.message) && !error.message.includes("Marker"));
const linked = tmp();
symlinkSync(file, join(linked, "agents.json"));
assert.throws(() => loadRootAgents(linked, { rolesDir }), (error) => error.exitCode === 4 && !error.message.includes(linked));
});
test("organization file: project agents are checked but not loaded; a malformed one refuses the file", () => {
const dir = tmp();
const doc = businessDoc(dir);
doc.projects.stack.agents = { coder: { vars: { "limits.network": "none" } }, checker: { definition: "fact-checker" } };
const loaded = validateOrganizationDocument(structuredClone(doc), "acme.json", { rolesDir });
assert.equal(loaded.projects.stack.agents, undefined);
const levels = resolveAgentLevels({ orgId: "acme", doc, root: NONE, rolesDir });
assert.equal(levels.unions.stack.ok, true);
assert.ok(levels.unions.stack.agents.some((a) => a.name === "checker" && a.level === "project"));
assert.deepEqual(levels.unions.stack.agents.find((a) => a.name === "coder").narrowed, ["limits.network"]);
doc.projects.stack.agents = { boss: { definition: "chief-of-staff" } };
refuses(() => validateOrganizationDocument(doc, "acme.json", { rolesDir }), /can't be a project agent/);
});
test("one project's bad union refuses only that project", () => {
const dir = tmp();
const doc = businessDoc(dir);
mkdirSync(join(dir, "other"));
doc.projects.other = { root: join(dir, "other"), agents: { ghost: { exclude: true } } };
const levels = resolveAgentLevels({ orgId: "acme", doc, root: NONE, rolesDir });
assert.equal(levels.unions.stack.ok, true);
assert.equal(levels.unions.other.ok, false);
assert.match(levels.unions.other.reason, /names no root or organization agent to exclude/);
assert.ok(levels.base.agents.length > 0);
});
@@ -0,0 +1,208 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as policy from '../src/org-rights.mjs';
import { CONSOLE_VERBS, USER_VERBS, BOARD_VERBS, PERSON_TASK_VERBS } from '../../bus/src/broker.mjs';
// Accounts design draft 3, section 3 and M1 (#1873). Expected assignments are
// written out: adding a real bus verb or widening a bundle must change this pin.
const RIGHTS = {
'org.view': { grantable: true },
'thread.read': { grantable: true },
'thread.write': { grantable: true },
'board.read': { grantable: true },
'task.write': { grantable: true },
decide: { grantable: true },
'request.write': { grantable: false },
'members.manage': { grantable: false },
'org.setup': { grantable: false },
'org.lifecycle': { grantable: false },
};
const BUNDLES = {
owner: ['board.read', 'decide', 'members.manage', 'org.lifecycle', 'org.setup', 'org.view', 'request.write', 'task.write', 'thread.read', 'thread.write'],
member: ['board.read', 'org.view', 'thread.read'],
collaborator: ['board.read', 'org.view', 'task.write', 'thread.read', 'thread.write'],
approver: ['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write'],
};
const VERBS = {
'project.list': 'org.view',
names: 'org.view',
'thread.list': 'thread.read',
'thread.messages': 'thread.read',
plugins: 'thread.read',
'thread.open': 'thread.write',
'thread.create': 'thread.write',
'thread.send': 'thread.write',
'thread.share': 'thread.write',
'thread.unshare': 'thread.write',
'thread.shares': 'thread.write',
inbox: 'board.read',
agents: 'board.read',
'thread.claims': 'board.read',
tasks: 'board.read',
decisions: 'board.read',
usage: 'board.read',
limits: 'board.read',
'request.get.console': 'board.read',
'request.projects.console': 'board.read',
'request.artifact.console': 'board.read',
'request.workspace.console': 'board.read',
trail: null,
credentials: null,
replies: null,
messages: null,
state: null,
'tasks.create': 'task.write',
'tasks.edit': 'task.write',
'tasks.archive': 'task.write',
'tasks.unarchive': 'task.write',
'tasks.delete': null,
'decision.preview.console': 'decide',
'decision.resolve.console': 'decide',
'request.answer.console': 'request.write',
'request.attach.console': 'request.write',
'request.cancel.console': 'request.write',
'request.mode.console': 'request.write',
'request.draft.console': 'request.write',
'prd.revoke.console': 'request.write',
'plugins.set': 'org.setup',
'system-types.console': 'org.setup',
'project.remote.get.console': 'org.setup',
'project.remote.set.console': 'org.setup',
};
test('M1 the ten rights pin the grantable flags, including compulsory org.view', () => {
assert.deepEqual({ ...policy.RIGHTS }, RIGHTS);
});
for (const [role, rights] of Object.entries(BUNDLES)) {
test(`M1 ${role} has exactly its reviewed rights, expanded and sorted`, () => {
assert.deepEqual(policy.ROLE_RIGHTS[role], rights);
});
}
test('M1 owner holds every right; only owner holds any reserved right', () => {
assert.deepEqual(policy.ROLE_RIGHTS.owner, Object.keys(policy.RIGHTS).sort());
for (const role of ['member', 'collaborator', 'approver']) {
for (const right of policy.ROLE_RIGHTS[role]) assert.equal(policy.RIGHTS[right].grantable, true, `${role}: ${right}`);
assert.ok(!policy.ROLE_RIGHTS[role].includes('request.write'));
}
assert.ok(!policy.ROLE_RIGHTS.member.includes('decide'));
assert.ok(!policy.ROLE_RIGHTS.member.includes('task.write'));
});
test('M1 canGrant allows only known grantable rights held by the granter', () => {
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, policy.ROLE_RIGHTS.approver), true);
assert.equal(policy.canGrant(['org.view', 'decide'], ['decide']), true);
assert.equal(policy.canGrant(['org.view'], ['thread.read']), false);
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.member, policy.ROLE_RIGHTS.collaborator), false);
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, []), true);
for (const right of ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle', 'future.right', '__proto__', 'constructor', 'toString']) {
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, [right]), false, right);
}
assert.equal(policy.canGrant(['org.view', 'future.right'], ['org.view']), false);
});
test('M1 canGrant refuses malformed lists and values, including sparse arrays', () => {
for (const value of [undefined, null, 'owner', {}, 1, [null], [1], [new String('org.view')], Array(1)]) {
assert.equal(policy.canGrant(value, ['org.view']), false);
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, value), false);
}
});
test('M1 normalizeRights sorts, deduplicates, adds org.view and applies explicit additions and removals', () => {
const rights = Object.freeze(['thread.read', 'board.read', 'thread.read']);
assert.deepEqual(policy.normalizeRights(rights), ['board.read', 'org.view', 'thread.read']);
assert.deepEqual(policy.normalizeRights([]), ['org.view']);
const add = Object.freeze(['task.write', 'decide', 'task.write']);
const remove = Object.freeze(['thread.read']);
assert.deepEqual(policy.normalizeRights(rights, { add, remove }), ['board.read', 'decide', 'org.view', 'task.write']);
assert.deepEqual(rights, ['thread.read', 'board.read', 'thread.read']);
assert.notEqual(policy.normalizeRights(rights), rights);
});
test('M1 normalization refuses any attempt to remove org.view, even when re-added', () => {
for (const rights of [[], ['org.view']]) {
assert.throws(() => policy.normalizeRights(rights, { add: ['org.view'], remove: ['org.view'] }), { code: 'right-not-removable' });
}
});
test('M1 normalization refuses reserved, owner and unknown rights in every input before returning a result', () => {
for (const right of ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle', 'future.right', '__proto__', 'constructor', 'toString']) {
const code = ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle'].includes(right) ? 'right-not-grantable' : 'unknown-right';
for (const [rights, options] of [[[right], {}], [[], { add: [right] }], [[], { remove: [right] }]]) {
assert.throws(() => policy.normalizeRights(rights, options), { code }, right);
}
}
});
test('M1 normalization refuses malformed lists, options and non-string rights', () => {
for (const value of [undefined, null, {}, 'member', 0, [null], [42], [new String('org.view')], Array(1)]) {
assert.throws(() => policy.normalizeRights(value));
assert.throws(() => policy.normalizeRights([], { add: value }));
assert.throws(() => policy.normalizeRights([], { remove: value }));
}
for (const options of [null, [], 'member', 1, { typo: [] }]) {
assert.throws(() => policy.normalizeRights([], options), { code: 'invalid-rights' });
}
});
test('M1 VERB_RIGHTS pins every assignment from section 3, with explicit null for unmapped owner-only operations', () => {
assert.deepEqual({ ...policy.VERB_RIGHTS }, VERBS);
for (const right of Object.values(policy.VERB_RIGHTS)) assert.ok(right === null || Object.hasOwn(policy.RIGHTS, right));
});
test('M1 every actual console, user, board and person-task verb has a reviewed assignment', () => {
for (const verbs of [CONSOLE_VERBS, USER_VERBS, BOARD_VERBS, PERSON_TASK_VERBS]) {
for (const verb of verbs) {
assert.ok(Object.hasOwn(VERBS, verb), `${verb} needs a reviewed assignment`);
assert.ok(Object.hasOwn(policy.VERB_RIGHTS, verb), `${verb} missing from VERB_RIGHTS`);
assert.equal(policy.VERB_RIGHTS[verb], VERBS[verb], verb);
}
}
assert.deepEqual(Object.keys(VERBS).sort(), [...new Set([
...CONSOLE_VERBS, ...USER_VERBS, ...BOARD_VERBS, ...PERSON_TASK_VERBS, 'plugins', 'plugins.set', 'limits',
])].sort());
});
test('M1 sensitive board reads, task delete, intake and PRD revoke remain owner-only', () => {
for (const verb of ['trail', 'credentials', 'replies', 'messages', 'state', 'tasks.delete']) {
assert.equal(policy.VERB_RIGHTS[verb], null, verb);
}
for (const verb of ['request.answer.console', 'request.attach.console', 'request.cancel.console', 'request.mode.console', 'request.draft.console', 'prd.revoke.console']) {
assert.equal(policy.VERB_RIGHTS[verb], 'request.write', verb);
assert.equal(policy.RIGHTS[policy.VERB_RIGHTS[verb]].grantable, false);
}
});
test('M1 unknown verbs, rights and role property names never inherit an assignment', () => {
for (const name of ['future.verb', 'tasks.foo', 'task.create', 'owner', '__proto__', 'constructor', 'toString', '', undefined, null]) {
assert.equal(policy.VERB_RIGHTS[name], undefined);
assert.equal(policy.RIGHTS[name], undefined);
if (name !== 'owner') assert.equal(policy.ROLE_RIGHTS[name], undefined);
assert.throws(() => policy.rightLevel(name), { code: 'unknown-right' });
}
});
test('M1 only thread.write needs collaborator; every other known right needs viewer', () => {
for (const right of Object.keys(RIGHTS)) {
assert.equal(policy.rightLevel(right), right === 'thread.write' ? 'collaborator' : 'viewer', right);
}
for (const value of [{}, [], 1, new String('org.view')]) assert.throws(() => policy.rightLevel(value), { code: 'unknown-right' });
});
test('M1 policy records, grantable flags, bundles and normalized rights are frozen', () => {
for (const map of [policy.RIGHTS, policy.ROLE_RIGHTS, policy.VERB_RIGHTS]) {
assert.equal(Object.getPrototypeOf(map), null);
assert.ok(Object.isFrozen(map));
assert.throws(() => { map.extra = 'decide'; }, TypeError);
}
for (const descriptor of Object.values(policy.RIGHTS)) {
assert.ok(Object.isFrozen(descriptor));
assert.throws(() => { descriptor.grantable = !descriptor.grantable; }, TypeError);
}
for (const bundle of Object.values(policy.ROLE_RIGHTS)) {
assert.ok(Object.isFrozen(bundle));
assert.throws(() => bundle.push('decide'), TypeError);
}
assert.ok(Object.isFrozen(policy.normalizeRights(['thread.read'])));
});
@@ -251,7 +251,7 @@ test("names that are Object.prototype properties don't count as declared", () =>
refuses(() => check((d) => { d.arbiters.delivery = "constructor"; }), /arbiters\.delivery names constructor/);
refuses(() => check((d) => { d.launch.by = "constructor"; }), /launch\.by names constructor/);
refuses(() => check((d) => { d.launch.instances = ["constructor"]; }), /instances names constructor/);
refuses(() => check((d) => { d.roles.pm.vars.model = "constructor"; }), /unknown model constructor/);
refuses(() => check((d) => { d.roles.pm.vars.model = "constructor"; }), /variable model selects an unknown model/);
refuses(() => check((d) => { d.roles.pm.definition = "constructor"; }), /not found/, 4);
const labels = check((d) => { d.tracker.labels = JSON.parse('{"__proto__": 5}'); }).tracker.labels;
assert.equal(Object.getPrototypeOf(labels), Object.prototype);
@@ -0,0 +1,140 @@
import test from "node:test";
import assert from "node:assert/strict";
import { join } from "node:path";
import {
BusinessError, checkVars, loadBusiness, loadProject, projectFilePath, systemVars,
} from "../src/index.mjs";
import { businessDoc, REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
import { checkRoutes, launchSettings } from "../src/launch-settings.mjs";
import { OrganizationError } from "../src/organization.mjs";
const SECRET = "sk-test-NOT-A-SECRET-1234";
function hidesValue(fn, key, reason) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError);
assert.equal(error.name, "BusinessError");
assert.equal(error.exitCode, 2);
assert.ok(error.message.includes(`variable ${key}`), error.message);
if (reason) assert.match(error.message, reason);
assert.ok(!error.message.includes(SECRET), "refusal disclosed a var value");
assert.ok(!String(error).includes(SECRET), "rendered error disclosed a var value");
return true;
});
}
for (const [scope, key, value] of [
["business", "tracker.kind", SECRET],
["agent", "thinking", SECRET],
["agent", "model", SECRET],
]) {
test(`organization file ${scope} ${key} refusal names its key and hides its value`, () => {
const root = tmp();
const doc = businessDoc(root);
if (scope === "agent") doc.roles.coder.vars[key] = value;
else doc.vars[key] = value;
writeJson(join(root, "businesses/acme.json"), doc);
hidesValue(() => loadBusiness("acme", { dir: root, rolesDir: REPO_ROLES }), key, key === "model" ? /unknown model/ : /one of/);
});
}
for (const scope of ["project", "role"]) {
test(`project file ${scope} var refusal names its key and hides its value`, () => {
const root = tmp();
const vars = { "tracker.project": SECRET };
writeJson(projectFilePath(root), {
projectVersion: 1, id: "stack",
...(scope === "project" ? { vars } : { roles: { coder: { vars } } }),
});
hidesValue(() => loadProject(root), "tracker.project", /positive integer/);
});
}
test("systemVars accepts user values and refuses malformed ones without quoting them", () => {
const config = {
environment: "development", dataRoot: "/d",
execution: { backend: "docker", provider: "zai", model: "m", adapter: "pi" },
};
for (const key of ["environment", "dataRoot", "execution.backend", "execution.provider", "execution.model", "execution.adapter"]) {
const bad = structuredClone(config);
const value = { pasted: SECRET };
if (key.startsWith("execution.")) bad.execution[key.slice("execution.".length)] = value;
else bad[key] = value;
hidesValue(() => systemVars(bad), key);
}
});
// Exercise every validator that used to quote a scalar, a nested leaf or a duplicate.
// Removing any of their redactions must fail here even if a caller sanitizes its error.
for (const [key, layer, value, reason] of [
["tracker.kind", "business", SECRET, /one of/],
["harness", "agent", `${SECRET}!`, /harness module name/],
["fallback.harness", "agent", `${SECRET}!`, /harness module name/],
["thinking", "agent", { pasted: [SECRET] }, /one of/],
["fallback.thinking", "agent", SECRET, /one of/],
["tracker.reconcileMinutes", "business", SECRET, /integer of at least 1/],
["tracker.pollSeconds", "project", SECRET, /integer of at least 10/],
["tracker.baseUrl", "business", SECRET, /http or https URL/],
["gitea.baseUrl", "business", SECRET, /http or https URL/],
["web.searxng", "business", `http://${SECRET}.example`, /https, or http on loopback/],
["git.workingBranch", "project", `${SECRET}!`, /git branch name/],
["git.protectedBranches", "project", [SECRET, `${SECRET}!`], /git branch name/],
["git.protectedBranches", "project", [SECRET, SECRET], /duplicate entry/],
["suites", "project", [SECRET, SECRET], /duplicate entry/],
["tracker.descendants", "project", SECRET, /true or false/],
["bus.tester", "business", SECRET, /true or false/],
["tracker.project", "project", SECRET, /positive integer/],
["limits.tools", "agent", ["read", SECRET], /unsupported tool/],
["limits.authority", "agent", ["message.send", SECRET], /unknown action/],
["limits.network", "agent", SECRET, /one of/],
]) {
test(`checkVars hides refused ${key}: ${reason.source}`, () => {
hidesValue(() => checkVars({ [key]: value }, layer, "fixture vars"), key, reason);
});
}
for (const prefix of ["", "fallback."]) {
test(`route writer unknown ${prefix}model names its key and hides its value`, () => {
const root = tmp();
const doc = businessDoc(root);
writeJson(join(root, "businesses/acme.json"), doc);
const settings = launchSettings({ dir: root, rolesDir: REPO_ROLES });
const route = { harness: "pi", model: Object.keys(doc.launch.models)[0], thinking: "high", fallback: null };
if (prefix) route.fallback = { harness: "pi", model: SECRET, thinking: "high" };
else route.model = SECRET;
assert.throws(() => settings.setRoutes("acme", {
revision: settings.writer.read("acme").revision, routes: { coder: route },
}, "jason"), error => {
assert.ok(error instanceof OrganizationError);
assert.equal(error.code, "unknown-model");
assert.ok(!String(error).includes(SECRET));
assert.ok(error.message.includes(`variable ${prefix}model`), error.message);
return true;
});
});
for (const [field, code] of [
["model", "unknown-model"], ["harness", "harness-off"],
["harness", "harness-refused"], ["thinking", "thinking-unsupported"],
]) {
test(`route check ${prefix}${field} ${code} names its key and hides its value`, () => {
const vars = prefix ? {
harness: "pi", model: "listed", thinking: "high",
"fallback.harness": "pi", "fallback.model": "listed", "fallback.thinking": "high",
} : { harness: "pi", model: "listed", thinking: "high" };
const key = `${prefix}${field}`;
vars[key] = SECRET;
const proposed = {
roles: { coder: { vars } }, launch: { models: { listed: {} } },
plugins: { harness: code === "harness-refused" ? ["pi", SECRET] : ["pi"] },
};
assert.throws(() => checkRoutes({ proposed, levels: name => name === SECRET ? null : ["high"] }), error => {
assert.ok(error instanceof OrganizationError);
assert.equal(error.code, code);
assert.ok(!String(error).includes(SECRET), "route refusal disclosed a var value");
assert.ok(error.message.includes(`variable ${key}`), error.message);
return true;
});
});
}
}
+70
View File
@@ -0,0 +1,70 @@
// #1873 Phase A slice A1: GET /api/agents/levels, the read behind the Agents grid's level and
// source columns (design docs/plans/2026-11-10_agent-levels-design.md section 6).
//
// Owner only, GET only, no bus read and no write. The orgs are the ones `organizations.members`
// admits, the same set the workspace read uses. The body carries no host path, no credential
// reference and no var other than the model and the names of the keys a project narrowed.
import { resolveAgentLevels } from '../../organization/src/agent-levels.mjs';
import { plainMessage } from './org-admin.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));
};
// A refusal message, with any host path made relative to the instance. Anything that is not a
// coded refusal gets a fixed line: parser text can quote file bytes.
const reason = (ctx, error) => error?.name === 'BusinessError' && typeof error.message === 'string'
? plainMessage(ctx, error.message) : 'could not be read';
const agentOut = a => ({
name: a.name, definition: a.definition, level: a.level, ...(a.project ? { project: a.project } : {}),
model: typeof a.vars?.model === 'string' ? a.vars.model : null,
narrowed: [...a.narrowed], overrides: a.overrides.map(o => ({ level: o.level, definition: o.definition })),
scope: a.scope.kind === 'org' ? { kind: 'org', org: a.scope.org } : { kind: a.scope.kind },
});
function rootOut(root) {
if (root.state !== 'ok') return root.state === 'absent' ? { state: 'absent', agents: [], assistant: null } : root;
return {
state: 'ok',
agents: Object.entries(root.agents).map(([name, a]) => ({ name, definition: a.definition, model: typeof a.vars.model === 'string' ? a.vars.model : null, excludeOrgs: [...a.excludeOrgs] }))
.sort((a, b) => a.name.localeCompare(b.name)),
assistant: root.assistant,
};
}
// `levels` is `{ loadRoot(), rolesDir, ctx }` from cli.mjs. A bad root file hides the root level
// only; a bad organization or project hides only itself.
export async function readAgentLevels({ organizations, session, levels, timeoutMs }) {
const ctx = levels.ctx ?? {};
let root;
try { root = levels.loadRoot(); }
catch (error) { root = { state: 'invalid', reason: reason(ctx, error), agents: {}, assistant: null }; }
const usable = root.state === 'ok' ? root : { state: root.state, agents: {}, assistant: null };
const members = await organizations.members(session, { timeoutMs });
const orgs = members.map(m => {
if (m.invalid) return { id: m.id, state: 'invalid', reason: m.reason };
let resolved;
try { resolved = resolveAgentLevels({ orgId: m.id, doc: m.doc, root: usable, rolesDir: levels.rolesDir }); }
catch (error) { return { id: m.id, state: 'invalid', reason: reason(ctx, error) }; }
const projects = Object.fromEntries(Object.entries(resolved.unions).map(([id, u]) => [id, u.ok
? { state: 'ok', agents: u.agents.map(agentOut), excluded: u.excluded.map(e => ({ ...e })) }
: { state: 'invalid', reason: plainMessage(ctx, u.reason) }]));
const defined = Object.fromEntries(Object.entries(resolved.projects).map(([id, entries]) => [id, Object.entries(entries)
.map(([name, e]) => ({ name, form: e.form, ...(e.form === 'define' ? { definition: e.definition } : {}), ...(e.form === 'narrow' ? { narrowed: Object.keys(e.vars) } : {}) }))]));
return { id: m.id, state: 'ok', base: { agents: resolved.base.agents.map(agentOut), excluded: resolved.base.excluded.map(e => ({ ...e })) }, defined, projects };
});
return { root: rootOut(root), orgs };
}
// GET /api/agents/levels. The hook fence has required a signed-in session on the page's name.
export async function agentLevelsHTTP(req, res, { session = null, owner = false, organizations = null, levels = null, timeoutMs, log = () => {} }) {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
if (!organizations || typeof organizations.members !== 'function' || !levels) return json(res, 404, { error: 'org-unavailable' });
if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'agent levels are the owner\'s' });
let body;
try { body = await readAgentLevels({ organizations, session, levels, timeoutMs }); }
catch { log('agent levels: 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);
}
+4 -1
View File
@@ -16,6 +16,7 @@ import { busStatus as orgBusStatus } from '../../cli/src/org-bus.mjs';
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
import { showOrg } from '../../organization/src/org-service.mjs';
import { configDir } from '../../organization/src/organization-config.mjs';
import { loadRootAgents } from '../../organization/src/agent-levels.mjs';
import { consoleReads, redactor } from './reads.mjs';
import { createDevOwner } from './dev-owner.mjs';
import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.mjs';
@@ -172,6 +173,8 @@ try {
};
},
}) : null;
// #1873 A1: the root agent level, read fresh on each request; the Agents grid's only new read.
const agentLevels = organizations ? { loadRoot: () => loadRootAgents(configDir(), { rolesDir: rolesDir() }), rolesDir: rolesDir(), ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance } } : null;
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.
@@ -183,7 +186,7 @@ try {
} : null;
const trail = system ? actionTrail(join(system.dataRoot, 'console')) : null;
try {
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 });
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, agentLevels, preselect: options.business ?? null });
} catch (err) { sso?.close(); throw err; }
if (testerSocket) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
+5 -1
View File
@@ -12,6 +12,9 @@ export const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.
export const PROXY_VERBS = Object.freeze(['proxy.accounts', 'proxy.login.start', 'proxy.login.submit',
'proxy.login.status', 'proxy.login.cancel', 'proxy.apikey.add', 'proxy.priority.set', 'proxy.group.models.set', 'proxy.keys', 'plugins', 'plugins.set']);
export const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request'];
// U4b (#1873): the task board's writes, the person's built-in store verbs (broker tasks.*). The
// caller refuses any other, so the board's taskWrite never reaches unarchive or delete.
export const TASK_WRITE_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive']);
export const agentCaller = (call, verbs) => async (verb, args) => {
if (!verbs.includes(verb)) throw new Error(`the Console does not call ${verb}`);
return call(verb, args);
@@ -36,13 +39,14 @@ export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
projectFiles: args => call('project.files', args),
projectFile: args => call('project.file', args),
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
send: null, librarySync: null, libraryRevert: null, librarySave: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
send: null, librarySync: null, libraryRevert: null, librarySave: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null, taskWrite: null,
};
if (sendOn) {
o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.libraryRevert = args => call('library.revert', args); o.settingsWrite = args => call('limits.set', args);
o.librarySave = args => call('library.file.save', args);
o.projectSave = args => call('project.file.save', args); o.nameWrite = args => call('name.set', args); o.assistantStop = args => call('assistant.stop', args);
o.instancesWrite = args => call('instances.set', args); o.routesWrite = args => call('routes.set', args); o.agentWrite = agentCaller(call, AGENT_VERBS);
o.taskWrite = agentCaller(call, TASK_WRITE_VERBS);
}
return o;
}
+3 -2
View File
@@ -35,8 +35,9 @@ const MAX_BODY = 64 * 1024;
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.
const SERVER_PROJECT_KEYS = ['root', 'repo', 'git'];
// Project fields the server owns. The browser never sees or sends them. `agents` (#1873 A1) is
// the project's agent level: read-only in A1, so the draft editor can't change it either.
const SERVER_PROJECT_KEYS = ['root', 'repo', 'git', 'agents'];
// The CLI verb each step equals (r4 section 2).
export function verbFor(step, id, opts = {}) {
+42 -19
View File
@@ -717,7 +717,7 @@
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.</p>
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section>
${threadSection}
<p class="small muted">Board seats stay on the <a href="#/">control board</a>. This view is about roles.</p>`;
<p class="small muted">Board seats stay on the <a href="${window.MosaicOrg ? '#/board' : '#/'}">control board</a>. This view is about roles.</p>`;
}
// ---------------------------------------------------------------- queue, business, settings
@@ -1391,7 +1391,7 @@
});
// Not found and states
function notFound(why) { return `${head('Not found')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="#/">Go to the Board</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
function notFound(why) { return `${head('Not found')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="#/">Go to the ${window.MosaicOrg ? 'Dashboard' : 'Board'}</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
const loadingView = (source = null) => `${head('Reading…')}<div class="skel-rows" aria-busy="true"><span class="sr-only">${source ? `Reading the ${esc(source.toLowerCase())}.` : 'Reading from the bus.'}</span><span class="skel"></span><span class="skel"></span><span class="skel"></span><span class="skel"></span></div>`;
const retry = '<div class="actions"><button type="button" class="btn" data-retry>Read again</button></div>';
// Why a read failed, in words, with the bus code as given. Never a path or a guess.
@@ -1442,20 +1442,15 @@
// ---------------------------------------------------------------- router
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
const routeOf = () => window.MosaicOrg?.route() ?? (location.hash.startsWith('#/') ? location.hash.slice(1) : '/');
// A one-line scope note right under a view's page heading.
function afterHead(html, note) {
const start = html.indexOf('<div class="page-head">'), end = start < 0 ? -1 : html.indexOf('</div></div>', start);
const line = `<p class="small muted ws-scope" data-not-built>${esc(note)}</p>`;
return end < 0 ? `${line}${html}` : `${html.slice(0, end + 12)}${line}${html.slice(end + 12)}`;
}
function view(get, path, q, opts, current) {
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
if (parts.includes(null)) return notFound();
const [a, b, c] = parts, n = parts.length;
if (n === 0 && window.MosaicOrg) return window.MosaicWorkspace.home();
if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q);
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
if (a === 'side-by-side' && n === 1 && window.MosaicOrg) return `${head('Side by side', 'Not built yet.')}${empty('Side by side is not built yet.', 'It arrives with a later unified workspace update. Projects and Agents work today.')}`;
if (a === 'inbox' && n === 1 && window.MosaicOrg) return inboxView(get).then(html => afterHead(html, 'The inbox across all organizations is not built yet. This is the selected organization\'s inbox.'));
if (a === 'tasks' && n === 1 && window.MosaicOrg) return tasksView(get).then(html => afterHead(html, 'Tasks across all organizations are not built yet. These are the selected organization\'s tasks.'));
if (a === 'inbox' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.inboxPage(q);
if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view();
if (a === 'inbox' && n === 1) return inboxView(get);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
if (a === 'messages' && n === 1) return messagesView(get, q);
@@ -1475,7 +1470,9 @@
let gen = 0, shown = null, timer, proxyCleanup=null, orgCleanup=null;
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
// A file open in the editor is never refreshed from under its draft; Reload is the person's to press.
function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), 10000); }
// The cross-organization pages (U3) poll at the workspace read's pollMs.
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/projects' || path === '/inbox');
function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 10000); }
// Keep focus, open <details> and the copy status across a refresh of the same page.
// In the send form, the focused field and its caret too (its values come from `draft`).
function keep() {
@@ -1489,6 +1486,7 @@
field: view.contains(a) ? a.dataset?.field : null, caret: view.contains(a) && typeof a.selectionStart === 'number' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null, send: view.contains(a) && a.hasAttribute?.('data-send'),
decide: view.contains(a) ? a.dataset?.decide ?? (a.id === 'decide-status' ? 'status' : null) : null,
open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '',
scroll: [document.scrollingElement?.scrollTop ?? 0, $('main').scrollTop, [...view.querySelectorAll('.ws-tbl, .ws-insp')].map(e => [e.scrollLeft, e.scrollTop])],
};
}
function restore(k) {
@@ -1502,7 +1500,7 @@
const setting=k.settingsField && [...$('bus-view').querySelectorAll('[data-instance-field],[data-assistant-field],[data-route],[data-setting]')].find(e=>e.dataset[k.settingsField.key]===k.settingsField.value && (!k.settingsField.form || e.closest('form')?.id===k.settingsField.form) && (!k.settingsField.instance || e.closest('[data-instance]')?.dataset.instance===k.settingsField.instance));
// The workspace screens mark their controls with data-focus-key: several Open links share an href.
const keyed = k.focusKey ? $('bus-view').querySelector(k.focusKey === 'bus-restart' ? '[data-bus-restart]' : `[data-focus-key="${CSS.escape(k.focusKey)}"]`) : null;
if (keyed) keyed.focus({ preventScroll: true });
if (keyed) { keyed.focus({ preventScroll: true }); if (k.caret && keyed.tagName === 'INPUT' && typeof keyed.setSelectionRange === 'function') try { keyed.setSelectionRange(k.caret[0], k.caret[1]); } catch {} }
else if (setting) { setting.focus({preventScroll:true}); if(k.caret && typeof setting.setSelectionRange==='function'){setting.setSelectionRange(k.caret[0],k.caret[1]);setting.scrollTop=k.caret[2];} }
else if (choice) choice.focus({preventScroll:true});
else if (dec) dec.focus({ preventScroll: true });
@@ -1511,26 +1509,47 @@
if (k.caret && el.tagName === 'TEXTAREA') { el.setSelectionRange(k.caret[0], k.caret[1]); el.scrollTop = k.caret[2]; }
}
else if (k.h1) $('bus-view').querySelector('h1')?.focus({ preventScroll: true });
// A repaint replaces the tables: put their horizontal scroll and the page's back.
if (k.scroll) {
const [top, main, inner] = k.scroll;
[...$('bus-view').querySelectorAll('.ws-tbl, .ws-insp')].forEach((e, i) => { if (inner[i]) { e.scrollLeft = inner[i][0]; e.scrollTop = inner[i][1]; } });
if (document.scrollingElement && document.scrollingElement.scrollTop !== top) document.scrollingElement.scrollTop = top;
if ($('main').scrollTop !== main) $('main').scrollTop = main;
}
}
async function render(navigated, opts = {}) {
// A refresh due while this render reads would replace it: a navigation would lose its heading focus.
clearTimeout(timer);
if (out()) return;
const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === '' || path === '/board';
const route = routeOf(), [path, query = ''] = route.split('?'), board = window.MosaicOrg ? path === '/board' : path === '/' || path === '' || path === '/board';
// On the cross-organization pages a filter or selection changes only the query: that is a
// repaint of the same page, not a navigation (no loading view, focus and scroll kept).
const samePage = wsPage(path) && typeof shown === 'string' && shown.split('?')[0] === path;
if (path !== '/settings/model-proxy' && proxyCleanup) {proxyCleanup();proxyCleanup=null;}
if ((navigated || path !== '/settings/organizations') && orgCleanup) {orgCleanup();orgCleanup=null;}
// Leaving a decision discards its confirmation immediately, including a preview in flight.
if (answer && (navigated || path !== `/inbox/${answer.id}`)) answer = null;
const g = ++gen, routeVersion = routesEditVersion;
const current = () => g === gen && route === routeOf() && !out();
// In organization mode #/ is the cross-organization dashboard; the processing controls stay on the board.
if (window.MosaicOrg) for (const id of ['processing-link', 'mobile-processing-link']) if ($(id) && $(id).getAttribute('href') !== '#/board') $(id).setAttribute('href', '#/board');
document.body.classList.toggle('on-bus', !board);
$('bus-view').hidden = board;
sections(path);
document.querySelectorAll('.rail a').forEach(a => { const target = a.getAttribute('href').slice(1); const active = target === '/projects' ? /^\/(projects|chat)(\/|$)/.test(path) : target === '/' ? path === '/' : path === target; if (active) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); });
const chatPath = path === '/projects' ? '/chat' : /^\/projects\/(threads|rooms)\//.test(path) ? '/chat' + path.slice('/projects'.length) : /^\/projects\/[^/]+$/.test(path) ? '/chat/projects/' + path.slice('/projects/'.length) : path;
const chatPath = path === '/projects' && !window.MosaicOrg ? '/chat' : /^\/projects\/(threads|rooms)\//.test(path) ? '/chat' + path.slice('/projects'.length) : /^\/projects\/[^/]+$/.test(path) ? '/chat/projects/' + path.slice('/projects/'.length) : path;
window.MosaicChat?.leave(chatPath);
if (path === '/business') { location.replace('#/settings/business'); return; }
if (path === '/settings/display') { location.replace('#/settings/appearance'); return; }
// U5: Side by side draws its own two panes (panes.js); every other page stops their polling.
if (path !== '/side-by-side') window.MosaicPanes?.unmount();
else if (window.MosaicOrg && window.MosaicPanes) {
const fresh = navigated || shown !== path;
shown = path;
if (fresh) { unmountEditor(); await window.MosaicPanes.mount($('bus-view'), query, { navigated }); }
else window.MosaicPanes.refresh();
return;
}
if (chatPath === '/chat' || chatPath.startsWith('/chat/') || /^\/projects\/[0-9a-f-]+\/(?:settings|conversations|tasks|requirements|files)$/i.test(chatPath)) {
// Re-read the scoped capability as the Console and bus recover. A failed read
// withdraws the control; the chat renderer retains the draft across repaint.
@@ -1597,11 +1616,11 @@
if (path === '/settings/model-proxy' && shown === route && !navigated) return;
if (path === '/settings/organizations' && shown?.split('?')[0] === path && !navigated) return;
if (($('agent-dialog')?.open || document.querySelector('.agent-more[aria-expanded=true]')) && !navigated) { schedule(); return; }
if (navigated || shown !== route) { unmountEditor(); $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
if (navigated || (shown !== route && !samePage)) { unmountEditor(); $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
let html, state = 'fresh';
const live = reader(true);
try {
if (path !== '/organization' && !(path === '/agents' && window.MosaicOrg) && path !== '/messages' && !['/settings','/settings/appearance','/settings/model-proxy','/settings/organizations'].includes(path)) {
if (path !== '/organization' && !wsPage(path) && !(path === '/agents' && window.MosaicOrg) && path !== '/messages' && !['/settings','/settings/appearance','/settings/model-proxy','/settings/organizations'].includes(path)) {
try { await live('messages'); }
catch (err) { messageCount = null; if (err.status === 403 || err.code === 'signed-out') throw err; }
}
@@ -1617,7 +1636,7 @@
freshBus(state, SOURCES[path.split('/')[1]]);
// A route edit after the markup was prepared must win over that pending redraw.
if(path==='/settings/routing' && routeVersion!==routesEditVersion){render(false);return;}
const k = !navigated && shown === route ? keep() : null;
const k = !navigated && (shown === route || samePage) ? keep() : null;
unmountEditor();
$('bus-view').innerHTML = html; shown = route;
syncAppearance(true);
@@ -1800,10 +1819,14 @@
}
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);
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')||wsPage(path)) render(false); else if (window.MosaicOrg ? path === '/board' : path === '/' || path === '') paintHeld(heldErr);
};
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); });
// The cross-organization pages ask for a repaint after a filter, sort or selection change,
// and report the inbox count across organizations for the sidebar.
document.addEventListener('mosaic-workspace-render', () => { if (wsPage(routeOf().split('?')[0])) render(false); });
document.addEventListener('mosaic-workspace-inbox', e => { const d = e.detail; if (d && Number.isFinite(d.open)) { inboxCount = { open: d.open, blocking: Number.isFinite(d.blocking) ? d.blocking : 0 }; sections(routeOf().split('?')[0]); } });
// This context comes from the conversations renderer after its authorized read.
let projectContext = null;
document.addEventListener('mosaic-project-context', event => {
+3 -1
View File
@@ -99,7 +99,9 @@
}
return data;
}
const legacyRoute = () => (window.MosaicOrg?.route() ?? location.hash.slice(1)).startsWith('/chat');
// In organization mode #/projects is the cross-organization page, so the organization's own
// project list lives at #/chat. That list keeps the project surface and its #/projects links.
const legacyRoute = () => { const r = window.MosaicOrg?.route() ?? location.hash.slice(1); return r.startsWith('/chat') && !(window.MosaicOrg && r.split('?')[0] === '/chat'); };
const projectPath = id => legacyRoute() ? `#/chat/projects/${id}` : `#/projects/${id}`;
const threadPath = id => legacyRoute() ? `#/chat/threads/${id}` : `#/projects/threads/${id}`;
const roomPath = id => legacyRoute() ? `#/chat/rooms/${id}` : `#/projects/rooms/${id}`;
+2 -1
View File
@@ -33,8 +33,9 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
return;
}
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
// /api/workspace reads and writes across organizations (U1, U4b), so it keeps its own path.
window.fetch = (input, init) => {
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$))/.test(input))
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/levels(?:\?|$))/.test(input))
input = `/api/orgs/${id}/${input.slice('/api/'.length)}`;
return nativeFetch(input, init);
};
+3 -3
View File
@@ -1,13 +1,13 @@
<!doctype html>
<html lang="en" data-design="console" data-palette="harbor">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Dashboard · Mosaic Console</title>
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/inbox.css"><link rel="stylesheet" href="/requests.css"><link rel="stylesheet" href="/files.css"><link rel="stylesheet" href="/project-remote.css"><link rel="stylesheet" href="/shell.css">
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/inbox.css"><link rel="stylesheet" href="/requests.css"><link rel="stylesheet" href="/files.css"><link rel="stylesheet" href="/project-remote.css"><link rel="stylesheet" href="/shell.css"><link rel="stylesheet" href="/tasks-board.css">
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
<link rel="stylesheet" href="/project-archive.css"><link rel="stylesheet" href="/projects.css">
<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="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><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>
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/panes.css">
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><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="/panes.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>
+1 -1
View File
@@ -628,7 +628,7 @@
[$('org-quick-run'), 'click', quickRun],
// F2: the interview opens a conversation with the assistant; Open organization skips it.
[$('org-quick-interview'), 'click', async () => { const b = $('org-quick-interview'); b.disabled = true; if (!await window.MosaicOnboarding?.start({ ...quickDone, status })) b.disabled = false; }],
[$('org-quick-open-org'), 'click', () => { history.replaceState(null, '', `#/orgs/${encodeURIComponent(st.id)}/projects`); location.reload(); }],
[$('org-quick-open-org'), 'click', () => { history.replaceState(null, '', `#/orgs/${encodeURIComponent(st.id)}/chat`); location.reload(); }],
[$('org-open-form'), 'submit', e => { e.preventDefault(); const id = $('org-open-id').value; if (id) openExisting(id); }],
[$('org-form'), 'submit', save],
[$('org-form'), 'input', onFormInput],
+83
View File
@@ -0,0 +1,83 @@
/* Side by side (U5, sample 07 "EXT 8"): a bar, then two panes with a divider between them. Each pane
has its own header with the organization's mark and edge, and its own scroll. One pane and a
shown-pane switch on a phone. Loaded after shell.css; every rule is scoped to the side-by-side page. */
html[data-organizations] .content:has(> #bus-view > .sbs){padding:0}
html[data-organizations] .sbs{margin:0;display:flex;flex-direction:column;min-height:0;height:calc(100vh - var(--bar-h,53px) - var(--foot-h,33px))}
html[data-organizations] .sbs .panebar{display:flex;align-items:center;gap:10px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--canvas);flex-wrap:wrap}
html[data-organizations] .sbs .panebar h1{font-size:16px;font-weight:700;margin:0;line-height:1.4}
html[data-organizations] .sbs .panebar h1:focus{outline:none}
html[data-organizations] .sbs .panebar .note{font-size:13px;color:var(--muted)}
html[data-organizations] .sbs .panebar [data-pane-add]{margin-left:auto}
html[data-organizations] .sbs .ws-seg.pane-tabs{display:none;margin-left:0}
html[data-organizations] .sbs .pane-tabs>button{gap:5px;max-width:44vw}
html[data-organizations] .sbs .pane-tabs>button>span:last-child{overflow:hidden;text-overflow:ellipsis}
html[data-organizations] .panes{--sbs-a:1fr;--sbs-b:1fr;display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-height:0}
html[data-organizations] .panes.two{grid-template-columns:minmax(0,var(--sbs-a)) 1px minmax(0,var(--sbs-b))}
html[data-organizations] .pane{display:flex;flex-direction:column;min-width:0;min-height:0}
html[data-organizations] .pane[hidden]{display:none}
html[data-organizations] .pane-h{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--surface);box-shadow:inset 0 3px 0 var(--org,var(--line));min-width:0}
html[data-organizations] .pane-h .ttl{font-weight:700;color:var(--strong,var(--text));overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto}
html[data-organizations] .pane-h .ttl small{font-weight:500;color:var(--muted);margin-left:6px;font-size:inherit}
html[data-organizations] .pane-h [data-pane-state]:empty{display:none}
html[data-organizations] .pane-pick{flex:0 1 180px;min-width:0;max-width:220px;margin-left:auto;font:500 12px/1.4 var(--font);color:var(--text);background:var(--canvas);border:1px solid var(--line);border-radius:var(--r);padding:3px 6px;min-height:0}
html[data-organizations] .pane-h .tools{display:flex;gap:2px;flex:none}
html[data-organizations] .pane-tool{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:var(--r);color:var(--muted);border:0;background:none;padding:0;min-height:0;cursor:pointer}
html[data-organizations] .pane-tool:hover{background:var(--raised);color:var(--text)}
html[data-organizations] .pane-tool[hidden]{display:none}
html[data-organizations] .pane-tool svg.i{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
html[data-organizations] .pane-line{margin:0;padding:6px 14px;border-bottom:1px solid var(--line);font-size:13px;color:var(--text);background:color-mix(in srgb,var(--warn,#b7791f) 12%,var(--surface))}
html[data-organizations] .pane-line[hidden]{display:none}
html[data-organizations] .pane-body{overflow:auto;padding:14px;min-height:0;flex:1}
html[data-organizations] .pane-body:focus{outline:none}
html[data-organizations] .pane-body:focus-visible{outline:2px solid var(--focus,var(--action));outline-offset:-2px}
/* A page inside a pane is a section of the side-by-side page: smaller headings, no page chrome. */
html[data-organizations] .pane-body .ws h1{font-size:20px}
html[data-organizations] .pane-body .chat-area.pane-project{display:block;height:auto;min-height:0;overflow:visible;padding:0}
/* A project in a pane is compact (sample 07): the pane header already names the project and its
organization, so the page heading stays only for assistive technology and focus, the full-page
actions give way (North star and PRD are on the Requirements tab, Settings in the full view), and
the tabs, measures and current work come first. Project pages size some parts from the window
(projects.css); in a pane they size from the pane. */
html[data-organizations] .pane-body{container-type:inline-size}
html[data-organizations] .pane-body .project-header{display:block;width:auto;max-width:100%;margin:0 0 6px;padding:0}
html[data-organizations] .pane-body .project-header .project-heading,html[data-organizations] .pane-body .project-header .project-heading>div{display:block}
html[data-organizations] .pane-body .project-header-icon,html[data-organizations] .pane-body .project-actions{display:none}
html[data-organizations] .pane-body .project-header h1{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
html[data-organizations] .pane-body:has(.project-header h1:focus-visible){outline:2px solid var(--focus,var(--action));outline-offset:-2px}
html[data-organizations] .pane-body .project-header .project-heading p{margin:0;font-size:13px;color:var(--muted)}
html[data-organizations] .pane-body .project-tabs a{min-height:40px;padding:0 12px;font-size:14px}
html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:10px;padding:6px 0}
html[data-organizations] .pane-body .project-metrics a{padding:4px 12px;border-right:0;border-bottom:0;line-height:1.3}
html[data-organizations] .pane-body .project-metrics a span,html[data-organizations] .pane-body .project-metrics small{font-size:12px;overflow-wrap:anywhere}
html[data-organizations] .pane-body .project-metrics strong{font-size:20px}
html[data-organizations] .pane-body .project-metrics p{margin:6px 12px 0;padding-top:6px}
html[data-organizations] .pane-body .project-columns{grid-template-columns:minmax(0,1fr);gap:14px;margin-top:12px}
html[data-organizations] .pane-body .project-columns h2{font-size:16px;margin:12px 0 8px}
html[data-organizations] .pane-body .project-columns h3{font-size:14px}
html[data-organizations] .pane-body .project-columns .project-section-title h2,html[data-organizations] .pane-body .project-columns aside>h2:first-child{margin-top:0}
@container (min-width:560px){
html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(4,minmax(0,1fr))}
html[data-organizations] .pane-body .project-metrics a:not(:nth-child(4)){border-right:1px solid var(--line)}
}
/* The side-by-side page has its own bar; the page breadcrumb would name only one pane. */
body:has(#bus-view > .sbs) #breadcrumbs{display:none}
html[data-organizations] .content:has(> #bus-view > .sbs) > .org-cue{margin:8px 14px}
html[data-organizations] .pane-div{position:relative;background:var(--line);cursor:col-resize;touch-action:none;outline:none}
html[data-organizations] .pane-div::before{content:"";position:absolute;inset:0 -4px;z-index:1}
html[data-organizations] .pane-div:hover,html[data-organizations] .pane-div.dragging{background:var(--action)}
html[data-organizations] .pane-div:focus-visible{background:var(--action);box-shadow:0 0 0 2px var(--action)}
html[data-organizations] .pane-div[hidden]{display:none}
@media (max-width:760px){
html[data-organizations] .content:has(> #bus-view > .sbs){padding:0}
html[data-organizations] .sbs{height:auto}
html[data-organizations] .sbs .ws-seg.pane-tabs{display:inline-flex}
html[data-organizations] .sbs .hide-m{display:none}
html[data-organizations] .panes,html[data-organizations] .panes.two{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .pane.alt,html[data-organizations] .pane-div{display:none}
html[data-organizations] .pane-body{overflow:visible}
html[data-organizations] .pane-h{flex-wrap:wrap}
html[data-organizations] .pane-pick{flex:1 1 100%;max-width:none;order:5;margin-left:0}
}
+477
View File
@@ -0,0 +1,477 @@
// Side by side (U5, sample 07): two panes, each showing one page of any organization. A pane holds
// the organization dashboard, the Agents levels or a project page (overview, conversations, tasks,
// requirements, files), drawn by the same modules as the full view (workspace.js, projects.js) into
// the pane instead of #bus-view. Every read names the pane's organization, so the panes never share
// state. Pages that keep one global form, dialog or editor (a conversation, the room, settings, the
// queue, docs, the inbox) are not offered in a pane; their links open the full view.
//
// The panes' routes, the divider and the phone's shown pane live in the URL
// (#/orgs/<page>/side-by-side?a=<org>/<route>&b=<org>/<route>&w=<percent>&show=a|b), updated with
// replaceState so nothing reloads, and the last layout is remembered in this browser.
(() => {
'use strict';
if (!document.documentElement.hasAttribute('data-organizations')) return;
const data = window.MosaicWorkspaceData, ws = () => window.MosaicWorkspace;
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
const STORE = 'mosaic-side-by-side-v1', POLL = 10000, LIST_TTL = 3000, PROJECTS_TTL = 30000;
const MIN = 25, MAX = 75, STEP = 5;
const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const PROJECT = /^\/projects\/([A-Za-z0-9][A-Za-z0-9._-]{0,159})(?:\/(conversations|tasks|requirements|files))?$/;
const SECTIONS = { overview: 'Overview', conversations: 'Conversations', tasks: 'Tasks', requirements: 'Requirements', files: 'Files' };
const FOCUSABLE = 'a[href],button:not([disabled]),select,textarea,input,summary,[tabindex]:not([tabindex="-1"])';
const NAMES = { a: 'left', b: 'right' };
const load = () => { try { return localStorage.getItem(STORE); } catch { return null; } };
const save = value => { try { localStorage.setItem(STORE, value); } catch {} };
const paused = () => document.getElementById('pause')?.getAttribute('aria-pressed') === 'true';
const svg = d => `<svg class="i sm" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>`;
const FULL = svg('<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/>');
const CLOSE = svg('<path d="M6 6l12 12M18 6 6 18"/>');
const PLUS = svg('<path d="M12 5v14M5 12h14"/>');
// A pane's page: the routes a pane can draw. Anything else is not pane-safe.
function page(route) {
if (route === '/organization') return { kind: 'dashboard' };
if (route === '/agents') return { kind: 'agents' };
const m = PROJECT.exec(route);
if (m && m[1] !== 'threads' && m[1] !== 'rooms') return { kind: 'project', id: m[1], section: m[2] || 'overview' };
return null;
}
// "<org>/<route>" from the URL; null when either part is not one a pane draws.
function parsePane(value) {
if (typeof value !== 'string') return null;
const cut = value.indexOf('/');
if (cut < 1) return null;
const org = value.slice(0, cut), route = value.slice(cut);
return ORG.test(org) && page(route) ? { org, route } : null;
}
const paneValue = p => `${p.org}${p.route}`;
const clampW = n => Math.min(MAX, Math.max(MIN, Math.round(n)));
// ---------- shared reads (one organization list and project lists for both panes) ----------
let listAt = 0, listPromise = null, projectReads = new Map();
function orgList(fresh = false) {
if (!listPromise || fresh || Date.now() - listAt > LIST_TTL) {
listAt = Date.now();
listPromise = Promise.all([data.organizations(), ws()?.themes?.()]).then(([rows]) => (Array.isArray(rows) ? rows : null)).catch(() => null);
}
return listPromise;
}
function projectsOf(org, fresh = false) {
const hit = projectReads.get(org);
if (hit && !fresh && Date.now() - hit.at < PROJECTS_TTL) return hit.promise;
const promise = data.projects(org).then(b => (Array.isArray(b?.rows) ? b.rows.filter(p => p && typeof p.id === 'string') : null)).catch(() => null);
projectReads.set(org, { at: Date.now(), promise });
return promise;
}
// ---------- state ----------
let mounted = null; // { root, panes: [pane], w, show, gen }
let mountGen = 0;
let listCache = null; // the last organization list read, for the shown-pane tabs
function readQuery(query) {
const q = new URLSearchParams(query || '');
return { a: parsePane(q.get('a')), b: parsePane(q.get('b')), w: Number(q.get('w')), show: q.get('show'), named: q.has('a') || q.has('b') };
}
function queryOf(state) {
const q = new URLSearchParams();
state.panes.forEach((p, i) => q.set(i ? 'b' : 'a', paneValue(p)));
q.set('w', String(state.w));
q.set('show', state.show);
return q.toString().replace(/%2F/g, '/');
}
function writeUrl() {
if (!mounted) return;
const qs = queryOf(mounted);
save(qs);
const next = `#/orgs/${enc(window.MosaicOrg.id)}/side-by-side?${qs}`;
if (location.hash !== next) history.replaceState(null, '', next);
}
// The first pane is this page's organization; the second is the next organization on the list,
// or this one's Agents when there is no other.
async function defaults() {
const here = window.MosaicOrg.id, list = await orgList();
const other = (list || []).find(o => o && !o.invalid && o.id !== here && ORG.test(o.id));
return [{ org: here, route: '/organization' }, other ? { org: other.id, route: '/organization' } : { org: here, route: '/agents' }];
}
// ---------- markup ----------
function paneShell(key) {
const name = NAMES[key];
return `<section class="pane" id="pane-${key}" data-pane="${key}" aria-label="${name[0].toUpperCase()}${name.slice(1)} pane">
<div class="pane-h ws-o" data-pane-head>
<span data-pane-av></span><span class="ttl" data-pane-ttl></span><span data-pane-state></span>
<label class="ws-sr" for="pane-${key}-pick">Page in the ${name} pane</label>
<select id="pane-${key}-pick" class="pane-pick" data-pane-pick></select>
<span class="tools"><a class="pane-tool" data-pane-full href="#" title="Open in full view"><span class="ws-sr" data-pane-full-label>Open in full view</span>${FULL}</a><button type="button" class="pane-tool" data-pane-close title="Close pane" aria-label="Close the ${name} pane">${CLOSE}</button></span>
</div>
<p class="pane-line" data-pane-line role="status"></p>
<div class="pane-body" data-pane-body tabindex="0" aria-label="${name[0].toUpperCase()}${name.slice(1)} pane content"></div>
</section>`;
}
function frame() {
return `<section class="sbs" data-side-by-side aria-labelledby="sbs-title">
<div class="panebar"><h1 id="sbs-title" tabindex="-1">Side by side</h1><span class="note" data-sbs-note></span>
<span class="ws-seg pane-tabs" role="group" aria-label="Shown pane" data-sbs-tabs></span>
<button type="button" class="ws-btn hide-m" data-pane-add>${PLUS}Add pane</button></div>
<div class="panes" data-sbs-panes>${paneShell('a')}<div class="pane-div" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Resize panes" aria-controls="pane-a pane-b" aria-valuemin="${MIN}" aria-valuemax="${MAX}" data-sbs-divider></div>${paneShell('b')}</div>
</section>`;
}
// Orgs in the picker: every organization on the list. A pane draws a valid one's dashboard, Agents or
// any project the organization's project read lists; an invalid one is listed and cannot be chosen.
async function pickerHtml(p, list) {
const groups = await Promise.all((list || []).filter(o => o && ORG.test(o.id)).map(async org => {
const name = ws().label(org);
if (org.invalid) return `<optgroup label="${esc(name)} · invalid"><option disabled value="">Organization invalid</option></optgroup>`;
const rows = await projectsOf(org.id);
const opt = (route, text) => `<option value="${esc(`${org.id}${route}`)}">${esc(text)}</option>`;
const projects = rows === null ? '<option disabled value="">Projects unavailable</option>' : rows.filter(r => !r.archived && PROJECT.test(`/projects/${r.id}`)).map(r => opt(`/projects/${r.id}`, `Project: ${r.name || r.id}`)).join('');
return `<optgroup label="${esc(name)}${org.busHealthy ? '' : ' · bus unavailable'}">${opt('/organization', 'Dashboard')}${opt('/agents', 'Agents')}${projects}</optgroup>`;
}));
let html = groups.join('');
const value = paneValue(p);
// A project section, or a page the list no longer names, is still the pane's page: keep it selectable.
if (!html.includes(`value="${esc(value)}"`)) html = `<option value="${esc(value)}">${esc(p.title || value)}</option>${html}`;
return html;
}
// ---------- drawing one pane ----------
const els = p => ({
head: p.el.querySelector('[data-pane-head]'), av: p.el.querySelector('[data-pane-av]'), ttl: p.el.querySelector('[data-pane-ttl]'),
state: p.el.querySelector('[data-pane-state]'), pick: p.el.querySelector('[data-pane-pick]'), full: p.el.querySelector('[data-pane-full]'),
fullLabel: p.el.querySelector('[data-pane-full-label]'), close: p.el.querySelector('[data-pane-close]'), line: p.el.querySelector('[data-pane-line]'), body: p.el.querySelector('[data-pane-body]'),
});
const setHtml = (el, html) => { if (el && el.innerHTML !== html) el.innerHTML = html; };
const setText = (el, text) => { if (el && el.textContent !== text) el.textContent = text; };
// Links a page draws are relative to the page's organization. In a pane they name the pane's
// organization; reads the page links to (/api/...) name it too.
function rewrite(p, root) {
const prefix = `#/orgs/${enc(p.org)}`;
for (const a of root.querySelectorAll('a[href^="#/"]')) if (!a.getAttribute('href').startsWith('#/orgs/')) a.setAttribute('href', prefix + a.getAttribute('href').slice(1));
for (const a of root.querySelectorAll('a[href^="/api/"]')) if (!a.getAttribute('href').startsWith('/api/orgs/')) a.setAttribute('href', `/api/orgs/${enc(p.org)}/${a.getAttribute('href').slice(5)}`);
// The pane's title is the page's heading; the side-by-side page keeps the one level-1 heading.
for (const h of root.querySelectorAll('h1:not([aria-level])')) h.setAttribute('aria-level', '2');
}
// Focus and scroll survive a repaint of this pane only. Focus in the other pane is never taken.
function capture(p, root) {
const a = document.activeElement, inside = !!a && a !== root && root.contains(a);
return {
top: root.scrollTop,
focus: inside ? { key: a.dataset?.focusKey || null, href: a.getAttribute('href'), index: [...root.querySelectorAll(FOCUSABLE)].indexOf(a), h1: a.tagName === 'H1' } : null,
};
}
function restore(p, root, kept) {
root.scrollTop = kept.top;
const f = kept.focus, a = document.activeElement;
if (!f || (a && a !== document.body && !root.contains(a))) return;
const target = (f.key && root.querySelector(`[data-focus-key="${CSS.escape(f.key)}"]`))
|| (f.href && [...root.querySelectorAll('a[href]')].find(x => x.getAttribute('href') === f.href))
|| (f.h1 && root.querySelector('h1'))
|| root.querySelectorAll(FOCUSABLE)[f.index];
(target || root).focus({ preventScroll: true });
}
function paintBody(p, html) {
const { body } = els(p);
if (p.html === html && body.childElementCount) return;
const kept = capture(p, body);
body.innerHTML = html; p.html = html; p.project = null;
rewrite(p, body);
restore(p, body, kept);
}
// A focus move that finishes after an asynchronous read happens only if the viewer has not
// moved on: focus is still where it was when the action started, nowhere, or inside this pane.
function mayFocus(p, before) {
const a = document.activeElement;
return !a || a === document.body || a === before || p.el.contains(a);
}
async function drawPane(p, { fresh = false, focusHead = false } = {}) {
if (!mounted || !p.el.isConnected) return;
const before = document.activeElement;
const turn = ++p.turn, gen = mountGen, live = () => mounted && gen === mountGen && turn === p.turn && p.el.isConnected;
clearTimeout(p.timer);
const e = els(p), what = page(p.route);
const list = await orgList(fresh);
if (!live()) return;
if (list) listCache = list;
const org = list?.find(o => o?.id === p.org) || null;
const name = org ? ws().label(org) : p.org;
// Header: the organization's mark, the page's title, its state, the picker and the tools.
if (org) e.head.setAttribute('style', ws().colours(org)); else e.head.removeAttribute('style');
setHtml(e.av, org ? ws().av(org, 'md') : '<span class="ws-av md" aria-hidden="true">?</span>');
const [scls, sglyph, sword] = org ? ws().state(org) : ['s-unknown', '?', 'not on your list'];
setHtml(e.state, org?.busHealthy && !org.invalid ? '' : ws().dot(scls, sglyph, sword, true));
const fullHref = `#/orgs/${enc(p.org)}${p.route}`;
if (e.full.getAttribute('href') !== fullHref) e.full.setAttribute('href', fullHref);
let line = '', html = null;
if (!list) line = 'The organization list could not be read. This pane shows what its own reads return.';
else if (!org) { line = `${p.org} is not on your organization list.`; html = '<p class="ws-line">Nothing is read for an organization that is not on your list.</p>'; }
else if (org.invalid) { line = `${name}: organization invalid${org.reason ? ` (${org.reason})` : ''}.`; html = '<p class="ws-line">Nothing is read from an invalid organization. Fix its file under Settings, Organizations.</p>'; }
else if (!org.busHealthy) line = `${name}: bus unavailable. Reads from this organization may fail; the other pane is not affected.`;
setText(e.line, line); e.line.hidden = !line;
let title = what?.kind === 'agents' ? 'Agents' : 'Dashboard';
if (html === null && what?.kind === 'dashboard') html = await ws().dashboard(p.org, { pane: true, idPrefix: `pane-${p.key}-` }).catch(() => '<p class="ws-line">Dashboard unavailable.</p>');
else if (html === null && what?.kind === 'agents') html = await ws().agentGrid('', p.org).catch(() => '<p class="ws-line">Agents unavailable.</p>');
if (!live()) return;
if (html === null && what?.kind === 'project') {
title = await drawProject(p, e, what, fresh, live);
if (title === null || !live()) return;
} else if (html !== null) paintBody(p, html);
p.title = title;
setHtml(e.ttl, `${esc(title)}<small>${esc(name)}</small>`);
p.el.setAttribute('aria-label', `${NAMES[p.key][0].toUpperCase()}${NAMES[p.key].slice(1)} pane: ${title}, ${name}`);
setText(e.fullLabel, `Open ${title}, ${name}, in full view`);
// The picker is rebuilt only while it is closed and unfocused, so a choice in progress stays.
if (document.activeElement !== e.pick) {
const options = await pickerHtml(p, list);
if (!live()) return;
if (document.activeElement !== e.pick) { setHtml(e.pick, options); e.pick.value = paneValue(p); }
}
paintBar(); fit();
if (focusHead && mayFocus(p, before)) (e.body.querySelector('h1') || e.body).focus({ preventScroll: true });
p.timer = setTimeout(() => tick(p), POLL);
}
function tick(p) {
if (!mounted || !p.el.isConnected) return;
if (paused()) { p.timer = setTimeout(() => tick(p), POLL); return; }
void drawPane(p);
}
// A project page: projects.js's header and content, mounted into the pane with the pane's
// organization, read-only (a pane never sends).
// Every write below happens only while this draw is still the pane's current one (live()), so a
// slow read never paints an old page or organization over the one the viewer has since chosen.
async function drawProject(p, e, what, fresh, live) {
const org = p.org;
const [projects, threadsRead] = await Promise.all([projectsOf(org, fresh), data.threads(org).catch(() => null)]);
if (!live() || p.org !== org) return null;
const project = projects?.find(r => r.id === what.id);
const sectionName = SECTIONS[what.section];
if (!project) {
paintBody(p, projects === null ? '<p class="ws-line">Projects unavailable. The organization did not answer the project read.</p>' : '<p class="ws-line">This project is not in the organization\'s project list.</p>');
return what.id;
}
const threads = Array.isArray(threadsRead?.rows) ? threadsRead.rows : [];
const head = window.MosaicProjects.header(project, what.section, threads);
const conv = what.section === 'conversations' ? conversations(project, threads, threadsRead) : null;
const key = JSON.stringify([org, project.id, what.section, head, conv]);
const { body } = e;
if (p.project?.key !== key) {
const kept = capture(p, body);
body.innerHTML = `<div class="chat-area pane-project">${head}${conv ?? '<div data-project-content>Reading…</div>'}</div>`;
p.html = null; p.project = { key };
rewrite(p, body);
restore(p, body, kept);
}
const content = body.querySelector('[data-project-content]');
if (content) await window.MosaicProjects.mountContent({ container: content, project, threads, section: what.section, canSend: false, org, pane: true, views: viewsOf(p, org) });
return what.section === 'overview' ? project.name || project.id : `${project.name || project.id} · ${sectionName}`;
}
// A pane's request choice and PRD disclosure, per organization, kept on the pane so a repaint
// that replaces the project content keeps them.
function viewsOf(p, org) {
if (!p.views.has(org)) p.views.set(org, { selected: new Map(), expanded: new Set() });
return p.views.get(org);
}
function conversations(project, threads, read) {
if (!read) return '<p class="ws-line">Conversations unavailable.</p>';
const own = threads.filter(t => t && !t.shared && t.project === project.id && typeof t.id === 'string');
const rows = own.map(t => `<a class="project-conversation-row" href="#/projects/threads/${enc(t.id)}" data-focus-key="${esc(`conv:${t.id}`)}"><span class="project-conversation-icon" aria-hidden="true">◌</span><span><strong>${esc(t.name || t.id)}</strong><small>With ${esc(t.instance || 'no agent recorded')}</small></span><span aria-hidden="true">›</span></a>`).join('');
return `<div class="project-section-title"><h2>Conversations</h2></div><div data-project-conversations>${rows || '<p>No conversations yet.</p>'}</div><p class="ws-line">A conversation opens in its full view.</p>`;
}
// ---------- the bar, tabs, divider and layout ----------
function paintBar() {
if (!mounted) return;
const { root, panes, show } = mounted, two = panes.length === 2;
setText(root.querySelector('[data-sbs-note]'), `${panes.length} ${panes.length === 1 ? 'pane' : 'panes'} · each pane reads its own organization's bus and keeps its own state`);
const tabs = root.querySelector('[data-sbs-tabs]'), focusedTab = tabs.contains(document.activeElement) ? document.activeElement.dataset.paneShow : null;
const list = listCache;
const html = panes.map(p => {
const org = list?.find(o => o?.id === p.org);
return `<button type="button" data-pane-show="${p.key}" aria-pressed="${show === p.key}" aria-controls="pane-${p.key}">${org ? ws().av(org, 'xs') : ''}<span>${esc(p.title || 'Reading…')}</span></button>`;
}).join('');
if (tabs.innerHTML !== html) { tabs.innerHTML = html; if (focusedTab) tabs.querySelector(`[data-pane-show="${focusedTab}"]`)?.focus({ preventScroll: true }); }
const add = root.querySelector('[data-pane-add]');
add.disabled = two; add.title = two ? 'Side by side holds two panes. Close one to choose another.' : 'Open a second pane';
for (const p of panes) { const c = p.el.querySelector('[data-pane-close]'); c.hidden = !two; }
}
function layout() {
if (!mounted) return;
const { root, panes, w, show } = mounted, two = panes.length === 2;
const box = root.querySelector('[data-sbs-panes]'), divider = root.querySelector('[data-sbs-divider]');
box.classList.toggle('two', two);
box.style.setProperty('--sbs-a', `${w}fr`); box.style.setProperty('--sbs-b', `${100 - w}fr`);
divider.hidden = !two;
divider.setAttribute('aria-valuenow', String(w));
divider.setAttribute('aria-valuetext', `Left pane ${w} percent, right pane ${100 - w} percent`);
root.querySelector('#pane-b').hidden = !two;
for (const p of panes) p.el.classList.toggle('alt', show !== p.key);
fit();
}
// Wide screens: the panes fill the window below the bar and each scrolls on its own.
const wide = window.matchMedia('(min-width: 761px)');
function fit() {
if (!mounted) return;
const { root } = mounted;
if (!wide.matches) { root.style.height = ''; return; }
const foot = document.querySelector('body > .foot, .foot')?.getBoundingClientRect().height || 0;
const top = root.getBoundingClientRect().top;
root.style.height = `${Math.max(360, Math.floor(window.innerHeight - Math.max(top, 0) - foot))}px`;
}
function setWidth(w, persist = true) {
if (!mounted) return;
const next = clampW(w);
if (next === mounted.w) return;
mounted.w = next; layout(); if (persist) writeUrl();
}
// ---------- events (bound once to the mount root) ----------
function bind(root) {
const divider = root.querySelector('[data-sbs-divider]');
divider.addEventListener('keydown', e => {
const w = mounted?.w; if (w === undefined) return;
const next = { ArrowLeft: w - STEP, ArrowDown: w - STEP, ArrowRight: w + STEP, ArrowUp: w + STEP, PageDown: w - 10, PageUp: w + 10, Home: MIN, End: MAX }[e.key];
if (next === undefined) return;
e.preventDefault(); setWidth(next);
});
divider.addEventListener('pointerdown', e => {
if (e.button !== 0) return;
e.preventDefault(); divider.focus({ preventScroll: true }); divider.setPointerCapture(e.pointerId); divider.classList.add('dragging');
const box = root.querySelector('[data-sbs-panes]').getBoundingClientRect();
const move = ev => setWidth(((ev.clientX - box.left) / box.width) * 100, false);
const up = () => { divider.removeEventListener('pointermove', move); divider.removeEventListener('pointerup', up); divider.removeEventListener('pointercancel', up); divider.classList.remove('dragging'); writeUrl(); };
divider.addEventListener('pointermove', move); divider.addEventListener('pointerup', up); divider.addEventListener('pointercancel', up);
});
root.addEventListener('change', e => {
const pick = e.target.closest?.('[data-pane-pick]'); if (!pick || !mounted) return;
const p = mounted.panes.find(x => x.el.contains(pick)), next = parsePane(pick.value);
if (!p || !next) return;
go(p, next, false);
});
root.addEventListener('click', e => {
if (!mounted) return;
const show = e.target.closest?.('[data-pane-show]');
if (show) { mounted.show = show.dataset.paneShow === 'b' && mounted.panes.length === 2 ? 'b' : 'a'; layout(); paintBar(); writeUrl(); return; }
if (e.target.closest?.('[data-pane-add]')) { void addPane(); return; }
const close = e.target.closest?.('[data-pane-close]');
if (close) { closePane(mounted.panes.find(x => x.el.contains(close))); return; }
// A plain click on a link to a page a pane draws, in the pane's own organization, opens it in
// the pane. Anything else (another organization, a page a pane does not draw, a new tab) is
// an ordinary link to the full view.
const a = e.target.closest?.('a[href^="#/orgs/"]');
if (!a || a.hasAttribute('data-pane-full') || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const p = mounted.panes.find(x => x.el.contains(a)); if (!p) return;
const m = /^#\/orgs\/([^/?#]+)(\/[^?#]*)$/.exec(a.getAttribute('href'));
let org, route;
try { org = decodeURIComponent(m?.[1] ?? ''); route = decodeURIComponent(m?.[2] ?? ''); } catch { return; }
if (org !== p.org || !page(route)) return;
e.preventDefault();
go(p, { org, route }, true);
});
}
function go(p, next, focusHead) {
p.org = next.org; p.route = next.route; p.html = null; p.project = null; p.title = null;
els(p).body.scrollTop = 0;
writeUrl();
void drawPane(p, { focusHead });
}
async function addPane() {
if (!mounted || mounted.panes.length !== 1) return;
const before = document.activeElement;
const [a] = mounted.panes, list = await orgList();
if (!mounted || mounted.panes.length !== 1) return;
const other = (list || []).find(o => o && !o.invalid && o.id !== a.org && ORG.test(o.id));
const b = makePane('b', other ? { org: other.id, route: '/organization' } : { org: a.org, route: a.route === '/agents' ? '/organization' : '/agents' });
mounted.panes.push(b); mounted.show = 'b';
layout(); paintBar(); writeUrl();
await drawPane(b);
if (mounted?.panes.includes(b) && mayFocus(b, before)) els(b).pick.focus({ preventScroll: true });
}
function closePane(p) {
if (!mounted || !p || mounted.panes.length !== 2) return;
clearTimeout(p.timer); p.turn++;
const keep = mounted.panes.find(x => x !== p);
// The remaining pane becomes the first: its page moves into pane a's element.
const a = mounted.panes[0];
if (keep !== a) {
const b = mounted.panes[1];
clearTimeout(b.timer); b.turn++;
a.org = keep.org; a.route = keep.route; a.html = null; a.project = null; a.title = keep.title; a.views = keep.views;
}
setHtml(els(mounted.panes[1]).body, ''); mounted.panes[1].html = null; mounted.panes[1].project = null;
mounted.panes = [a]; mounted.show = 'a';
layout(); paintBar(); writeUrl();
// The close button just left the page; focus goes to the remaining pane's picker now, and again
// after its draw only if the viewer has not moved elsewhere meanwhile.
els(a).pick.focus({ preventScroll: true });
const before = document.activeElement;
void drawPane(a).then(() => { if (mounted?.panes[0] === a && mayFocus(a, before)) els(a).pick.focus({ preventScroll: true }); });
}
function makePane(key, at) {
const el = mounted.root.querySelector(`#pane-${key}`);
return { key, el, org: at.org, route: at.route, turn: 0, timer: null, html: null, project: null, title: null, views: new Map() };
}
// ---------- public ----------
async function mount(container, query, { navigated = false } = {}) {
unmount();
const gen = ++mountGen;
container.innerHTML = frame();
const root = container.querySelector('[data-side-by-side]');
mounted = { root, panes: [], w: 50, show: 'a', gen, observers: [] };
// projects.js paints a project's content after its own reads; its links are rewritten as they
// land. One observer per pane element for the life of the mount, applying whichever pane object
// holds that element now, so a closed and re-added pane never runs an old organization's rewrite.
const m = mounted;
for (const key of ['a', 'b']) {
const body = root.querySelector(`#pane-${key} [data-pane-body]`);
const observer = new MutationObserver(() => {
if (mounted !== m) return;
const p = m.panes.find(x => x.key === key);
if (p) rewrite(p, body);
});
observer.observe(body, { childList: true, subtree: true });
m.observers.push(observer);
}
bind(root);
window.addEventListener('resize', fit);
document.title = 'Side by side · Mosaic Console';
if (navigated) root.querySelector('h1').focus({ preventScroll: true });
let wanted = readQuery(query);
if (!wanted.named) { const kept = readQuery(load() || ''); if (kept.a) wanted = { ...kept, named: true }; }
let at = [wanted.a, wanted.b].filter(Boolean);
if (!at.length) at = await defaults();
if (!mounted || gen !== mountGen) return;
listCache = await orgList();
if (!mounted || gen !== mountGen) return;
mounted.panes = at.slice(0, 2).map((x, i) => makePane(i ? 'b' : 'a', x));
mounted.w = Number.isFinite(wanted.w) && wanted.w ? clampW(wanted.w) : 50;
mounted.show = wanted.show === 'b' && mounted.panes.length === 2 ? 'b' : 'a';
layout(); paintBar(); writeUrl();
await Promise.all(mounted.panes.map(p => drawPane(p)));
}
function unmount() {
if (!mounted) return;
for (const p of mounted.panes) { clearTimeout(p.timer); p.turn++; }
for (const o of mounted.observers) o.disconnect();
window.removeEventListener('resize', fit);
mounted = null; mountGen++;
}
// The Console's Refresh: both panes read again now, each keeping its own focus and scroll.
function refresh() {
if (!mounted) return;
projectReads = new Map();
void orgList(true).then(rows => { if (rows) listCache = rows; });
for (const p of mounted.panes) void drawPane(p, { fresh: true });
}
// The list the tabs draw from follows each fresh read of the organization list.
document.addEventListener('mosaic-org-list', e => { if (Array.isArray(e.detail?.rows)) listCache = e.detail.rows; });
window.MosaicPanes = Object.freeze({ mount, unmount, refresh, page });
})();
+28 -16
View File
@@ -31,12 +31,15 @@
let treeRows=[], treeThreads=[], treeGeneration=0, treeState, treeRead=null, treeReadAt=0;
const treeFocus = new WeakMap();
try { treeState=JSON.parse(localStorage.getItem(storageKey) || '{}'); if (!treeState || Array.isArray(treeState) || typeof treeState !== 'object') treeState={}; } catch { treeState={}; }
// A side-by-side pane reads its own organization: its reads name that organization explicitly.
const at=(org,url)=>org && url.startsWith('/api/') ? `/api/orgs/${encodeURIComponent(org)}/${url.slice(5)}` : url;
async function read(url) {
const response=await fetch(url,{cache:'no-store',redirect:'manual'});
if(response.status===401 || response.type==='opaqueredirect') { document.dispatchEvent(new CustomEvent('mosaic-signed-out',{detail:{signIn:true}})); throw Error('signed-out'); }
if(!response.ok) { const body=await response.json().catch(()=>null); const error=Error('unavailable'); error.status=response.status; error.code=body?.error; throw error; }
return response.json();
}
const readAt=url=>read(url);
function currentProject(route) {
const id=route.match(/^\/(?:projects|chat)\/(?:projects\/|rooms\/)?([0-9a-f-]{36})/i)?.[1];
if(id) return id;
@@ -104,8 +107,9 @@
const same=(a,b)=>JSON.stringify(canonical(a))===JSON.stringify(canonical(b));
const material=(d,s)=>d && d.candidate && s?.candidate && same(d.candidate,s.candidate) && d.candidate.request===s.request && d.candidate.project===s.project && d.candidate.mode_revision===s.mode_revision && typeof d.prd==='string' && Array.isArray(d.requirements) && Array.isArray(d.candidate.requirements) && d.requirements.length===d.candidate.requirements.length && d.requirements.every((r,i)=>r && r.id===d.candidate.requirements[i]?.id && r.title===d.candidate.requirements[i]?.title && typeof r.text==='string') && d.snapshot && Number.isSafeInteger(d.snapshot.revision) && d.snapshot.revision>=0 && d.snapshot.id===s.snapshot && (d.snapshot.id===null || requestId(d.snapshot.id) && d.snapshot.revision>0);
const ownThreads=(project,threads)=>threads.filter(t=>t.project===project.id && !t.shared && UUID.test(t.id));
async function gather(project,threads) {
const own=ownThreads(project,threads), key=project.id+':'+String(project.repo)+':'+own.map(t=>t.id).join(',');
async function gather(project,threads,org=null) {
const read=url=>readAt(at(org,url));
const own=ownThreads(project,threads), key=(org?org+'|':'')+project.id+':'+String(project.repo)+':'+own.map(t=>t.id).join(',');
const prior=cache.get(key); if(prior && Date.now()-prior.at<10000) return prior.promise;
const started=epoch;
const promise=(async()=>{
@@ -133,7 +137,7 @@
const [draft,workspace]=await Promise.all([read(base+'/draft').catch(()=>null),read(base+'/workspace').catch(()=>null)]);
return {...item,state,draft:material(draft,state)?draft:null,materialFailed:!!state.candidate && !material(draft,state),workspace};
}))).filter(Boolean);
const [tasks,catalog,room]=await Promise.all([read('/api/bus/tasks').catch(()=>null),read('/api/chat/instances').catch(()=>null),readRoomStatus(project)]);
const [tasks,catalog,room]=await Promise.all([read('/api/bus/tasks').catch(()=>null),read('/api/chat/instances').catch(()=>null),readRoomStatus(project,org)]);
const members=Array.isArray(catalog?.rows)?catalog.rows.filter(m=>typeof m?.instance==='string' && own.some(t=>t.instance===m.instance && t.name===t.instance)).length:null;
let docs=project.repo ? null : {files:[]}, north=null, recordsMissing=false, northMissing=false, northRecordsMissing=false;
if(project.repo && /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/.test(project.repo)) {
@@ -145,9 +149,9 @@
})();
cache.set(key,{at:Date.now(),promise}); return promise;
}
async function readRoomStatus(project) {
async function readRoomStatus(project,org=null) {
try {
const response=await fetch(`/api/chat/rooms/${encodeURIComponent(project.id)}/messages?limit=100`,{cache:'no-store',redirect:'manual'});
const response=await fetch(at(org,'/api/chat/rooms/')+`${encodeURIComponent(project.id)}/messages?limit=100`,{cache:'no-store',redirect:'manual'});
if(response.status===401 || response.type==='opaqueredirect') { document.dispatchEvent(new CustomEvent('mosaic-signed-out',{detail:{signIn:true}})); return 'Unavailable'; }
const body=await response.json();
if(response.ok && Array.isArray(body?.rows) && typeof body.hasMore==='boolean') return 'Open';
@@ -194,11 +198,18 @@
return `<h2>Reviewed deliveries <span class="project-count">${f.reviewed.length}</span></h2>${f.reviewed.length?f.reviewed.map(({r,file})=>`<div class="project-panel"><span class="project-delivery-icon" aria-hidden="true">▤</span><strong>${clean(file.name)}</strong> <span class="project-chip success">Reviewed</span><p>Request ${clean(r.id)} · from ${clean(r.thread.name)} · Reviewed delivery. Not landed or deployed.</p><div class="project-delivery-actions"><a class="btn" target="_blank" rel="noopener noreferrer" href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}/text">Open as text</a> <a class="btn" download href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}">Download</a>${deliveryPr(r)?` <a class="btn" target="_blank" rel="noopener noreferrer" href="${esc(deliveryPr(r))}">Open pull request</a>`:''}</div><p>Open shows plain text in a new tab. Downloaded HTML can be opened locally after saving.</p></div>`).join(''):`<p>${data.disabled?'Files are unavailable.':data.failed?'Reviewed files are unavailable.':data.requests.length?'No reviewed file is available.':'No request files yet. Files appear when a request in this project produces them.'}</p>`}<h2>Draft workspace <span class="project-count">${f.drafts.length}</span></h2><p>Names only. Draft files may change and are not reviewed deliveries.</p><div class="project-panel project-file-list">${f.drafts.length?f.drafts.map(({r,name})=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><span>Request ${clean(r.id)} · <span class="project-chip">Draft</span></span></div>`).join(''):data.failed || data.requests.some(r=>r.workspace?.available!==true || !Array.isArray(r.workspace.entries))?'<p>Could not read draft workspace. Try again later.</p>':'<p>No draft files found.</p>'}${data.requests.some(r=>r.workspace?.truncated)?'<p>The draft list was truncated.</p>':''}</div><h2>Project documents ${docs?`<span class="project-count">${docs.length}</span>`:''}</h2><div class="project-panel project-file-list">${!project.repo?'No linked repository.':data.recordsMissing?window.MosaicRecords.markup(project.repo):!docs?'Project documents unavailable. Docs could not read this project’s repository.':docs.length?docs.map(name=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><a href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(name)}">Open in Docs</a></div>`).join(''):'No project documents found.'}</div>`;
}
const expandedPrds = new Set();
function requirementView(data,project,selected) {
// U5: a project in a Side by side pane keeps its own request choice and PRD disclosure, so two
// panes showing the same project (or two organizations' projects with one id) never share them.
// The pane passes `views`, kept on the pane per organization, so the state outlives a repaint
// that replaces the content element (a new conversation changes the header). Without it, the
// state lives as long as the container.
const paneViews = new WeakMap();
const viewState=(container,pane,views)=>{if(!pane) return {selected:selectedRequests,expanded:expandedPrds}; if(views?.selected instanceof Map && views.expanded instanceof Set) return views; if(!paneViews.has(container)) paneViews.set(container,{selected:new Map(),expanded:new Set()}); return paneViews.get(container);};
function requirementView(data,project,selected,expandedSet=expandedPrds) {
const options=data.requests.filter(r=>r.draft), r=options.find(x=>x.id===selected)||options[0], d=r?.draft;
const north=typeof data.north?.content==='string'?data.north.content:null;
const select=options.length>1?`<label>Request <select id="project-request-select">${options.map(x=>`<option value="${clean(x.id)}"${x===r?' selected':''}>${clean(x.id)} · ${clean(x.thread.name)}</option>`).join('')}</select></label>`:'';
const candidate=d?.candidate, expanded=r && expandedPrds.has(project.id+':'+r.id);
const candidate=d?.candidate, expanded=r && expandedSet.has(project.id+':'+r.id);
const docPath=candidate?.prd_path, docLink=project.repo && typeof docPath==='string' && /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(docPath) ? `#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(docPath)}` : null;
const rows=candidate ? [['PRD version',candidate.prd_version],['Approved snapshot',d.snapshot.id || 'None'],['PRD file',docPath],['PRD blob',candidate.prd_blob],['Records commit',candidate.records_commit],['Confirmed intent commit',candidate.spec_commit],['Mode revision',candidate.mode_revision]].map(([label,value])=>`<div class="project-provenance-row"><span>${label}</span>${label==='PRD file' && docLink?`<a href="${docLink}"><code>${clean(value)}</code></a>`:`<code>${clean(value)}</code>`}</div>`).join('') : '<p>Unavailable until a PRD is read.</p>';
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?`<p>${window.MosaicRecords.markup(project.repo)}</p>`:data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details data-restore-key="req:${clean(r.id)}:${clean(req.id)}"><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details data-restore-key="provenance"${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
@@ -241,31 +252,32 @@
const wanted=new Set((restore.disclosures || []).map(String)), closed=new Set((restore.closed || []).map(String));
[...root.querySelectorAll('details')].forEach((el,i)=>{const key=restoreKey(el,i);if(wanted.has(key)) el.open=true;else if(el.dataset.restoreKey && closed.has(key)) el.open=false;});
const f=restore.projectFocus; if(!f) return;
const focus=f.id ? document.getElementById(f.id) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index];
const focus=f.id ? root.querySelector('#'+CSS.escape(f.id)) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index];
focus?.focus({preventScroll:true});
}
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt}) {
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null,pane=false,views:paneState=null}) {
if(!container || !project) return;
const generation=epoch, run=(contentRuns.get(container) || 0)+1;
contentRuns.set(container,run);
if (!contentPaints.has(container)) container.textContent='Reading…';
const data=await gather(project,threads).catch(()=>null);
const data=await gather(project,threads,org).catch(()=>null);
if(!container.isConnected || generation!==epoch || contentRuns.get(container)!==run || document.body.classList.contains('signed-out')) return;
if(!data){contentPaints.delete(container);container.textContent='Project data unavailable. Refresh to try again.';return;}
const views=viewState(container,pane,paneState), selectedRequests=views.selected, expandedPrds=views.expanded;
const selected=selectedRequests.get(project.id);
if(selected && !data.requests.some(r=>r.id===selected && r.draft)) selectedRequests.delete(project.id);
const selection=selectedRequests.get(project.id);
const view = section==='requirements'?requirementView(data,project,selection):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
const view = section==='requirements'?requirementView(data,project,selection,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
const selectedRequest=data.requests.find(r=>r.id===(selection||data.requests.find(x=>x.draft)?.id));
// The generated presentation and separately mounted Markdown determine visible content.
const signature=JSON.stringify([view, section==='requirements' ? selectedRequest?.draft?.prd : null, ['requirements','overview'].includes(section) ? data.north?.content : null, data.failed && ['files','tasks'].includes(section)]);
const header=container.closest('.chat-area')?.querySelector('.project-tabs');
if(header){const tasks=header.querySelector(`a[href="${path(project.id,'tasks')}"]`), files=header.querySelector(`a[href="${path(project.id,'files')}"]`);const f=filesData(data);if(tasks && !tasks.querySelector('.project-count') && !data.failed)tasks.insertAdjacentHTML('beforeend',` <span class="project-count">${taskRefs(data).length}</span>`);if(files && !files.querySelector('.project-count') && !data.failed && f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries)))files.insertAdjacentHTML('beforeend',` <span class="project-count">${f.reviewed.length+f.drafts.length+f.docs.length}</span>`);}
if(header){const tab=key=>header.querySelector(`a[href$="${path(project.id,key).slice(1)}"]`), tasks=tab('tasks'), files=tab('files');const f=filesData(data);if(tasks && !tasks.querySelector('.project-count') && !data.failed)tasks.insertAdjacentHTML('beforeend',` <span class="project-count">${taskRefs(data).length}</span>`);if(files && !files.querySelector('.project-count') && !data.failed && f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries)))files.insertAdjacentHTML('beforeend',` <span class="project-count">${f.reviewed.length+f.drafts.length+f.docs.length}</span>`);}
revealTab(header);
// Retained controls (the request selector) draw from the newest accepted read, even when
// this read changes nothing visible and the DOM is kept.
contentModels.set(container,{data,project,section,canSend,roomReceipt});
window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
if(!pane) window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
if (contentPaints.get(container) === signature) return;
contentPaints.set(container, signature);
// Capture at commit time: the user may have interacted while the read was pending.
@@ -273,7 +285,7 @@
const liveRestore=contentPaints.has(container) && container.children.length ? captureView(container) : null;
const draw=selected=>{
const {data,project,section,canSend,roomReceipt}=contentModels.get(container);
container.innerHTML=section==='requirements'?requirementView(data,project,selected):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
container.innerHTML=section==='requirements'?requirementView(data,project,selected,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
if(data.failed && ['files','tasks'].includes(section)) container.insertAdjacentHTML('afterbegin','<p role="status">Some request data could not be read; this list may be incomplete.</p>');
const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id));
const md=container.querySelector('[data-prd]'); if(md && selectedRequest?.draft) { md.replaceChildren(window.MosaicMarkdown?.render(selectedRequest.draft.prd) || document.createTextNode(selectedRequest.draft.prd)); const toggle=container.querySelector('.project-prd-toggle'); toggle.hidden=!md.classList.contains('expanded') && md.scrollHeight<=md.clientHeight; toggle.addEventListener('click',()=>{const key=project.id+':'+selectedRequest.id; if(expandedPrds.has(key)) expandedPrds.delete(key); else expandedPrds.add(key); const open=expandedPrds.has(key); md.classList.toggle('expanded',open); toggle.setAttribute('aria-expanded',String(open));toggle.textContent=open?'Show less':'Show full PRD';}); }
@@ -285,7 +297,7 @@
if (restore) {
restoreView(container,restore);
}
const sectionTarget=new URLSearchParams(location.hash.split('?')[1] || '').get('section');
const sectionTarget=pane ? null : new URLSearchParams(location.hash.split('?')[1] || '').get('section');
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'});
}
// Cut at a word boundary, unless that would leave only a markdown marker (a long heading).
@@ -334,7 +346,7 @@
}
const palette=()=>treeRows.filter(p=>!p.archived && UUID.test(p.id)).flatMap(p=>sections.map(([key,label])=>({href:path(p.id,key),label:key==='overview'?p.name:`${p.name}: ${label}`,group:'project'})));
function breadcrumb(box, view) {
const h=view.querySelector('.project-header'); if(!h)return false;
const h=view.querySelector('.project-header'); if(!h||h.closest('[data-pane]'))return false;
const id=h.dataset.projectId, name=h.dataset.projectName, section=h.dataset.projectSection, conversation=h.dataset.projectConversation==='true';
const title=sections.find(([key])=>key===section)?.[1] || (section==='settings'?'Settings':'Overview');
const leaf=conversation?view.querySelector('h1')?.textContent:title;
+154
View File
@@ -529,9 +529,148 @@ html[data-organizations] .ws-agent small{color:var(--muted);font-weight:500;font
html[data-organizations] .ws-agent .ws-dot b{font-size:11px;width:10px}
html[data-organizations] .ws-rules{display:grid;gap:10px;font-size:13px}
html[data-organizations] .ws-rules p{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;margin:0}
html[data-organizations] .ws-psel{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 0 0 8px;font-size:13px;color:var(--strong);white-space:nowrap;max-width:100%}
html[data-organizations] .ws-psel select{border:0;background:transparent;color:inherit;font:inherit;padding:3px 8px 3px 0;min-width:0;max-width:240px;text-overflow:ellipsis;cursor:pointer}
html[data-organizations] .ws-psel:focus-within{outline:2px solid var(--action);outline-offset:1px}
html[data-organizations] .ws-psel select:focus{outline:0}
html[data-organizations] .ws-away{margin:8px 0 0}
html[data-organizations] .ws-off{display:block;margin-top:4px}
html[data-organizations] .ws-rules .ws-lvl{margin-right:0}
html[data-organizations] .ws-scope{margin:-8px 0 14px}
/* Across organizations (U3): the main dashboard, projects and inbox, samples 02, 04 and 05. */
body:has([data-ws-home]) #breadcrumbs,body:has([data-ws-projects]) #breadcrumbs,body:has([data-ws-inbox]) #breadcrumbs{display:none}
html[data-organizations] .ws-edge{box-shadow:inset 3px 0 0 var(--org)}
html[data-organizations] .ws-orgref{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;color:var(--strong);font-weight:600}
html[data-organizations] .ws-chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;padding:1px 7px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap;line-height:1.5}
html[data-organizations] .ws-chip.gated{background:color-mix(in srgb,var(--danger) 14%,var(--surface));color:var(--danger)}
html[data-organizations] .ws-chip.block{background:var(--danger);color:var(--surface)}
html[data-organizations] .ws-chip.outline{background:none;box-shadow:inset 0 0 0 1px var(--line);color:var(--text);font-family:var(--mono);font-weight:500;font-size:11.5px}
html[data-organizations] .ws-dot.s-ok{color:var(--success)}
html[data-organizations] .ws-tbl .ws-dot,html[data-organizations] .ws-sess .ws-dot{color:var(--text)}
html[data-organizations] .ws-tbl .ws-dot.s-working b,html[data-organizations] .ws-tbl .ws-dot.s-ok b{color:var(--success)}
html[data-organizations] .ws-tbl .ws-dot.s-waiting b{color:var(--warning)}
html[data-organizations] .ws-tbl .ws-dot.s-error b{color:var(--danger)}
html[data-organizations] .ws-tbl .ws-dot.s-idle b,html[data-organizations] .ws-tbl .ws-dot.s-offline b{color:var(--muted)}
html[data-organizations] .ws-g5{display:grid;gap:10px;grid-template-columns:repeat(5,minmax(0,1fr))}
html[data-organizations] .ws-tile .ws-big{font-size:24px;font-weight:800;color:var(--strong);letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.1}
html[data-organizations] .ws-tile .ws-big small{font-size:13px;font-weight:600;color:var(--muted);letter-spacing:0;margin-left:4px}
html[data-organizations] .ws-tile .ws-delta{font-size:12px;color:var(--muted)}
html[data-organizations] .ws-tile .ws-delta b{color:var(--success);font-weight:600}
html[data-organizations] .ws-tile.quiet{border-style:dashed;background:transparent}
html[data-organizations] .ws-bar{display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--raised);gap:2px}
html[data-organizations] .ws-bar i{display:block;background:var(--org,var(--action))}
html[data-organizations] .ws-by{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:12px;color:var(--text)}
html[data-organizations] .ws-by>span{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;font-variant-numeric:tabular-nums}
html[data-organizations] .ws-by .ws-av{width:14px;height:14px;border-radius:3px;font-size:7px}
html[data-organizations] .ws-orgcards{list-style:none;margin:0;padding:0;display:grid;gap:8px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}
html[data-organizations] .ws-orgcard a{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:2px 10px;align-items:center;padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);box-shadow:inset 3px 0 0 var(--org);color:var(--text);text-decoration:none;font-size:13px;height:100%}
html[data-organizations] .ws-orgcard a:hover{background:var(--raised)}
html[data-organizations] .ws-orgcard .ws-av{grid-row:1/3}
html[data-organizations] .ws-orgcard .nm{font-weight:700;color:var(--strong);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
html[data-organizations] .ws-orgcard .ws-dot{grid-column:2;grid-row:2;font-size:12.5px;color:var(--text)}
html[data-organizations] .ws-orgcard .ws-dot.s-working b{color:var(--success)}
html[data-organizations] .ws-orgcard .ws-dot.s-waiting b{color:var(--warning)}
html[data-organizations] .ws-orgcard .ws-dot.s-error b{color:var(--danger)}
html[data-organizations] .ws-orgcard .at{grid-column:3;grid-row:1;font:500 11.5px var(--mono);color:var(--muted)}
html[data-organizations] .ws-orgcard .why{grid-column:1/-1;font-size:12px;color:var(--muted)}
html[data-organizations] .ws-orgcard.off a{border-style:dashed}
html[data-organizations] .ws-home-split{margin-top:4px}
html[data-organizations] .ws-trail{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
html[data-organizations] .ws-trail li{display:grid;grid-template-columns:48px 20px minmax(0,1fr);gap:10px;padding:7px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);font-size:13px;align-items:center}
html[data-organizations] .ws-trail li:last-child{border-bottom:0}
html[data-organizations] .ws-trail time{font-family:var(--mono);font-size:12px;color:var(--muted)}
html[data-organizations] .ws-trail .what{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
html[data-organizations] .ws-trail .what a{color:var(--strong);text-decoration:none}
html[data-organizations] .ws-trail .what a:hover{text-decoration:underline}
html[data-organizations] .ws-trail .what b{font-weight:600}
html[data-organizations] .ws-trail .src{color:var(--muted)}
html[data-organizations] .ws-states{display:grid;gap:8px}
html[data-organizations] .ws-srow{display:grid;grid-template-columns:112px 24px minmax(0,1fr) auto;gap:10px;align-items:center;font-size:13px}
html[data-organizations] .ws-srow .sl{color:var(--strong);font-weight:600}
html[data-organizations] .ws-srow .sn{font:700 13px var(--mono);color:var(--strong);text-align:right}
html[data-organizations] .ws-srow .ws-bar{height:8px;max-width:100%}
html[data-organizations] .ws-needs{list-style:none;margin:0;padding:0;display:grid;gap:8px}
html[data-organizations] .ws-needs li{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:4px 10px;align-items:center;background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:9px 12px}
html[data-organizations] .ws-needs li>.ws-av{grid-row:1/3}
html[data-organizations] .ws-needs .q{color:var(--strong);font-weight:600;font-size:13.5px;text-decoration:none}
html[data-organizations] .ws-needs .q:hover{text-decoration:underline}
html[data-organizations] .ws-needs .src{grid-column:2;font-size:12px;color:var(--muted)}
html[data-organizations] .ws-needs .ws-chip{grid-row:1/3;grid-column:3}
html[data-organizations] .ws-more-line{margin:6px 0 0}
html[data-organizations] .ws-sess td.who{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:0;width:100%}
html[data-organizations] .ws-sess td.who .ws-orgref{vertical-align:middle}
html[data-organizations] .ws-sess td.st{white-space:nowrap}
/* Filters and tables on Projects and Inbox. */
html[data-organizations] .ws-toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;margin-bottom:12px}
html[data-organizations] .ws-toolbar .ws-right{align-items:center}
html[data-organizations] .ws-toolbar .ws-seg{margin-left:0}
html[data-organizations] .ws-fchips{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
html[data-organizations] .ws-fchips .f{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:999px;padding:2px 10px 2px 4px;font:600 12.5px/1.5 var(--font);color:var(--text);white-space:nowrap;cursor:pointer;box-shadow:none}
html[data-organizations] .ws-fchips .f.plain{padding-left:10px}
html[data-organizations] .ws-fchips .f:hover{background:var(--raised)}
html[data-organizations] .ws-fchips .f[aria-pressed="true"]{background:var(--strong);color:var(--surface);border-color:var(--strong)}
html[data-organizations] .ws-fchips .f .ws-av{width:18px;height:18px;border-radius:999px;font-size:8.5px;letter-spacing:0}
html[data-organizations] .ws-fchips .f .n{margin-left:2px;font:600 11px var(--mono);opacity:.8}
html[data-organizations] .ws-sel{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 0 0 8px;font-size:13px;color:var(--strong);white-space:nowrap}
html[data-organizations] .ws-sel .ws-label{font-size:10px}
html[data-organizations] .ws-sel select{border:0;background:transparent;color:var(--strong);font:500 13px/1.5 var(--font);padding:3px 6px 3px 2px;min-height:0;box-shadow:none;width:auto}
html[data-organizations] .ws-search{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 8px;color:var(--muted);font-size:13px;min-width:180px}
html[data-organizations] .ws-search svg.i{width:14px;height:14px;flex:none}
html[data-organizations] .ws-search input{border:0;background:transparent;color:var(--strong);font:500 13px/1.5 var(--font);padding:3px 0;min-height:0;box-shadow:none;width:100%;min-width:0}
html[data-organizations] .ws-search:focus-within,html[data-organizations] .ws-sel:focus-within{outline:2px solid var(--focus,var(--action));outline-offset:1px}
html[data-organizations] .ws-search input:focus,html[data-organizations] .ws-sel select:focus{outline:none}
html[data-organizations] .ws-tbl th.sorted{color:var(--strong)}
html[data-organizations] .ws-tbl th .ar{font-family:var(--mono);margin-left:3px;color:var(--action)}
html[data-organizations] .ws-tbl td.num,html[data-organizations] .ws-tbl th.num{text-align:right;font-variant-numeric:tabular-nums}
html[data-organizations] .ws-tbl tr.ws-edge td:first-child{box-shadow:inset 3px 0 0 var(--org)}
html[data-organizations] .ws-tbl tr.ws-edge{box-shadow:none}
html[data-organizations] .ws-tbl .nm{color:var(--strong);font-weight:600;text-decoration:none}
html[data-organizations] .ws-tbl a.nm:hover{text-decoration:underline}
html[data-organizations] .ws-tbl .ws-n{margin-left:0}
html[data-organizations] .ws-grp-row th{padding:8px 10px 6px;background:color-mix(in srgb,var(--raised) 45%,var(--surface));text-transform:none;letter-spacing:0;font-size:12.5px;color:var(--strong);box-shadow:inset 3px 0 0 var(--org)}
html[data-organizations] .ws-grp-row th>*{vertical-align:middle}
html[data-organizations] .ws-grp-row th>span{margin-left:8px}
html[data-organizations] .ws-tbl .ws-grp-row th>.ws-n{margin-left:8px}
html[data-organizations] .ws-unread td{font-size:12.5px}
html[data-organizations] .ws-unread-note{margin:-4px 0 10px}
html[data-organizations] .ws-av.mob{display:none}
html[data-organizations] .ws .show-m{display:none}
/* Inbox. */
html[data-organizations] .ws-ibx-grid{display:grid;gap:16px;grid-template-columns:minmax(0,1fr)}
html[data-organizations] .ws-ibx-grid.with-insp{grid-template-columns:minmax(0,1fr) minmax(300px,360px);align-items:start}
html[data-organizations] .ws-ibx-list>.ws-sec-h{margin-top:22px}
html[data-organizations] .ws-ibx td.src{white-space:nowrap}
html[data-organizations] .ws-ibx .q{color:var(--strong);font-weight:600;text-decoration:none}
html[data-organizations] .ws-ibx .q:hover{text-decoration:underline}
html[data-organizations] .ws-ibx .via{display:block;font-size:12px;color:var(--muted)}
html[data-organizations] .ws-ibx tr.on{background:color-mix(in srgb,var(--action) 10%,var(--surface))}
html[data-organizations] .ws-ibx tr.on td:first-child{box-shadow:inset 3px 0 0 var(--action)}
html[data-organizations] .ws-msrc{align-items:center;gap:5px;font-size:12px;color:var(--muted);margin-bottom:2px}
html[data-organizations] .ws-insp{border:1px solid var(--line);border-radius:10px;background:var(--surface);display:flex;flex-direction:column;min-width:0;position:sticky;top:12px;max-height:calc(100vh - 80px);overflow:auto}
html[data-organizations] .ws-insp header{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:1;box-shadow:inset 3px 0 0 var(--org)}
html[data-organizations] .ws-insp header .x{margin-left:auto;color:var(--muted);display:inline-flex}
html[data-organizations] .ws-insp header .x svg.i{width:18px;height:18px}
html[data-organizations] .ws-insp .body{padding:14px;display:grid;gap:14px}
html[data-organizations] .ws-insp h3{margin:0;font-size:16px;font-weight:700;color:var(--strong)}
html[data-organizations] .ws-insp .note{margin:0}
html[data-organizations] .ws-auth{border:1px solid var(--line);border-left:3px solid var(--org,var(--action));border-radius:0 6px 6px 0;padding:8px 10px;background:var(--canvas);font-size:13px;color:var(--strong)}
html[data-organizations] .ws-opts{list-style:none;margin:0;padding:0;display:grid;gap:8px}
html[data-organizations] .ws-opts li{border:1px solid var(--line);border-radius:8px;padding:9px 10px;display:grid;gap:6px;background:var(--canvas)}
html[data-organizations] .ws-opts .k{font:700 13px var(--mono);color:var(--strong)}
html[data-organizations] .ws-opts .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
html[data-organizations] .ws-opts b{font-weight:600;color:var(--strong)}
html[data-organizations] [data-ws-cmds]{display:grid;gap:6px}
html[data-organizations] .ws-cmd{display:flex;gap:6px;align-items:center;min-width:0}
html[data-organizations] .ws-cmd code{flex:1;min-width:0;overflow-x:auto;white-space:nowrap;background:var(--surface);border:1px solid var(--line);border-radius:4px;padding:3px 6px;font-size:12px;color:var(--strong)}
html[data-organizations] .ws-cmd .ws-btn{padding:4px 6px}
html[data-organizations] #copy-status:empty{display:none}
@media (max-width:1100px){
html[data-organizations] .ws-g5{grid-template-columns:repeat(3,minmax(0,1fr))}
html[data-organizations] .ws-ibx-grid.with-insp{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .ws-insp{position:static;max-height:none}
}
@media (pointer:coarse){html[data-organizations] .ws-nav,html[data-organizations] .s1-section.ws-nav{min-height:40px}}
@media (max-width:759px){
html[data-organizations] .content:has(.ws){padding:14px 16px 24px}
@@ -553,4 +692,19 @@ html[data-organizations] .ws-scope{margin:-8px 0 14px}
html[data-organizations] .ws-levels>.span{grid-column:auto}
html[data-organizations] .ws-levels>.last.lh{border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
html[data-organizations] .ws-levels .mlabel{display:flex}
html[data-organizations] .ws-g5{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .ws-g5.keep2{grid-template-columns:repeat(2,minmax(0,1fr))}
html[data-organizations] .ws-g5.keep2>:last-child:nth-child(odd){grid-column:1/-1}
html[data-organizations] .ws-orgcards{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .ws-srow{grid-template-columns:92px 20px minmax(0,1fr)}
html[data-organizations] .ws-srow .ws-by{grid-column:1/-1}
html[data-organizations] .ws-trail li{grid-template-columns:42px 18px minmax(0,1fr);gap:8px}
html[data-organizations] .ws-trail .what{white-space:normal}
html[data-organizations] .ws-toolbar .ws-right{margin-left:0}
html[data-organizations] .ws-search{min-width:0;flex:1 1 100%}
html[data-organizations] .ws .show-m,html[data-organizations] .ws-tbl .t2.show-m{display:block}
html[data-organizations] .ws span.show-m{display:inline-flex}
html[data-organizations] .ws span.ws-msrc.show-m{display:flex}
html[data-organizations] .ws-av.mob{display:inline-grid;margin-right:6px;vertical-align:-3px}
html[data-organizations] .ws-tbl .t2.mono.hide-m{display:none}
}
+97
View File
@@ -0,0 +1,97 @@
/* U4b (#1873): the task board across organizations, ported from the U0 sample 06 (workspace.css
EXT 7 and the board rules). Organization mode only; the org colour comes from .ws-o (shell.css). */
html[data-organizations] .tb .ws-vh .ws-seg{margin-left:0}
html[data-organizations] .tb-trk{display:inline-flex;align-items:center;gap:4px;font:600 10.5px var(--mono);letter-spacing:.02em;padding:0 5px;border-radius:3px;border:1px solid var(--line);color:var(--muted);white-space:nowrap}
html[data-organizations] .tb-trk.vik{border-style:dashed;color:var(--accent);border-color:color-mix(in srgb,var(--accent) 60%,var(--line))}
html[data-organizations] .tb-trackers{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center;font-size:12.5px;color:var(--text);padding:8px 12px;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface);margin-bottom:12px}
html[data-organizations] .tb-trackers>span{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap}
html[data-organizations] .tb-trackers .note{font-size:12.5px}
html[data-organizations] .tb-tracker .ws-dot{font-weight:600}
html[data-organizations] .tb-reason{color:var(--muted)}
html[data-organizations] .tb-notice{margin:0 0 10px;font-size:13px;color:var(--muted)}
html[data-organizations] .tb-notice:empty{display:none}
html[data-organizations] .tb-notice.is-error{color:var(--danger);font-weight:600}
/* Filter bar. */
html[data-organizations] .tb-toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;margin-bottom:12px}
html[data-organizations] .tb-right{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
html[data-organizations] .tb-chips{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
html[data-organizations] .tb-f{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:999px;padding:2px 10px 2px 4px;font:600 12.5px/1.5 var(--font);color:var(--text);white-space:nowrap;cursor:pointer;box-shadow:none}
html[data-organizations] .tb-f.plain{padding-left:10px}
html[data-organizations] .tb-f[aria-pressed=true]{background:var(--strong);color:var(--surface);border-color:var(--strong)}
html[data-organizations] .tb-f .ws-av{width:18px;height:18px;border-radius:999px;font-size:8.5px;letter-spacing:0}
html[data-organizations] .tb-f .n{margin-left:2px;color:inherit;opacity:.8}
html[data-organizations] .tb-sel{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 0 0 8px;font-size:13px;color:var(--strong);white-space:nowrap}
html[data-organizations] .tb-sel .ws-label{font-size:10px}
html[data-organizations] .tb-sel select{border:0;background:none;color:var(--strong);font:500 13px/1.5 var(--font);padding:3px 6px;min-height:0;max-width:16em;box-shadow:none}
html[data-organizations] .tb-search{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 0 0 8px;color:var(--muted);min-width:180px}
html[data-organizations] .tb-search svg.i,html[data-organizations] .tb-who svg.i{width:13px;height:13px}
html[data-organizations] .tb-search input{border:0;background:none;color:var(--strong);font:13px/1.5 var(--font);padding:3px 8px 3px 0;min-height:0;width:100%;min-width:0;box-shadow:none}
html[data-organizations] .tb-sel:focus-within,html[data-organizations] .tb-search:focus-within{outline:2px solid var(--action);outline-offset:1px}
html[data-organizations] .tb-sel select:focus,html[data-organizations] .tb-search input:focus{outline:none}
html[data-organizations] .tb-count{margin:0 0 8px;font-size:13px;color:var(--muted)}
/* Kanban. */
html[data-organizations] .tb-board{display:grid;grid-template-columns:repeat(var(--tb-cols,5),minmax(0,1fr));gap:12px;align-items:start}
html[data-organizations] .tb-col{background:color-mix(in srgb,var(--raised) 55%,var(--canvas));border:1px solid var(--line);border-radius:var(--r-lg);padding:8px;display:grid;gap:8px;min-width:0}
html[data-organizations] .tb-col.is-over{outline:2px dashed var(--action);outline-offset:-2px}
html[data-organizations] .tb-col-h{display:flex;align-items:center;gap:8px;padding:2px 4px}
html[data-organizations] .tb-col-h h3{margin:0;font-size:13px;font-weight:700;color:var(--strong)}
html[data-organizations] .tb-col-h .n{font-size:12px;font-weight:600;color:var(--muted)}
html[data-organizations] .tb-col-h .mix{margin-left:auto;display:flex;gap:2px}
html[data-organizations] .tb-colnote,html[data-organizations] .tb-empty{margin:0;padding:2px 4px;font-size:12px;color:var(--muted)}
html[data-organizations] .tb-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-table);padding:8px 10px 8px 12px;display:grid;gap:5px;min-width:0;box-shadow:inset 3px 0 0 var(--org)}
html[data-organizations] .tb-card[draggable=true]{cursor:grab}
html[data-organizations] .tb-card.is-dragging{opacity:.5}
html[data-organizations] .tb-card .top{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
html[data-organizations] .tb-card .top .id{font:500 11.5px var(--mono);color:var(--muted)}
html[data-organizations] .tb-card .top .tb-trk{margin-left:auto}
html[data-organizations] .tb-card .tt{color:var(--strong);font-weight:600;font-size:13px;line-height:1.35;min-width:0}
html[data-organizations] .tb-card .bot{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);flex-wrap:wrap}
html[data-organizations] .tb-card.dim .tt{color:var(--text)}
html[data-organizations] .tb-open{all:unset;cursor:pointer;overflow-wrap:anywhere}
html[data-organizations] .tb-open:hover{text-decoration:underline}
html[data-organizations] .tb-open:focus-visible{outline:2px solid var(--action);outline-offset:2px;border-radius:2px}
html[data-organizations] .tb-who{display:inline-flex;align-items:center;gap:4px}
html[data-organizations] .tb-chip{display:inline-flex;align-items:center;font-size:11.5px;font-weight:600;padding:0 6px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap}
html[data-organizations] .tb-chip.warn{background:color-mix(in srgb,var(--warning) 16%,var(--surface));color:var(--warning)}
/* List. */
html[data-organizations] .tb-row td:first-child{box-shadow:inset 3px 0 0 var(--org)}
html[data-organizations] .tb-row.dim td{color:var(--muted)}
html[data-organizations] .tb-list .tb-open.t1{display:inline;color:var(--strong)}
html[data-organizations] .tb-list .t2.show-m{display:none}
html[data-organizations] .tb-move{font:500 13px/1.4 var(--font);color:var(--strong);background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:2px 4px;min-height:0}
/* The create and edit dialog (on <body>, outside the repainted view). */
html[data-organizations] .tb-dialog{border:1px solid var(--line);border-radius:12px;background:var(--surface);color:var(--text);padding:0;width:min(520px,calc(100vw - 32px));max-height:calc(100vh - 48px);box-shadow:0 12px 40px rgba(0,0,0,.25)}
html[data-organizations] .tb-dialog::backdrop{background:rgba(0,0,0,.35)}
html[data-organizations] .tb-form{display:grid;gap:10px;padding:18px 20px;font-size:14px}
html[data-organizations] .tb-form h2{margin:0;font-size:17px;font-weight:700;color:var(--strong);overflow-wrap:anywhere}
html[data-organizations] .tb-form label{display:grid;gap:4px;font-size:12.5px;font-weight:600;color:var(--strong)}
html[data-organizations] .tb-form input:not([type=checkbox]),html[data-organizations] .tb-form select{font:14px/1.4 var(--font);color:var(--strong);background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:5px 8px;min-width:0}
html[data-organizations] .tb-row2{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
html[data-organizations] .tb-roles{border:1px solid var(--line);border-radius:var(--r);padding:6px 10px 8px;margin:0;display:flex;flex-wrap:wrap;gap:4px 14px}
html[data-organizations] .tb-roles legend{font-size:12.5px;font-weight:600;color:var(--strong);padding:0 4px}
html[data-organizations] .tb-roles label{display:inline-flex;align-items:center;gap:6px;font-weight:500;color:var(--text)}
html[data-organizations] .tb-field-note,html[data-organizations] .tb-facts{margin:0;font-size:12.5px;color:var(--muted)}
html[data-organizations] .tb-facts{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
html[data-organizations] .tb-dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:4px 14px;margin:0;font-size:13px}
html[data-organizations] .tb-dl dt{color:var(--muted)}
html[data-organizations] .tb-dl dd{margin:0;color:var(--strong)}
html[data-organizations] .tb-form-status{margin:0;font-size:13px;color:var(--danger);font-weight:600}
html[data-organizations] .tb-form-status:empty{display:none}
html[data-organizations] .tb-actions{display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end;align-items:center}
html[data-organizations] .tb-gap{flex:1}
html[data-organizations] .tb-form .ws-btn.primary{background:var(--action);color:var(--onAction);border-color:var(--action)}
html[data-organizations] .tb-form .ws-btn.primary:hover:not(:disabled){background:color-mix(in srgb,var(--action) 88%,var(--strong))}
html[data-organizations] .tb-form .ws-btn.danger{color:var(--danger)}
@media (max-width:759px){
html[data-organizations] .tb-board{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .tb-right{margin-left:0;flex:1 1 100%}
html[data-organizations] .tb-search{min-width:0;flex:1}
html[data-organizations] .tb-list .t2.show-m{display:block}
html[data-organizations] .tb-row2{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
html[data-organizations] .tb-sel select{max-width:11em}
}
+496
View File
@@ -0,0 +1,496 @@
// U4b (#1873, PRD 1.20): one task board across every organization (sample 06).
//
// Reads: GET /api/workspace?sections=tasks,projects, where each org's tasks come from its own tracker
// module (the built-in store or Vikunja), merged and tagged with the org. The project, state, assignee
// and label filters go to the server, which applies them before its per-org cap, so a filter reaches
// every task an org holds; the org chips, the search, Nobody and No project narrow in the page. An org whose bus is down,
// slow or invalid keeps its U1 state on the Trackers line and the others still render.
// Writes: POST /api/workspace/tasks, one org per request, through that org's bus and its person
// verbs (tasks.create, tasks.edit, tasks.archive). The bus runs its own human check; a refusal is
// shown as the bus answered it. Vikunja tasks are read only here: the bus refuses them
// (tracker-not-store), so the board offers no control for them.
// bus.js calls view() for #/tasks in organization mode and repaints it on its own 10 s poll.
(() => {
'use strict';
if (!document.documentElement.hasAttribute('data-organizations')) return;
const data = window.MosaicWorkspaceData;
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const LAYOUT = 'mosaic-tasks-layout-v1';
const load = key => { try { return localStorage.getItem(key); } catch { return null; } };
const save = (key, value) => { try { localStorage.setItem(key, value); } catch {} };
const STATES = [['todo', 'To do'], ['in-progress', 'In progress'], ['in-review', 'Review'], ['blocked', 'Blocked'], ['done', 'Done']];
const STATE_LABEL = Object.fromEntries(STATES);
const UNREAD = 'unread';
const REQUIREMENT = '^REQ-[A-Z]+-[1-9][0-9]*$';
const ORG_STATE = { down: ['s-offline', '○', 'bus down'], timeout: ['s-waiting', '◐', 'no answer in time'], invalid: ['s-error', '▲', 'invalid answer'], starting: ['s-waiting', '◐', 'starting'] };
const phone = () => window.matchMedia('(max-width:759px)').matches;
// The layout is remembered per viewer and per width: a phone opens on List until its viewer picks
// Kanban, a wide screen on Kanban until its viewer picks List.
const layouts = (() => { try { const v = JSON.parse(load(LAYOUT) || '{}'); return v && typeof v === 'object' ? v : {}; } catch { return {}; } })();
const layoutNow = () => { const v = layouts[phone() ? 'phone' : 'wide']; return v === 'kanban' || v === 'list' ? v : phone() ? 'list' : 'kanban'; };
const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '' };
// `opened` is the task as the edit dialog showed it: Save diffs against it and sends its digest, so a
// poll that reads a newer task while the dialog is open turns a stale save into a task-conflict.
let snap = null, canWrite = false, notice = null, dragging = null, dragEnded = null, opened = null;
const mark = () => window.MosaicWorkspace?.orgMark;
const orgOf = id => snap?.orgs.find(o => o.id === id) || { id, label: id, theme: null };
const av = (id, size = '') => mark()?.av(orgOf(id), size) ?? '';
const colours = id => mark()?.colours(orgOf(id)) ?? '';
const icon = id => window.MosaicWorkspace?.icon(id) ?? '';
const key = t => `${t.org}|${t.ref}`;
// Ids keep their tracker's form: #N for the built-in store, V-N for Vikunja.
const shortId = ref => { let m = /^task:(\d+)$/.exec(ref); if (m) return `#${m[1]}`; m = /^vikunja:\d+\/(\d+)$/.exec(ref); return m ? `V-${m[1]}` : ref; };
const trk = tracker => (tracker === 'vikunja' ? '<span class="tb-trk vik">Vikunja</span>' : '<span class="tb-trk">built-in</span>');
const stateOf = t => (STATE_LABEL[t.state] ? t.state : UNREAD);
// An org can answer while its tasks read fails (errors.tasks): its tasks are unavailable, not zero.
const tasksRead = o => o?.state === 'ok' && !o.errors?.tasks;
const orgWritable = o => canWrite && tasksRead(o) && o.tracker === 'built-in';
const taskWritable = t => t.writable === true && typeof t.digest === 'string' && orgWritable(orgOf(t.org));
const projectName = t => t.project?.name || t.project?.key || null;
const dueText = iso => {
const d = new Date(iso);
if (!Number.isFinite(d.getTime())) return null;
const opts = { day: 'numeric', month: 'short', timeZone: 'UTC', ...(d.getUTCFullYear() !== new Date().getUTCFullYear() ? { year: 'numeric' } : {}) };
return d.toLocaleDateString('en-GB', opts);
};
const overdue = t => t.dueDate && t.state !== 'done' && Date.parse(t.dueDate) < Date.now();
// ---------- reads ----------
const PROJECT_SELECTOR = /^[a-z0-9][a-z0-9._-]{0,63}:[A-Za-z0-9._-]{1,128}$/, ROLE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
function readQuery() {
const q = new URLSearchParams({ sections: 'tasks,projects' });
if (PROJECT_SELECTOR.test(filter.project)) q.set('project', filter.project);
if (filter.state) q.set('task-state', filter.state === UNREAD ? 'open' : filter.state);
if (ROLE.test(filter.assignee)) q.set('assignee', filter.assignee);
if (/^[1-9]\d{0,14}$/.test(filter.label)) q.set('label', filter.label);
return q.toString();
}
async function read() {
const [response, list] = await Promise.all([
fetch(`/api/workspace?${readQuery()}`, { cache: 'no-store', redirect: 'manual' }),
data.organizations().then(b => (Array.isArray(b) ? b : null)).catch(() => null),
mark()?.ready().catch(() => null),
window.MosaicConsole?.ready,
]);
canWrite = window.MosaicConsole?.config?.orgSetup === true;
if (response.type === 'opaqueredirect' || !response.ok) {
const body = await response.json().catch(() => null);
throw Object.assign(new Error('read failed'), { status: response.status, message: typeof body?.message === 'string' ? body.message : null });
}
const body = await response.json();
if (!Array.isArray(body?.orgs) || !Array.isArray(body.tasks) || !Array.isArray(body.projects)) throw new Error('read failed');
const named = new Map((list || []).map(o => [o.id, o]));
const orgs = body.orgs.map(o => ({ ...o, id: o.org, label: named.get(o.org)?.label || o.org, theme: named.get(o.org)?.theme ?? o.theme ?? null, roles: Array.isArray(o.roles) ? o.roles : [] }));
return { orgs, tasks: body.tasks, projects: body.projects, summary: body.summary, readAt: body.readAt };
}
// Only the latest read lands: a filter change and the poll can both be reading.
let readSeq = 0;
const reload = async () => { const n = ++readSeq, next = await read(); if (n !== readSeq) return false; snap = next; prune(); return true; };
async function refilter() { paint(); try { if (await reload()) paint(); } catch { /* the next poll shows the failed read */ } }
// A filter naming an org or project that is gone falls back to all.
function prune() {
if (filter.org && !snap.orgs.some(o => o.id === filter.org)) filter.org = '';
if (filter.project && filter.project !== '-' && !snap.projects.some(p => `${p.org}:${p.id}` === filter.project)) filter.project = '';
}
// ---------- filters ----------
function filtered({ q = true } = {}) {
const needle = filter.q.trim().toLowerCase();
return snap.tasks.filter(t => (!filter.org || t.org === filter.org)
&& (!filter.project || (filter.project === '-' ? !t.project : `${t.org}:${t.project?.id}` === filter.project))
&& (!filter.assignee || (filter.assignee === '-' ? !t.assignees.length : t.assignees.includes(filter.assignee)))
&& (!filter.state || stateOf(t) === filter.state)
&& (!filter.label || t.labels.map(String).includes(filter.label))
&& (!q || !needle || `${t.title ?? ''} ${shortId(t.ref)} ${projectName(t) ?? ''}`.toLowerCase().includes(needle)));
}
const select = (name, label, options, focus) => `<label class="tb-sel"><span class="ws-label">${esc(label)}</span><select data-tb-filter="${name}" data-focus-key="${focus}">${options}</select></label>`;
const opt = (value, text, current) => `<option value="${esc(value)}"${value === current ? ' selected' : ''}>${esc(text)}</option>`;
function toolbar() {
const counts = new Map(snap.orgs.map(o => [o.id, 0]));
for (const t of snap.tasks) counts.set(t.org, (counts.get(t.org) ?? 0) + 1);
const chip = (id, inner, label) => `<button type="button" class="tb-f${id ? '' : ' plain'}" data-tb-org="${esc(id)}" aria-pressed="${filter.org === id}" data-focus-key="tb-org:${esc(id)}"${label ? ` aria-label="${esc(label)}"` : ''}>${inner}</button>`;
const chips = [chip('', `All <span class="n">${snap.tasks.length}</span>`)].concat(snap.orgs.map(o => chip(o.id,
`${av(o.id)}${esc(o.label)}<span class="n">${tasksRead(o) ? counts.get(o.id) : 'unavailable'}</span>`,
`${o.label}, ${tasksRead(o) ? `${counts.get(o.id)} tasks` : 'tasks unavailable'}`))).join('');
const scope = snap.orgs.filter(o => !filter.org || o.id === filter.org);
const projectOpts = opt('', `All ${snap.projects.filter(p => !filter.org || p.org === filter.org).length}`, filter.project) + opt('-', 'No project', filter.project)
+ scope.map(o => { const ps = snap.projects.filter(p => p.org === o.id); return ps.length ? `<optgroup label="${esc(o.label)}">${ps.map(p => opt(`${p.org}:${p.id}`, p.name || p.key || p.id, filter.project)).join('')}</optgroup>` : ''; }).join('');
const roles = [...new Set(scope.flatMap(o => o.roles).concat(snap.tasks.filter(t => !filter.org || t.org === filter.org).flatMap(t => t.assignees)))].sort();
if (filter.assignee && filter.assignee !== '-' && !roles.includes(filter.assignee)) roles.push(filter.assignee);
const assigneeOpts = opt('', 'Anyone', filter.assignee) + opt('-', 'Nobody', filter.assignee) + roles.map(r => opt(r, r, filter.assignee)).join('');
const stateOpts = opt('', 'Any', filter.state) + STATES.map(([v, l]) => opt(v, l, filter.state)).join('') + (snap.tasks.some(t => stateOf(t) === UNREAD) ? opt(UNREAD, 'State not read', filter.state) : '');
const labels = [...new Set(snap.tasks.flatMap(t => t.labels.map(String)))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const labelSel = labels.length ? select('label', 'Label', opt('', 'Any', filter.label) + labels.map(l => opt(l, l, filter.label)).join(''), 'tb-label') : '';
return `<div class="tb-toolbar">
<div class="tb-chips" role="group" aria-label="Organization">${chips}</div>
${select('project', 'Project', projectOpts, 'tb-project')}${select('assignee', 'Assignee', assigneeOpts, 'tb-assignee')}${select('state', 'State', stateOpts, 'tb-state')}${labelSel}
<div class="tb-right"><label class="tb-search">${icon('search')}<input type="search" placeholder="Find a task" aria-label="Find a task" data-tb-q data-focus-key="tb-q" value="${esc(filter.q)}" autocomplete="off"></label></div>
</div>`;
}
// ---------- results ----------
const who = t => (t.assignees.length ? `<span class="tb-who">${icon('people')}${esc(t.assignees.join(', '))}</span>` : '');
const prio = t => (Number.isSafeInteger(t.priority) && t.priority > 0 ? `<span class="tb-chip${t.priority >= 4 ? ' warn' : ''}" title="Priority ${t.priority} of 5">P${t.priority}</span>` : '');
const due = t => { const d = t.dueDate && dueText(t.dueDate); return d ? `<span class="tb-chip${overdue(t) ? ' warn' : ''}" title="Due ${esc(t.dueDate.slice(0, 10))} (UTC)">${overdue(t) ? 'overdue' : 'due'} ${esc(d)}</span>` : ''; };
const title = t => esc(t.title || 'Untitled');
function card(t) {
const w = taskWritable(t), k = esc(key(t));
return `<article class="tb-card ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${k}" data-org="${esc(t.org)}"${w ? ' draggable="true"' : ''}>
<div class="top">${av(t.org, 'xs')}<span class="ws-sr">${esc(orgOf(t.org).label)}</span><span class="id">${esc(shortId(t.ref))}</span>${trk(t.tracker)}</div>
<div class="tt"><button type="button" class="tb-open" data-tb-open="${k}" data-focus-key="open:${k}">${title(t)}</button></div>
<div class="bot"><span>${projectName(t) ? esc(projectName(t)) : '<span class="muted">No project</span>'}</span>${who(t)}${prio(t)}${due(t)}</div>
</article>`;
}
function kanban(rows) {
const cols = STATES.concat(rows.some(t => stateOf(t) === UNREAD) ? [[UNREAD, 'State not read']] : []);
return `<div class="tb-board" style="--tb-cols:${cols.length}" data-tb-kanban>${cols.map(([v, l]) => {
const inCol = rows.filter(t => stateOf(t) === v), orgs = [...new Set(inCol.map(t => t.org))];
const drop = v !== UNREAD ? ` data-tb-col="${v}"` : '';
return `<section class="tb-col"${drop} aria-label="${esc(l)}"><div class="tb-col-h"><h3>${esc(l)}</h3><span class="n">${inCol.length}</span>${orgs.length ? `<span class="mix" title="Organizations in this column">${orgs.map(o => av(o, 'xs')).join('')}</span>` : ''}</div>
${v === UNREAD ? '<p class="tb-colnote">Vikunja does not tell the bus which bucket an open task is in.</p>' : ''}${inCol.map(card).join('') || '<p class="tb-empty">No tasks</p>'}</section>`;
}).join('')}</div>`;
}
const order = t => { const i = STATES.findIndex(([v]) => v === t.state); return i < 0 ? STATES.length : i; };
function list(rows) {
const sorted = [...rows].sort((a, b) => order(a) - order(b) || a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref, undefined, { numeric: true }));
const stateCell = t => {
if (!taskWritable(t)) return esc(STATE_LABEL[t.state] || 'Not read');
return `<select class="tb-move" data-tb-move="${esc(key(t))}" data-focus-key="move:${esc(key(t))}" aria-label="${esc(`State of ${shortId(t.ref)}, ${t.title || 'Untitled'}`)}">${STATES.map(([v, l]) => opt(v, l, t.state)).join('')}</select>`;
};
return `<div class="ws-tbl tb-list" data-tb-list><table><thead><tr><th scope="col">Org</th><th scope="col">Id</th><th scope="col">Task</th><th scope="col" class="hide-m">Project</th><th scope="col">State</th><th scope="col" class="hide-m">Assignee</th><th scope="col" class="hide-m">Priority</th><th scope="col" class="hide-m">Due</th></tr></thead><tbody>
${sorted.map(t => `<tr class="tb-row ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${esc(key(t))}" data-org="${esc(t.org)}">
<td>${av(t.org)}<span class="ws-sr">${esc(orgOf(t.org).label)}</span></td>
<td class="mono">${esc(shortId(t.ref))}<span class="t2">${t.tracker === 'vikunja' ? 'Vikunja' : 'built-in'}</span></td>
<td><button type="button" class="tb-open t1" data-tb-open="${esc(key(t))}" data-focus-key="lopen:${esc(key(t))}">${title(t)}</button><span class="t2 show-m">${esc(projectName(t) || 'No project')}</span></td>
<td class="hide-m">${projectName(t) ? esc(projectName(t)) : '<span class="muted">none</span>'}</td>
<td>${stateCell(t)}</td>
<td class="hide-m">${t.assignees.length ? esc(t.assignees.join(', ')) : '<span class="muted">nobody</span>'}</td>
<td class="hide-m">${prio(t) || '<span class="muted">none</span>'}</td>
<td class="hide-m">${due(t) || '<span class="muted">none</span>'}</td>
</tr>`).join('')}</tbody></table></div>`;
}
function results() {
const rows = filtered(), all = snap.tasks.length;
// Project, state, assignee and label filter on the server, so the board never holds the unfiltered
// total; with one of those on, the line says how many match rather than inventing an "of N".
const onServer = new URLSearchParams(readQuery()).size > 1, n = rows.length;
const line = onServer ? `<p class="tb-count">Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.</p>`
: n !== all ? `<p class="tb-count">Showing ${n} of ${all} tasks.</p>` : '';
// An empty result is only exhaustive when every organization's tasks were read.
const unread = snap.orgs.filter(o => !tasksRead(o));
const none = what => (unread.length ? `${what} in the organizations that answered. Tasks from ${unread.map(o => o.label).join(', ')} could not be read.` : `${what}.`);
const body = !snap.orgs.length ? '<p class="ws-panel tight note">No organizations yet.</p>'
: !snap.orgs.some(tasksRead) ? '<p class="ws-panel tight note">No organization answered with its tasks, so no tasks could be read.</p>'
: !all && !onServer ? `<p class="ws-panel tight note">${esc(none('No tasks yet'))}</p>`
: !rows.length ? `<p class="ws-panel tight note">${esc(none('No tasks match these filters'))}</p>`
: layoutNow() === 'list' ? list(rows) : kanban(rows);
return `<div data-tb-results>${line}${body}</div>`;
}
// ---------- page ----------
function trackers() {
const items = snap.orgs.map(o => {
const s = ORG_STATE[o.state] ?? (o.state === 'ok' && o.errors?.tasks ? ['s-error', '▲', 'tasks not read'] : null);
const why = ORG_STATE[o.state] ? o.reason : o.errors?.tasks;
const st = s ? ` <span class="ws-dot ${s[0]}"><b aria-hidden="true">${s[1]}</b>${esc(s[2])}</span>${why ? `<span class="tb-reason">${esc(why)}</span>` : ''}` : '';
const cut = o.tasksHeld ? `<span class="tb-reason">shows the 500 most recently changed of ${esc(o.tasksHeld)} matching tasks</span>` : '';
return `<span class="tb-tracker${s ? ' is-down' : ''}" data-tb-tracker="${esc(o.id)}" data-state="${esc(o.state)}">${av(o.id)}${esc(o.label)} ${o.tracker ? trk(o.tracker) : ''}${st}${cut}</span>`;
}).join('');
return `<div class="tb-trackers"><span class="ws-label">Trackers</span>${items}<span class="note">Ids keep their tracker's form: V-12 is Vikunja, #41 is built in.</span></div>`;
}
function head() {
const lay = layoutNow();
const writableOrgs = snap ? snap.orgs.filter(orgWritable) : [];
const why = !canWrite ? 'This Console takes no task changes.' : !writableOrgs.length ? 'No organization with a built-in task store answered.' : '';
const seg = `<span class="ws-seg" role="group" aria-label="Layout"><button type="button" data-tb-layout="kanban" aria-pressed="${lay === 'kanban'}" data-focus-key="tb-layout-kanban">Kanban</button><button type="button" data-tb-layout="list" aria-pressed="${lay === 'list'}" data-focus-key="tb-layout-list">List</button></span>`;
const add = `<button type="button" class="ws-btn" data-tb-new data-focus-key="tb-new"${why ? ` disabled title="${esc(why)}"` : ''}>${icon('plus')}New task</button>`;
let sub = '';
if (snap) {
const n = snap.tasks.length, m = snap.orgs.filter(tasksRead).length, of = snap.summary?.of ?? snap.orgs.length;
sub = `${n} ${n === 1 ? 'task' : 'tasks'} from ${m} of ${of} ${of === 1 ? 'organization' : 'organizations'}. Each organization keeps its own tasks; a change made here goes only to that organization's bus.`;
if (snap.orgs.some(o => o.tracker === 'vikunja')) sub += ' Vikunja tasks are read only here.';
if (!canWrite) sub += ' This Console takes no task changes, so the board is read only.';
}
return `<div class="ws-vh"><h1 tabindex="-1">Tasks</h1><div class="ws-right">${seg}${add}</div>${sub ? `<p class="sub">${sub}</p>` : ''}</div>`;
}
const noticeLine = () => (notice ? `<p class="tb-notice${notice.error ? ' is-error' : ''}" role="${notice.error ? 'alert' : 'status'}" data-tb-notice>${esc(notice.text)}</p>` : '<p class="tb-notice" role="status" data-tb-notice></p>');
function markup() {
return `<section class="ws tb" data-tasks-board data-layout="${layoutNow()}">${head()}${noticeLine()}${trackers()}${toolbar()}${results()}</section>`;
}
const failed = err => `<section class="ws tb" data-tasks-board><div class="ws-vh"><h1 tabindex="-1">Tasks</h1><p class="sub">The task board could not be read${err?.message && err.message !== 'read failed' ? `: ${esc(err.message)}` : ''}. It tries again on the next refresh.</p></div></section>`;
// Kept across a repaint: focus by data-focus-key, the search caret and the sideways scroll of the
// board and the list. bus.js restores focus by key; the caret and scroll are put back here.
function snapshotView(root) {
const a = document.activeElement;
return {
focus: root && root.contains(a) ? a.dataset?.focusKey || null : null,
caret: root && root.contains(a) && a.matches?.('[data-tb-q]') ? [a.selectionStart, a.selectionEnd] : null,
scroll: root ? [...root.querySelectorAll('.tb-board,.tb-list')].map(e => [e.matches('.tb-board') ? 'board' : 'list', e.scrollLeft]) : [],
};
}
// With focus false (after bus.js's own repaint, which puts focus back by key) the caret is put back
// only if focus is still on the search box it was taken from.
function putBack(root, kept, { focus = true } = {}) {
if (!root || !kept) return;
for (const [which, left] of kept.scroll) { const e = root.querySelector(which === 'board' ? '.tb-board' : '.tb-list'); if (e) e.scrollLeft = left; }
const el = kept.focus ? root.querySelector(`[data-focus-key="${CSS.escape(kept.focus)}"]`) : null;
if (focus && el && document.activeElement !== el) el.focus({ preventScroll: true });
if (el && document.activeElement === el && kept.caret && el.matches('[data-tb-q]')) el.setSelectionRange(kept.caret[0], kept.caret[1]);
}
// Repaint in place, for the board's own changes (filters, layout, a write).
function paint(part = 'all') {
const root = document.querySelector('[data-tasks-board]');
if (!root || !snap) return;
const kept = snapshotView(root);
if (part === 'results') root.querySelector('[data-tb-results]').outerHTML = results();
else root.outerHTML = markup();
putBack(document.querySelector('[data-tasks-board]'), kept);
}
// bus.js's view for #/tasks. A drag in progress holds the poll's repaint until it ends.
async function view() {
if (dragging) await Promise.race([dragEnded, new Promise(r => setTimeout(r, 30000))]);
try { await reload(); } catch (err) { snap = null; return failed(err); }
// Taken after the read, just before bus.js replaces the view, so focus, caret or scroll moved while
// the read was in flight is what is kept. bus.js puts focus back by key; this puts back the rest.
const kept = snapshotView(document.querySelector('[data-tasks-board]'));
setTimeout(() => putBack(document.querySelector('[data-tasks-board]'), kept, { focus: false }), 0);
return markup();
}
// ---------- writes ----------
const UNKNOWN = Object.freeze({ ok: false, error: 'outcome-unknown', unknown: true, message: 'The Console\'s answer could not be read. The change may or may not have been written; the board shows it on its next read.' });
async function write(body) {
let response, answer = null;
try {
response = await fetch('/api/workspace/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });
answer = await response.json().catch(() => null);
} catch { return UNKNOWN; }
if (response.type === 'opaqueredirect') return { ok: false, error: 'signed-out', message: 'The Console asked to sign in again. Nothing was written.' };
if (response.ok && answer?.ok) return answer;
// A refusal names its code. An answer that cannot be read, or one that says the outcome is open,
// is not a refusal: the change may have reached the bus.
if (typeof answer?.error !== 'string' || answer.sent === 'unknown') return { ...UNKNOWN, message: answer?.sent === 'unknown' && answer.message ? answer.message : UNKNOWN.message };
return { ok: false, error: answer.error, message: answer.message || `The Console refused the change (${response.status}). Nothing was written.` };
}
const find = k => snap?.tasks.find(t => key(t) === k) || null;
async function move(k, state) {
const t = find(k);
if (!t || !taskWritable(t) || t.state === state) return;
notice = { text: `Moving ${shortId(t.ref)} to ${STATE_LABEL[state]}…` }; paint();
const r = await write({ op: 'edit', org: t.org, task_ref: t.ref, expect: t.digest, state });
notice = r.ok ? { text: `${shortId(t.ref)} moved to ${STATE_LABEL[state]} in ${orgOf(t.org).label}.` } : { error: true, text: r.unknown ? `${shortId(t.ref)}: ${r.message}` : `${shortId(t.ref)} was not moved. ${r.message}` };
try { await reload(); } catch {}
paint();
}
// ---------- dialog (create and edit) ----------
function dialogEl() {
let d = document.getElementById('tb-dialog');
if (!d) {
d = document.createElement('dialog');
d.id = 'tb-dialog'; d.className = 'tb-dialog'; d.setAttribute('aria-labelledby', 'tb-dialog-h');
document.body.append(d);
d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
}
return d;
}
const dateValue = iso => (typeof iso === 'string' && /^\d{4}-\d\d-\d\d/.test(iso) ? iso.slice(0, 10) : '');
const roleBoxes = (roles, picked) => (roles.length
? `<fieldset class="tb-roles"><legend>Assignees</legend>${roles.map(r => `<label><input type="checkbox" name="assignees" value="${esc(r)}"${picked.includes(r) ? ' checked' : ''}>${esc(r)}</label>`).join('')}</fieldset>`
: '<p class="tb-field-note">Assignees: this organization reports no roles.</p>');
const prioSelect = p => `<label>Priority<select name="priority">${[0, 1, 2, 3, 4, 5].map(n => `<option value="${n}"${n === (p ?? 0) ? ' selected' : ''}>${n ? `P${n}` : 'None'}</option>`).join('')}</select></label>`;
const stateSelect = s => `<label>State<select name="state">${STATES.map(([v, l]) => `<option value="${v}"${v === s ? ' selected' : ''}>${l}</option>`).join('')}</select></label>`;
function projectOptions(org, current) {
return `<option value="">No project</option>${snap.projects.filter(p => p.org === org).map(p => `<option value="${esc(p.id)}"${p.id === current ? ' selected' : ''}>${esc(p.name || p.key || p.id)}</option>`).join('')}`;
}
function createForm(orgId) {
const orgs = snap.orgs.filter(orgWritable), org = orgs.find(o => o.id === orgId) || orgs[0];
return `<form method="dialog" class="tb-form" data-tb-form="create">
<h2 id="tb-dialog-h">New task</h2>
<label>Organization<select name="org" data-tb-create-org>${orgs.map(o => `<option value="${esc(o.id)}"${o.id === org.id ? ' selected' : ''}>${esc(o.label)}</option>`).join('')}</select></label>
<label>Project<select name="group">${projectOptions(org.id, filter.project.startsWith(`${org.id}:`) ? filter.project.slice(org.id.length + 1) : '')}</select></label>
<label>Title<input name="title" required maxlength="250" autocomplete="off"></label>
<label>Requirement<input name="requirement" required pattern="${REQUIREMENT}" placeholder="REQ-WEB-1" autocomplete="off" aria-describedby="tb-req-note"></label>
<p class="tb-field-note" id="tb-req-note">The requirement this task serves, written as REQ-AREA-N.</p>
<div class="tb-row2">${stateSelect('todo')}${prioSelect(0)}<label>Due date<input type="date" name="due_date"></label></div>
<div data-tb-roles>${roleBoxes(org.roles, [])}</div>
<p class="tb-form-status" role="alert" data-tb-form-status></p>
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Create task</button></div>
</form>`;
}
function editForm(t) {
const o = orgOf(t.org), w = taskWritable(t);
const why = t.tracker === 'vikunja' ? 'This task is in Vikunja. The board reads it but does not change it; change it in Vikunja.'
: !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.' : !w ? 'The bus did not give this task a version to check a change against.' : '';
const facts = `<p class="tb-facts">${av(t.org, 'xs')}${esc(o.label)} · <span class="mono">${esc(shortId(t.ref))}</span> ${trk(t.tracker)}${t.requirement ? ` · <span class="mono">${esc(t.requirement)}</span>` : ''}${projectName(t) ? ` · ${esc(projectName(t))}` : ''}</p>`;
if (!w) {
return `<div class="tb-form" data-tb-form="view"><h2 id="tb-dialog-h">${title(t)}</h2>${facts}
<dl class="tb-dl"><dt>State</dt><dd>${esc(STATE_LABEL[t.state] || 'Not read')}</dd><dt>Assignees</dt><dd>${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}</dd><dt>Priority</dt><dd>${prio(t) || 'none'}</dd><dt>Due</dt><dd>${due(t) || 'none'}</dd></dl>
<p class="tb-field-note">${esc(why)}</p>
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
}
return `<form method="dialog" class="tb-form" data-tb-form="edit" data-tb-key="${esc(key(t))}">
<h2 id="tb-dialog-h">Edit ${esc(shortId(t.ref))}</h2>${facts}
<label>Title<input name="title" required maxlength="250" value="${esc(t.title ?? '')}" autocomplete="off"></label>
<div class="tb-row2">${stateSelect(t.state)}${prioSelect(t.priority)}<label>Due date<input type="date" name="due_date" value="${dateValue(t.dueDate)}"></label></div>
${roleBoxes([...new Set([...o.roles, ...t.assignees])].sort(), t.assignees)}
<p class="tb-form-status" role="alert" data-tb-form-status></p>
<div class="tb-actions"><button type="button" class="ws-btn danger" data-tb-archive>Archive</button><span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Save</button></div>
</form>`;
}
function openDialog(html, back, task = null) {
const d = dialogEl();
d.innerHTML = html; d.dataset.return = back || ''; opened = task ? structuredClone(task) : null;
if (!d.open) d.showModal();
d.querySelector('input:not([type=checkbox]),select,button')?.focus();
}
const formStatus = (form, text) => { const s = form.querySelector('[data-tb-form-status]'); if (s) s.textContent = text; };
const due0 = v => (v ? `${v}T00:00:00.000Z` : null);
const picked = form => [...form.querySelectorAll('input[name=assignees]:checked')].map(i => i.value).sort();
function editBody(form, t) {
const f = new FormData(form), body = { op: 'edit', org: t.org, task_ref: t.ref, expect: t.digest };
const titleV = String(f.get('title') ?? ''), state = String(f.get('state')), priority = Number(f.get('priority')), dueV = due0(String(f.get('due_date') || '')), who = picked(form);
if (titleV !== (t.title ?? '')) body.title = titleV;
if (state !== t.state) body.state = state;
if (priority !== (t.priority ?? 0)) body.priority = priority;
if (dueV !== (t.dueDate ? `${dateValue(t.dueDate)}T00:00:00.000Z` : null)) body.due_date = dueV;
if (who.join('\n') !== [...t.assignees].sort().join('\n')) body.assignees = who;
return body;
}
// The competing write may have moved the task out of the board's filters, so the conflict read
// asks for its one organization unfiltered. The task was just changed, so the cap keeps it.
async function current(t) {
try {
const r = await fetch(`/api/workspace?${new URLSearchParams({ sections: 'tasks', org: t.org })}`, { cache: 'no-store', redirect: 'manual' });
if (r.type === 'opaqueredirect' || !r.ok) return null;
const b = await r.json();
return (Array.isArray(b?.tasks) && b.tasks.find(x => x.org === t.org && x.ref === t.ref)) || null;
} catch { return null; }
}
async function submit(form) {
const kind = form.dataset.tbForm, btns = form.querySelectorAll('button');
if (!form.reportValidity()) return;
let body, t = null;
if (kind === 'create') {
const f = new FormData(form);
body = { op: 'create', org: String(f.get('org')), title: String(f.get('title')), requirement: String(f.get('requirement')), state: String(f.get('state')), priority: Number(f.get('priority')), assignees: picked(form) };
const group = String(f.get('group') || ''); if (group) body.group = group;
const d = due0(String(f.get('due_date') || '')); if (d) body.due_date = d;
} else {
t = opened && key(opened) === form.dataset.tbKey ? opened : null;
if (!t) { formStatus(form, 'The task is no longer on the board. Nothing was written.'); return; }
body = editBody(form, t);
if (Object.keys(body).length === 4) { formStatus(form, 'Nothing changed.'); return; }
}
btns.forEach(b => { b.disabled = true; }); formStatus(form, 'Sending…');
const r = await write(body);
btns.forEach(b => { b.disabled = false; });
if (!r.ok) {
formStatus(form, r.message);
// The task changed under the form: the form shows the current task and keeps the refusal.
if (r.error === 'task-conflict' && t) {
try { await reload(); } catch {}
const now = find(key(t)) || await current(t);
if (now) { const msg = r.message; openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, msg); }
paint();
} else if (r.unknown) { try { await reload(); } catch {} paint(); }
return;
}
const label = orgOf(body.org).label;
notice = { text: kind === 'create' ? `${shortId(r.task_ref || '')} created in ${label}.` : `${shortId(t.ref)} saved in ${label}.` };
dialogEl().close();
try { await reload(); } catch {}
paint();
}
async function archive(form) {
const t = opened && key(opened) === form.dataset.tbKey ? opened : null;
if (!t) return;
if (!form.querySelector('[data-tb-archive]').hasAttribute('data-armed')) {
form.querySelector('[data-tb-archive]').setAttribute('data-armed', '');
form.querySelector('[data-tb-archive]').textContent = 'Archive: press again to confirm';
return;
}
const r = await write({ op: 'archive', org: t.org, task_ref: t.ref, expect: t.digest });
if (!r.ok) { formStatus(form, r.message); if (r.unknown) { try { await reload(); } catch {} paint(); } return; }
notice = { text: `${shortId(t.ref)} archived in ${orgOf(t.org).label}.` };
dialogEl().close();
try { await reload(); } catch {}
paint();
}
// ---------- events ----------
const inBoard = e => e.target.closest?.('[data-tasks-board]');
document.addEventListener('click', e => {
const d = e.target.closest?.('#tb-dialog');
if (d) {
if (e.target.closest('[data-tb-cancel]')) { e.preventDefault(); d.close(); }
else if (e.target.closest('[data-tb-archive]')) { e.preventDefault(); archive(e.target.closest('form')); }
return;
}
if (!inBoard(e) || !snap) return;
const lay = e.target.closest('[data-tb-layout]');
if (lay) { layouts[phone() ? 'phone' : 'wide'] = lay.dataset.tbLayout; save(LAYOUT, JSON.stringify(layouts)); paint(); return; }
const chip = e.target.closest('[data-tb-org]');
if (chip) {
filter.org = chip.dataset.tbOrg;
if (filter.project && filter.project !== '-' && filter.org && !filter.project.startsWith(`${filter.org}:`)) { filter.project = ''; refilter(); } else paint();
return;
}
const open = e.target.closest('[data-tb-open]');
if (open) { const t = find(open.dataset.tbOpen); if (t) openDialog(editForm(t), open.dataset.focusKey, t); return; }
const add = e.target.closest('[data-tb-new]');
if (add && !add.disabled) openDialog(createForm(filter.org), 'tb-new');
});
document.addEventListener('change', e => {
if (e.target.matches?.('[data-tb-create-org]')) {
const form = e.target.closest('form'), org = orgOf(e.target.value);
form.querySelector('select[name=group]').innerHTML = projectOptions(org.id, '');
form.querySelector('[data-tb-roles]').innerHTML = roleBoxes(org.roles, []);
return;
}
if (!inBoard(e) || !snap) return;
const f = e.target.dataset?.tbFilter;
if (f) { filter[f] = e.target.value; refilter(); return; }
if (e.target.matches('[data-tb-move]')) move(e.target.dataset.tbMove, e.target.value);
});
document.addEventListener('input', e => {
if (!e.target.matches?.('[data-tb-q]') || !inBoard(e) || !snap) return;
filter.q = e.target.value; paint('results');
});
document.addEventListener('submit', e => {
const form = e.target.closest?.('#tb-dialog form');
if (!form) return;
e.preventDefault(); submit(form);
});
// Drag and drop between columns: an enhancement over the list's State control and the edit form.
document.addEventListener('dragstart', e => {
const c = e.target.closest?.('[data-tasks-board] .tb-card[draggable=true]');
if (!c) return;
dragging = c.dataset.tbTask; dragEnded = new Promise(r => { c.addEventListener('dragend', r, { once: true }); });
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragging);
c.classList.add('is-dragging');
});
document.addEventListener('dragend', () => { dragging = null; document.querySelectorAll('.tb-col.is-over').forEach(c => c.classList.remove('is-over')); });
document.addEventListener('dragover', e => {
const col = dragging && e.target.closest?.('[data-tasks-board] [data-tb-col]');
if (!col) return;
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
document.querySelectorAll('.tb-col.is-over').forEach(c => { if (c !== col) c.classList.remove('is-over'); });
col.classList.add('is-over');
});
document.addEventListener('drop', e => {
const col = e.target.closest?.('[data-tasks-board] [data-tb-col]');
if (!col || !dragging) return;
e.preventDefault();
const k = dragging; dragging = null;
move(k, col.dataset.tbCol);
});
// The layout default follows the width when the viewer has not picked one for it.
window.matchMedia('(max-width:759px)').addEventListener('change', () => paint());
window.MosaicTasksBoard = Object.freeze({ view });
})();
+14 -2
View File
@@ -4,7 +4,8 @@
(() => {
const read = async path => {
const response = await fetch(path, { cache: 'no-store', redirect: 'manual' });
if (!response.ok || response.type === 'opaqueredirect') throw new Error('Read unavailable');
// The status travels with the failure, so a caller can tell a refusal (403) from a bus that is down.
if (!response.ok || response.type === 'opaqueredirect') { const error = new Error('Read unavailable'); error.status = response.type === 'opaqueredirect' ? 0 : response.status; throw error; }
return response.json();
};
const orgPath = (id, path) => `/api/orgs/${encodeURIComponent(id)}/${path}`;
@@ -20,5 +21,16 @@
const business = id => read(orgPath(id, 'business'));
const settings = id => read(orgPath(id, 'settings'));
const projectFiles = (id, project) => read(orgPath(id, `project-files?project=${encodeURIComponent(project)}`));
window.MosaicWorkspaceData = Object.freeze({ organizations, themes, projects, threads, instances, agents, claims, queue, business, settings, projectFiles });
// U1's cross-organization read (GET /api/workspace). Only the keys it accepts are sent, each once:
// an unknown or repeated key is a 400 there.
const WORKSPACE_KEYS = ['sections', 'org', 'status', 'state', 'name', 'sort', 'order', 'project'];
const workspace = (params = {}) => {
const q = new URLSearchParams();
for (const key of WORKSPACE_KEYS) { const v = params[key]; if (v !== undefined && v !== null && v !== '') q.set(key, Array.isArray(v) ? v.join(',') : String(v)); }
const qs = q.toString();
return read(`/api/workspace${qs ? `?${qs}` : ''}`);
};
// The three agent levels (root, organization, project) for every organization on the list, owner only.
const agentLevels = () => read('/api/agents/levels');
window.MosaicWorkspaceData = Object.freeze({ organizations, themes, projects, threads, instances, agents, claims, queue, business, settings, projectFiles, workspace, agentLevels });
})();
+469 -21
View File
@@ -128,7 +128,7 @@
if (toggle) { expanded = !expanded; save(ORGS_OPEN, expanded ? 'open' : 'closed'); paint(); document.querySelector('[data-org-toggle]')?.focus(); return; }
if (e.target.closest?.('[data-projects-toggle]')) { projectsOpen = !projectsOpen; save(PROJECTS_OPEN, projectsOpen ? 'open' : 'closed'); paint(); document.querySelector('[data-projects-toggle]')?.focus(); return; }
const group = e.target.closest?.('#workspace-nav [data-group]');
if (group) { grouping = group.dataset.group === 'flat' ? 'flat' : 'org'; save(GROUPING, grouping); paint(); document.querySelector(`#workspace-nav [data-group="${grouping}"]`)?.focus(); }
if (group) { grouping = group.dataset.group === 'flat' ? 'flat' : 'org'; save(GROUPING, grouping); paint(); document.querySelector(`#workspace-nav [data-group="${grouping}"]`)?.focus(); if (route().split('?')[0] === '/projects') document.dispatchEvent(new CustomEvent('mosaic-workspace-render')); }
});
window.addEventListener('hashchange', () => { if (orgs) paint(); });
document.addEventListener('mosaic-org-list', e => refresh(e.detail?.rows));
@@ -191,8 +191,10 @@
}).join('')}</ul>`;
}
async function dashboard() {
const id = window.MosaicOrg.id;
// A side-by-side pane passes its own organization and { pane: true, idPrefix }: the pane has no bus
// restart (that stays in the organization's full view) and its ids carry the pane's prefix.
async function dashboard(id = window.MosaicOrg.id, opts = {}) {
const pane = opts.pane === true, why = `${opts.idPrefix || ''}ws-reload-why`;
// Only an organization the membership list names is read; anything else reads nothing more.
const [listRead] = await Promise.all([data.organizations().catch(() => null), ensureThemes()]);
const list = Array.isArray(listRead) ? listRead : null;
@@ -210,11 +212,12 @@
// Header: the bus restart is console-restart.js's button where the Console allows it, otherwise
// the same control, present and disabled, with the reason.
const restart = window.MosaicConsole?.busButton?.('Restart bus') || '';
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${icon('restart', 'ws-busicon')}${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="Restarting the bus is the owner's trailed action, on a Console started with send and the action trail">${icon('restart')}Restart bus</button></span>`;
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="ws-reload-why" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart bus') || '';
const offTitle = pane ? "Restart the bus from this organization's full view" : "Restarting the bus is the owner's trailed action, on a Console started with send and the action trail";
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${icon('restart', 'ws-busicon')}${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="${esc(offTitle)}">${icon('restart')}Restart bus</button></span>`;
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="${esc(why)}" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
const [bcls, bglyph] = state(org);
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="ws-reload-why">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="${esc(why)}">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid;
const typechips = '<div class="ws-typechips ws-chips"><span class="ws-label">System types</span><span class="note">No system types yet. They arrive with the system types update.</span></div>';
@@ -262,38 +265,483 @@
}
// ---------- agents levels (sample 08) ----------
async function agentGrid(note = '') {
const [list] = await Promise.all([data.organizations().catch(() => null), ensureThemes()]);
const selected = window.MosaicOrg?.id;
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Only organization agents can be read today.${note ? ` ${note}` : ''}</p></div>`;
// Root and organization agents come from their files through /api/agents/levels (#1873 A1, read
// only). Organization agents' status comes from each bus. Root and project agents are not launched
// in A1, so they read "not running"; nothing here invents a status, a model or a memory.
const NOT_RUNNING = ['s-idle', '–', 'not running'];
const LEVEL_CLS = { root: 'root', org: 'org', project: 'proj' };
const projectName = (rows, id) => { const p = Array.isArray(rows) ? rows.find(r => r?.id === id) : null; return p?.name || id; };
let agentsPick = null; // the project chosen in "Available in a project", kept across repaints
// Repaints that section alone when the choice changes, one renderer per organization: a side-by-side
// pane can show another organization's agents next to the page's own.
let agentsAvailable = {};
function levelsTable(ctx, projectId) {
const { selOrg, lv, mine, levels, modelOf, names, others } = ctx;
const union = projectId && lv?.state === 'ok' ? lv.projects[projectId] : null;
if (union && union.state !== 'ok') return `<p class="ws-panel tight note">The agents for ${esc(names(projectId))} could not be resolved. ${esc(union.reason)}</p>`;
const set = union || (lv?.state === 'ok' ? lv.base : null);
const inst = mine?.inst ?? null;
const rows = [];
if (levels?.root?.state === 'ok' && levels.root.assistant) {
// The model the root file sets is shown as configured; the assistant's running state is not reported.
const configured = levels.root.agents.find(a => a.name === levels.root.assistant)?.model ?? null;
rows.push({ name: levels.root.assistant, level: 'root', definition: 'assistant', model: configured, scope: { kind: 'cross-org' }, overrides: [], narrowed: [], assistant: true });
}
if (set) {
rows.push(...set.agents);
// A bus instance the organization file no longer names still shows, as the bus reports it.
const known = new Set([...set.agents.map(a => a.name), ...set.excluded.map(e => e.name)]);
for (const name of Object.keys(inst || {})) if (!known.has(name)) rows.push({ name, level: 'org', definition: inst[name]?.definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] });
} else if (inst) {
rows.push(...Object.entries(inst).map(([name, v]) => ({ name, level: 'org', definition: v?.definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] })));
}
if (!set && inst === null) return '<p class="ws-panel tight note">Agents unavailable. The bus did not answer the instance read.</p>';
if (!rows.length) return '<p class="ws-panel tight note">No agents configured</p>';
const where = a => (a.level === 'root' ? 'Your root configuration' : a.level === 'org' ? `${label(selOrg)} file` : `${names(a.project)} project`);
const sub = a => [a.definition && a.definition !== a.name ? a.definition : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · ');
const status = a => {
if (a.assistant) return dot('s-unknown', '?', 'status not reported', true);
if (a.level !== 'org') return dot(...NOT_RUNNING, true);
if (inst === null) return '<span class="muted">status unavailable</span>';
return dot(...(inst[a.name] ? activity(a.name, inst[a.name], mine.agents) : NOT_RUNNING), true);
};
const model = a => {
if (a.level === 'org') return modelCell(a.name, modelOf(a.name) ?? a.model);
return a.model ? esc(a.model) : '<span class="muted">not set</span>';
};
const body = rows.map(a => { const s = sub(a); return `<tr data-agent="${esc(a.name)}"><td class="t1">${esc(a.name)}${s ? `<span class="t2">${esc(s)}</span>` : ''}</td><td><span class="ws-lvl ${LEVEL_CLS[a.level]}${a.level === 'org' ? ' ws-o" style="' + colours(selOrg) : ''}">${a.level}</span></td><td class="hide-m">${esc(where(a))}</td><td class="hide-m">${a.scope?.kind === 'cross-org' ? 'Shared across all organizations' : 'This organization only'}</td><td>${status(a)}</td><td class="hide-m mono">${model(a)}</td></tr>`; }).join('');
// What this project does not get: other projects' own agents, its exclusions and other organizations.
const away = [];
if (union) {
const here = new Set(union.agents.map(a => a.name));
for (const [pid, entries] of Object.entries(lv.defined || {})) if (pid !== projectId) for (const e of entries) if (e.form === 'define' && !here.has(e.name)) away.push(`${e.name} (${names(pid)} only)`);
}
if (set) {
for (const e of set.excluded) away.push(`${e.name} (${e.by === 'project' ? 'excluded in this project' : `turned off for ${label(selOrg)}`})`);
}
const otherOrgs = others.map(o => label(o));
const tail = otherOrgs.length ? `every ${otherOrgs.length > 1 ? `${otherOrgs.slice(0, -1).join(', ')} or ${otherOrgs.at(-1)}` : otherOrgs[0]} agent` : '';
const parts = [...away, tail].filter(Boolean);
const note = parts.length ? `<p class="note ws-away">Not available here: ${esc(parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}` : parts[0])}.</p>` : '';
return `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Level</th><th scope="col" class="hide-m">Defined in</th><th scope="col" class="hide-m">Memory</th><th scope="col">Status</th><th scope="col" class="hide-m">Model</th></tr></thead><tbody>${body}</tbody></table></div>${note}`;
}
async function agentGrid(note = '', selected = window.MosaicOrg?.id) {
const [list, levels] = await Promise.all([data.organizations().catch(() => null), data.agentLevels().catch(() => null), ensureThemes()]);
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Root and project agents are read from their files and are not launched yet.${note ? ` ${note}` : ''}</p></div>`;
if (!Array.isArray(list)) return `<section class="ws ws-agents" data-org-agents>${head}<p class="ws-panel tight note">Organizations unavailable.</p></section>`;
const valid = list.filter(org => !org.invalid);
const admitted = valid.some(org => org.id === selected);
const [results, business] = await Promise.all([
Promise.all(valid.map(org => Promise.all([data.instances(org.id).then(instancesOf).catch(() => null), data.agents(org.id).then(rowsOf).catch(() => null)]).then(([inst, agents]) => ({ org, inst, agents })))),
Promise.all(valid.map(org => Promise.all([
data.instances(org.id).then(instancesOf).catch(() => null), data.agents(org.id).then(rowsOf).catch(() => null),
projects.get(org.id) !== undefined ? projects.get(org.id) : data.projects(org.id).then(b => (Array.isArray(b?.rows) ? b.rows : null)).catch(() => null),
]).then(([inst, agents, rows]) => ({ org, inst, agents, rows, lv: levels?.orgs?.find(o => o?.id === org.id) ?? null })))),
admitted ? data.business(selected).catch(() => null) : null,
]);
const band = `<div class="ws-span-band"><span class="ws-av all md" aria-hidden="true">${LOGO}</span><b>assistant</b><span class="ws-lvl root">root</span>${dot('s-unknown', '?', 'status not reported', true)}<span>Works across</span><span class="orgs">${valid.map(org => av(org)).join('')}</span><span class="muted ws-band-note">The assistant talks with you across organizations. Its status is not reported on this page; set it up under Settings, Assistant.</span></div>`;
const n = Math.max(valid.length, 1);
const cell = (org, inner, cls = '') => `<div class="ws-o${cls}" style="${colours(org)}" data-org="${esc(org.id)}"><span class="mlabel">${av(org, 'xs')}${esc(label(org))}</span>${inner}</div>`;
const endc = i => (i === results.length - 1 ? ' endc' : '');
const pill = (name, item, agents) => { const cos = item?.definition === 'chief-of-staff' || name === 'chief'; const [acls, aglyph, aword] = activity(name, item, agents); return `<span class="ws-agent${cos ? ' cos' : ''}">${dot(acls, aglyph, aword)}${esc(name)}${cos && name !== 'chief' ? ' <small>chief</small>' : ''}</span>`; };
const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? '<span class="muted">Agents unavailable</span>' : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(k, v, agents)).join('') : '<span class="muted">No agents configured</span>', i === results.length - 1 ? ' endc' : '')).join('');
const levels = valid.length ? `<div class="ws-levels" style="grid-template-columns:200px repeat(${n},minmax(0,1fr))">
const idle = (name, small) => `<span class="ws-agent" data-level-agent="${esc(name)}">${dot(...NOT_RUNNING)}${esc(name)} <small>${esc(small)}</small></span>`;
// Root: one copy per organization, each running inside that organization; an opt-out is said.
const root = levels?.root;
const rootRow = !root ? '<div class="span endc"><span class="muted">Root agents unavailable. The levels read did not answer.</span></div>'
: root.state === 'invalid' ? `<div class="span endc"><span class="muted">Root agents unavailable. ${esc(root.reason)}</span></div>`
: !root.agents.some(a => a.definition !== 'assistant') ? '<div class="span endc"><span class="muted">No root agents. They are defined in agents.json in your configuration directory.</span></div>'
: results.map(({ org }, i) => {
const mineRoot = root.agents.filter(a => a.definition !== 'assistant');
const on = mineRoot.filter(a => !a.excludeOrgs.includes(org.id)), off = mineRoot.filter(a => a.excludeOrgs.includes(org.id));
return cell(org, `${on.map(a => idle(a.name, 'copy')).join('') || '<span class="muted">none</span>'}${off.length ? `<span class="note ws-off">${esc(off.map(a => a.name).join(', '))} turned off for ${esc(label(org))}</span>` : ''}`, endc(i));
}).join('');
const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? '<span class="muted">Agents unavailable</span>' : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(k, v, agents)).join('') : '<span class="muted">No agents configured</span>', endc(i))).join('');
// Project: the agents a project defines itself. Narrowing and exclusion show in the table.
const projRow = results.map(({ org, lv, rows }, i) => {
if (lv?.state !== 'ok') return cell(org, `<span class="muted">${lv?.state === 'invalid' ? 'Project agents unavailable: the organization file was refused' : 'Project agents unavailable'}</span>`, ` last${endc(i)}`);
const own = Object.entries(lv.defined).flatMap(([pid, entries]) => entries.filter(e => e.form === 'define').map(e => idle(e.name, projectName(rows, pid))));
return cell(org, own.join('') || '<span class="muted">none</span>', ` last${endc(i)}`);
}).join('');
const levelsGrid = valid.length ? `<div class="ws-levels" style="grid-template-columns:200px repeat(${n},minmax(0,1fr))">
<div class="lh hide-m"></div>${valid.map((org, i) => `<div class="oh hide-m ws-o${i === valid.length - 1 ? ' endc' : ''}" style="${colours(org)}">${av(org)}${esc(label(org))}</div>`).join('')}
<div class="lh"><span class="ws-lvl root">root</span><small>Your root configuration, shared by every organization.</small></div><div class="span endc"><span class="muted">Root agents are not available yet.</span></div>
<div class="lh"><span class="ws-lvl root">root</span><small>Your root configuration. Only you change it. Each organization runs its own copy, with memory, files and credentials kept in that organization.</small></div>${rootRow}
<div class="lh"><span class="ws-lvl org">org</span><small>Defined in the organization file. Each organization's agents work only inside it.</small></div>${orgRow}
<div class="lh last"><span class="ws-lvl proj">project</span><small>Defined in one project and available only there.</small></div><div class="span endc last"><span class="muted">Project agents are not available yet.</span></div>
<div class="lh last"><span class="ws-lvl proj">project</span><small>Defined in one project and available only there.</small></div>${projRow}
</div>` : '<p class="ws-panel tight note">No organizations yet.</p>';
const mine = results.find(r => r.org.id === selected);
const selOrg = list.find(o => o.id === selected) || { id: selected, label: selected };
const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model;
const table = !mine ? '<p class="ws-panel tight note">The selected organization is not on the list.</p>' : mine.inst === null ? '<p class="ws-panel tight note">Agents unavailable. The bus did not answer the instance read.</p>' : !Object.keys(mine.inst).length ? '<p class="ws-panel tight note">No agents configured</p>' : `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Level</th><th scope="col" class="hide-m">Defined in</th><th scope="col">Status</th><th scope="col" class="hide-m">Model</th></tr></thead><tbody>${Object.entries(mine.inst).map(([k, v]) => { const [acls, aglyph, aword] = activity(k, v, mine.agents), model = modelOf(k); return `<tr><td class="t1">${esc(k)}${v?.definition && v.definition !== k ? `<span class="t2">${esc(v.definition)}</span>` : ''}</td><td><span class="ws-lvl org ws-o" style="${colours(selOrg)}">org</span></td><td class="hide-m">${esc(label(selOrg))} file</td><td>${dot(acls, aglyph, aword, true)}</td><td class="hide-m mono">${modelCell(k, model)}</td></tr>`; }).join('')}</tbody></table></div>`;
const rules = `<div class="ws-panel tight ws-rules"><p><span class="ws-lvl org">org</span>Agents come from the organization file. Add, change or remove them under Settings, Agents.</p><p><span class="ws-lvl proj">project</span><span class="ws-lvl root">root</span>Not built yet, so nothing can be defined at these levels.</p><p class="muted">An agent's harness, model and thinking are set under Settings, Routing.</p></div>`;
return `<section class="ws ws-agents" data-org-agents>${head}${band}${levels}
<div class="ws-split ws-split-agents"><section>${secH(`Agents in ${esc(label(selOrg))}`, 'organization level', '')}${table}</section><section>${secH('Who changes what')}${rules}</section></div>
const lv = mine?.lv ?? null;
const projectIds = lv?.state === 'ok' ? Object.keys(lv.projects) : [];
const pick = projectIds.includes(agentsPick?.[selected]) ? agentsPick[selected] : projectIds[0] ?? null;
const ctx = { selOrg, lv, mine, levels, modelOf, names: id => projectName(mine?.rows, id), others: valid.filter(o => o.id !== selected) };
agentsAvailable = { ...agentsAvailable, [selected]: projectId => levelsTable(ctx, projectId) };
const chooser = projectIds.length ? `<label class="ws-psel">${av(selOrg, 'xs')}<span class="ws-sr">Project</span><select data-agents-project="${esc(selected)}" data-focus-key="agents-project">${projectIds.map(id => `<option value="${esc(id)}"${id === pick ? ' selected' : ''}>${esc(ctx.names(id))}</option>`).join('')}</select></label>` : '';
const title = projectIds.length ? 'Available in a project' : `Agents in ${esc(label(selOrg))}`;
const table = !mine ? '<p class="ws-panel tight note">The selected organization is not on the list.</p>' : levelsTable(ctx, pick);
const rules = `<div class="ws-panel tight ws-rules"><p><span class="ws-lvl root">root</span>You define root agents in agents.json in your configuration directory. The chief cannot change them.</p><p><span class="ws-lvl org">org</span>Agents come from the organization file. Add, change or remove them under Settings, Agents.</p><p><span class="ws-lvl proj">project</span>A project in the organization file can add an agent, narrow an inherited agent's limits or exclude one. It never widens them.</p><p><span class="ws-lvl proj">override</span>The most specific level wins. A row says which definition it replaces.</p><p class="muted">An agent's harness, model and thinking are set under Settings, Routing.</p></div>`;
return `<section class="ws ws-agents" data-org-agents>${head}${band}${levelsGrid}
<div class="ws-split ws-split-agents"><section><div class="ws-sec-h"><h2>${title}</h2>${chooser}</div><div data-agents-available>${table}</div></section><section>${secH('Who changes what')}${rules}</section></div>
</section>`;
}
document.addEventListener('change', e => {
const select = e.target.closest?.('[data-agents-project]');
const org = select?.dataset.agentsProject, draw = org && Object.hasOwn(agentsAvailable, org) ? agentsAvailable[org] : null;
const box = draw && select.closest('[data-org-agents]')?.querySelector('[data-agents-available]');
if (!box) return;
agentsPick = { ...agentsPick, [org]: select.value };
box.innerHTML = draw(select.value);
});
// ---------- across organizations (U3: samples 02, 04 and 05) ----------
// One read, GET /api/workspace (U1), backs the three pages. Each organization carries a state and
// the time it was read: one that is down, starting or slow is named with that state, and the rest
// still render. Filters live in the page's hash query, so a poll repaints the same view and a
// link reproduces it. Numbers come only from the read; a field no read gives is one honest line.
let pollMs = 10000;
const took = body => { const n = Number(body?.pollMs); if (Number.isFinite(n) && n >= 1000) pollMs = n; return body; };
const selHref = path => `#/orgs/${enc(window.MosaicOrg?.id || '')}${path}`;
const ORG_STATE = { ok: ['s-working', '●', 'running'], starting: ['s-waiting', '◐', 'starting'], down: ['s-offline', '○', 'down'], timeout: ['s-error', '▲', 'not answering'], invalid: ['s-error', '▲', 'invalid'] };
const orgState = o => ORG_STATE[o?.state] || ['s-unknown', '?', 'not reported'];
const msOf = iso => { const t = Date.parse(iso || ''); return Number.isFinite(t) ? t : null; };
const hm = iso => { const t = msOf(iso); return t === null ? '' : new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); };
// Today's times as HH:MM, older ones as a short date.
const when = iso => { const t = msOf(iso); if (t === null) return ''; const d = new Date(t); return d.toDateString() === new Date().toDateString() ? hm(iso) : d.toLocaleDateString([], { month: 'short', day: 'numeric' }); };
const span = iso => { const t = msOf(iso); if (t === null) return ''; const m = Math.max(0, Math.floor((Date.now() - t) / 60000)); return m < 1 ? 'under a minute' : m < 60 ? `${m} min` : m < 2880 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} days`; };
const ago = iso => { const s = span(iso); return !s ? '' : s === 'under a minute' ? 'just now' : `${s} ago`; };
const num = v => (Number.isFinite(v) ? v : 0);
const arr = v => (Array.isArray(v) ? v : []);
// The organizations the read names, with their colours, marks and states.
function orgsOf(body) {
const rows = arr(body?.orgs).filter(o => o && typeof o.org === 'string');
// The read names an organization by id; the sidebar's list carries its display label.
const known = id => (Array.isArray(orgs) ? orgs.find(x => x?.id === id) : null);
const byId = new Map(rows.map(o => [o.org, { id: o.org, label: known(o.org)?.label || o.label || o.org, theme: o.theme ?? known(o.org)?.theme ?? null }]));
const meta = id => byId.get(id) || { id, label: id };
return {
rows, meta, name: id => label(meta(id)), style: id => colours(meta(id)),
ref: (id, cls = '') => `<span class="ws-orgref${cls}">${av(meta(id))}${esc(label(meta(id)))}</span>`,
unread: rows.filter(o => o.state !== 'ok'),
};
}
const stateWords = o => `${esc((Array.isArray(orgs) ? orgs.find(x => x?.id === o.org)?.label : null) || o.label || o.org)} ${orgState(o)[2]}${o.at ? `, read ${hm(o.at)}` : ''}`;
const andList = items => (items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`);
// An organization that answered can still fail one read (errors in GET /api/workspace). The page
// names that organization and the reason rather than count the read as zero.
const READ_WHY = { 'channel-disabled': 'the chat channel is off', 'outcome-unknown': 'no answer', timeout: 'no answer in time' };
const missOf = (r, names) => { const n = r?.state === 'ok' && r.errors ? names.find(x => r.errors[x]) : null; return n ? READ_WHY[r.errors[n]] || String(r.errors[n]) : null; };
const missed = (o, names) => o.rows.filter(r => missOf(r, names)).map(r => `${esc(o.name(r.org))} (${esc(missOf(r, names))})`);
const missLine = (o, names, key, lead = 'Not read in') => { const m = missed(o, names); return m.length ? `<p class="note ws-miss" data-ws-miss="${key}">${lead} ${andList(m)}.</p>` : ''; };
// The organizations that answered and made every read a figure is built from. Only these count
// toward it: a failed read reaches the merge as an empty list, so its zero is not a figure.
const readers = (o, names) => new Set(o.rows.filter(r => r.state === 'ok' && !missOf(r, names)).map(r => r.org));
// Some organizations exist, and none of them could make these reads.
const noneRead = (o, names) => o.rows.length > 0 && !readers(o, names).size;
function failedPage(cls, attr, title, err) {
const why = err?.status === 403 ? 'This Console refused the read across organizations.' : err?.status === 0 ? 'You are signed out. Reload the page to sign in again.' : err?.status === 400 ? 'The read refused these filters. Clear them and try again.' : 'The read across organizations did not answer. The page tries again on its next refresh.';
return `<section class="ws ${cls}" ${attr} data-ws-failed><div class="ws-vh"><h1 tabindex="-1">${title}</h1><p class="sub">${why}</p></div></section>`;
}
// The Inbox count in the navigation follows the cross-organization read wherever it was made.
const tellInbox = rows => { if (Array.isArray(rows)) document.dispatchEvent(new CustomEvent('mosaic-workspace-inbox', { detail: { open: rows.length, blocking: rows.filter(d => d?.blocking).length } })); };
// A stacked bar and its per-organization figures, one segment per organization.
const bar = (o, parts, style = '') => `<div class="ws-bar"${style ? ` style="${style}"` : ''} aria-hidden="true">${parts.filter(p => p.n > 0).map(p => `<i class="ws-o" style="${o.style(p.id)};flex:${p.n}"></i>`).join('')}</div>`;
const by = (o, parts, fmt = n => n) => `<div class="ws-by">${parts.map(p => `<span>${av(o.meta(p.id))}<span class="ws-sr">${esc(o.name(p.id))}</span>${esc(fmt(p.n))}</span>`).join('')}</div>`;
const CLASS_CHIP = { gated: 'gated', 'within-role': 'within', routine: 'routine', 'cross-role': 'cross' };
const classChip = c => (c ? `<span class="ws-chip ${CLASS_CHIP[c] || ''}">${esc(c)}</span>` : '');
const itemKey = d => `${d.org}:${d.id}`;
const roleOf = d => d.session?.role || d.raisedBy || '';
const fromWords = (o, d, withOrg = true) => [withOrg ? o.name(d.org) : '', d.project?.name || 'No project', roleOf(d)].filter(Boolean).map(esc).join(' · ');
// ---- main dashboard (sample 02) ----
async function home() {
let body;
try { [body] = await Promise.all([data.workspace({ sections: ['projects', 'sessions', 'inbox', 'tasks', 'metrics'] }), ensureThemes()]); took(body); }
catch (err) { return failedPage('ws-home', 'data-ws-home', 'Dashboard', err); }
const o = orgsOf(body), m = body.metrics, t = m?.totals, per = arr(m?.orgs);
const inbox = arr(body.inbox), sessions = arr(body.sessions), projectRows = arr(body.projects), tasks = arr(body.tasks);
if (!noneRead(o, ['inbox'])) tellInbox(body.inbox);
const sessN = s => num(s?.working) + num(s?.waiting) + num(s?.idle) + num(s?.live);
// The reads each tile's figures come from (workspace-read.mjs orgRows).
const SRC = { needs: ['inbox'], sessions: ['agents', 'claims'], tasks: ['tasks'], projects: ['projects'] };
// Per-organization figures name only the organizations that made the tile's reads.
const parts = (key, f) => { const ok = readers(o, SRC[key]); return per.filter(x => ok.has(x.org)).map(x => ({ id: x.org, n: num(f(x)) })); };
const answered = o.rows.filter(r => r.state === 'ok').length;
const tile = (key, name, big, small, ps, delta) => {
const m = missed(o, SRC[key]);
if (!readers(o, SRC[key]).size) return `<div class="ws-tile quiet" data-ws-tile="${key}" data-ws-miss="${key}"><span class="ws-label">${name}</span><span class="ws-empty-t">Not read</span><span class="ws-delta">${andList(m)}</span></div>`;
return `<div class="ws-tile" data-ws-tile="${key}"><span class="ws-label">${name}</span><span class="ws-big">${big}${small ? `<small>${small}</small>` : ''}</span>${ps ? bar(o, ps) + by(o, ps) : ''}<span class="ws-delta">${delta}</span>${m.length ? `<span class="ws-delta ws-miss" data-ws-miss="${key}">Not read in ${andList(m)}</span>` : ''}</div>`;
};
const tiles = t && answered ? `<div class="ws-g5 keep2" data-ws-tiles>
${tile('needs', 'Needs you', num(t.needsYou), t.needsYou === 1 ? 'item' : 'items', parts('needs', x => x.needsYou), t.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting')}
${tile('sessions', 'Sessions', sessN(t.sessions), `${num(t.sessions?.working)} working`, parts('sessions', x => sessN(x.sessions)), `${num(t.sessions?.waiting)} waiting on you · ${num(t.sessions?.idle)} idle${num(t.sessions?.live) ? ` · ${num(t.sessions.live)} live ${t.sessions.live === 1 ? 'thread' : 'threads'}` : ''}`)}
${tile('tasks', 'Open tasks', num(t.openTasks), '', parts('tasks', x => x.openTasks), `<b>${num(t.doneThisWeek)}</b> done this week`)}
${tile('projects', 'Projects', num(t.projects?.active) + num(t.projects?.archived), `${num(t.projects?.active)} active`, parts('projects', x => num(x.projects?.active) + num(x.projects?.archived)), `${num(t.projects?.archived)} archived`)}
<div class="ws-tile quiet" data-ws-tile="spend"><span class="ws-label">Model spend</span><span class="ws-empty-t">No spend figures yet</span><span class="ws-delta">No read prices model use today.</span></div>
</div>` : '<p class="ws-panel tight note" data-ws-tiles>No organization answered, so there are no totals yet.</p>';
const cards = o.rows.map(r => {
const [cls, glyph, word] = orgState(r), id = r.org;
const extra = r.state === 'ok' ? '' : `<span class="why">${esc(r.reason || '')}${r.boot?.at ? `${r.reason ? ' · ' : ''}last start ${esc(when(r.boot.at))}` : ''}</span>`;
return `<li class="ws-orgcard ws-o${r.state === 'ok' ? '' : ' off'}" style="${o.style(id)}" data-ws-orgcard="${esc(id)}" data-state="${esc(r.state)}"><a href="${esc(orgHref(id))}/organization" data-focus-key="${esc(`orgcard:${id}`)}">${av(o.meta(id), 'md')}<span class="nm">${esc(o.name(id))}</span>${dot(cls, glyph, word, true)}<span class="at">read ${esc(hm(r.at))}</span>${extra}</a></li>`;
}).join('');
const recent = projectRows.filter(p => p.lastActivity).slice(0, 6);
const STATE_WORD = { working: 'has a session working', waiting: 'is waiting on you', idle: 'has an idle session', none: 'no live session' };
const trail = recent.length ? `<ol class="ws-trail" data-ws-recent>${recent.map(p => `<li class="ws-o" style="${o.style(p.org)}"><time datetime="${esc(p.lastActivity)}">${esc(when(p.lastActivity))}</time>${av(o.meta(p.org))}<span class="what"><a href="${esc(orgHref(p.org))}/projects/${enc(p.id)}" data-focus-key="${esc(`recent:${p.org}:${p.id}`)}"><b>${esc(p.name || p.key || p.id)}</b></a> ${STATE_WORD[p.state] || ''}${p.needsYou ? `, ${plural(p.needsYou, 'item')} for you` : ''} <span class="src">· ${esc(o.name(p.org))}</span></span></li>`).join('')}</ol>` : `<p class="ws-panel tight note" data-ws-recent>${noneRead(o, ['projects']) ? 'Projects not read' : 'No project activity yet'}</p>`;
const open = tasks.filter(x => !x.done);
const buckets = new Map();
for (const x of open) { const b = typeof x.bucket === 'string' && x.bucket ? x.bucket : ''; if (!buckets.has(b)) buckets.set(b, new Map()); const g = buckets.get(b); g.set(x.org, (g.get(x.org) || 0) + 1); }
const srows = [...buckets.entries()].map(([b, g]) => ({ name: b ? titleCase(b) : 'No bucket', g, n: [...g.values()].reduce((a, c) => a + c, 0) })).sort((a, b) => b.n - a.n);
const taskOrgs = readers(o, ['tasks']);
if (t && taskOrgs.size) srows.push({ name: 'Done this week', g: new Map(per.filter(x => taskOrgs.has(x.org)).map(x => [x.org, num(x.doneThisWeek)])), n: num(t.doneThisWeek) });
const max = Math.max(1, ...srows.map(s => s.n));
const states = srows.length ? `<div class="ws-panel tight ws-states" data-ws-states>${srows.map(s => { const ps = [...s.g.entries()].filter(([, n]) => n > 0).map(([id, n]) => ({ id, n })); return `<div class="ws-srow"><span class="sl">${esc(s.name)}</span><span class="sn">${s.n}</span>${bar(o, ps, `width:${Math.max(4, Math.round((s.n / max) * 90))}%`)}${by(o, ps)}</div>`; }).join('')}</div>` : `<p class="ws-panel tight note" data-ws-states>${noneRead(o, ['tasks']) ? 'Tasks not read' : 'No open tasks'}</p>`;
const needs = inbox.slice(0, 5);
const needsList = needs.length ? `<ul class="ws-needs" data-ws-needs>${needs.map(d => `<li class="ws-edge ws-o" style="${o.style(d.org)}">${av(o.meta(d.org), 'md')}<a class="q" href="${esc(selHref(`/inbox?d=${enc(itemKey(d))}`))}" data-focus-key="${esc(`need:${itemKey(d)}`)}">${esc(d.question || `Decision ${d.id}`)}</a><span class="src">${fromWords(o, d)}</span>${classChip(d.class)}</li>`).join('')}</ul>${inbox.length > needs.length ? `<p class="note ws-more-line">${inbox.length - needs.length} more in the inbox</p>` : ''}` : `<p class="ws-panel tight note" data-ws-needs>${noneRead(o, ['inbox']) ? 'Decisions not read' : 'Nothing needs you'}</p>`;
const now = sessions.filter(s => s.kind === 'thread' || s.state === 'working' || s.state === 'waiting');
const idle = sessions.length - now.length;
const who = s => (s.kind === 'thread' ? s.instance || s.thread || 'thread' : s.role || 'agent');
const what = s => s.project?.name || s.task?.title || '';
const st = s => { const [cls, glyph, word] = s.kind === 'thread' ? ['s-working', '●', 'live'] : ACTIVITY[s.state] || ['s-unknown', '?', s.state || 'not reported']; return `${dot(cls, glyph, word === 'waiting on you' ? 'waiting' : word, true)}${s.state === 'waiting' ? ' <span class="muted">on you</span>' : s.since ? ` <span class="muted">${esc(span(s.since))}</span>` : ''}`; };
const sessTable = now.length ? `<div class="ws-tbl"><table class="ws-sess" data-ws-sessions><thead><tr><th scope="col">Agent and project</th><th scope="col">Status</th></tr></thead><tbody>${now.slice(0, 8).map(s => `<tr><td class="who"><span class="ws-orgref">${av(o.meta(s.org), 'xs')}<span class="ws-sr">${esc(o.name(s.org))}: </span>${esc(who(s))}</span>${what(s) ? `<span class="muted"> · ${esc(what(s))}</span>` : ''}</td><td class="st">${st(s)}</td></tr>`).join('')}</tbody></table></div>${now.length > 8 ? `<p class="note ws-more-line">${now.length - 8} more on Agents</p>` : ''}` : `<p class="ws-panel tight note" data-ws-sessions>${noneRead(o, ['agents', 'claims']) ? 'Sessions not read' : `No session working right now${idle ? `; ${plural(idle, 'idle session')}` : ''}`}</p>`;
const total = t ? num(t.projects?.active) + num(t.projects?.archived) : null;
const allRead = !o.unread.length;
const sub = !o.rows.length ? 'You belong to no organization yet.'
: allRead ? `All ${plural(o.rows.length, 'organization')}${missed(o, ['projects']).length ? '' : ` and ${plural(total ?? 0, 'project')}`}. Totals add up every organization you belong to; each tile breaks them down by organization.`
: `${body.summary?.read ?? o.rows.length - o.unread.length} of ${plural(o.rows.length, 'organization')} answered. Totals add up the ones that answered; ${andList(o.unread.map(stateWords))}.`;
return `<article class="ws ws-home" data-ws-home>
<div class="ws-vh"><h1 tabindex="-1">Dashboard</h1><p class="sub">${sub}</p></div>
${tiles}
${secH('Organizations', `read ${esc(hm(body.readAt))}`)}
<ul class="ws-orgcards" data-ws-orgs>${cards || '<li class="ws-line">No organizations yet</li>'}</ul>
<div class="ws-split ws-home-split">
<section aria-label="Projects and tasks">
${secH('Recent project activity', 'latest change per project; there is no activity feed yet', `<a href="${esc(selHref('/projects'))}" data-focus-key="home-projects">All projects</a>`)}
${trail}${missLine(o, ['projects'], 'recent')}
${secH('Tasks by state', 'open tasks, every tracker', `<a href="${esc(selHref('/tasks'))}" data-focus-key="home-tasks">Open tasks</a>`)}
${states}${missLine(o, ['tasks'], 'states')}
</section>
<section aria-label="Needs you and sessions">
${secH('Needs you', '', `<a href="${esc(selHref('/inbox'))}" data-focus-key="home-inbox">Inbox</a>`)}
${needsList}${missLine(o, ['inbox'], 'needs')}
${secH('Sessions now', '', `<a href="${esc(selHref('/agents'))}" data-focus-key="home-agents">Agents</a>`)}
${sessTable}${missLine(o, ['agents', 'claims'], 'sessions')}
</section>
</div>
</article>`;
}
// ---- projects across organizations (sample 04, REQ-WS-5) ----
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const listParam = (q, key, ok) => { const v = [...new Set(String(q?.get?.(key) || '').split(',').map(s => s.trim()).filter(ok))]; return v.length ? v : null; };
// The hash query, cleaned to what the API accepts, so a hand-edited link never reads as "all".
function projectQuery(q) {
const sort = ['activity', 'name', 'org', 'status'].includes(q?.get?.('sort')) ? q.get('sort') : 'activity';
const order = ['asc', 'desc'].includes(q?.get?.('order')) ? q.get('order') : sort === 'activity' ? 'desc' : 'asc';
const name = String(q?.get?.('name') || '').trim().slice(0, 200);
return {
org: listParam(q, 'org', id => ORG_ID.test(id)), status: listParam(q, 'status', s => ['active', 'archived'].includes(s)) || ['active'],
state: listParam(q, 'state', s => ['working', 'waiting', 'idle', 'none'].includes(s)), name: name || null, sort, order,
};
}
const SORT_WORD = { activity: 'latest activity', name: 'name', org: 'organization', status: 'status' };
const STATUS_OPTS = [['active', 'Active'], ['archived', 'Archived'], ['active,archived', 'Active and archived']];
const STATE_OPTS = [['', 'Any session state'], ['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['none', 'No session']];
function orgChips(o, counts, chosen, total, src = []) {
const all = !chosen;
return `<div class="ws-fchips" role="group" aria-label="Organization"><button type="button" class="f plain" data-ws-org="" data-focus-key="chip:all" aria-pressed="${all}">All <span class="n">${total}</span></button>${o.rows.map(r => `<button type="button" class="f ws-o" style="${o.style(r.org)}" data-ws-org="${esc(r.org)}" data-focus-key="${esc(`chip:${r.org}`)}" aria-pressed="${!!chosen?.includes(r.org)}">${av(o.meta(r.org))}${esc(o.name(r.org))}<span class="n">${r.state === 'ok' && !missOf(r, src) ? counts.get(r.org) || 0 : `<span class="ws-sr">, </span>${r.state === 'ok' ? 'not read' : esc(orgState(r)[2])}`}</span></button>`).join('')}</div>`;
}
async function projectsPage(q) {
const f = projectQuery(q);
const params = { sections: ['projects'], org: f.org, status: f.status, state: f.state, name: f.name, sort: f.sort, order: f.order };
let body, all;
try {
[body, all] = await Promise.all([data.workspace(params), f.org ? data.workspace({ ...params, org: null }) : null, ensureThemes()]);
took(body);
} catch (err) { return failedPage('ws-projects', 'data-ws-projects', 'Projects', err); }
const every = orgsOf(all || body), o = orgsOf(body);
const counts = new Map();
for (const p of arr((all || body).projects)) counts.set(p.org, (counts.get(p.org) || 0) + 1);
const rows = arr(body.projects);
const flat = grouping === 'flat';
const cols = flat ? 8 : 7;
const aria = key => (f.sort === key ? ` aria-sort="${f.order === 'desc' ? 'descending' : 'ascending'}" class="sorted` : ' class="');
const ar = key => (f.sort === key ? `<span class="ar" aria-hidden="true">${f.order === 'desc' ? '↓' : '↑'}</span>` : '');
const head = `<tr>${flat ? `<th scope="col"${aria('org')} hide-m">Organization${ar('org')}</th>` : ''}<th scope="col"${aria('name')}">Project${ar('name')}</th><th scope="col"${aria('status')}">Status${ar('status')}</th><th scope="col" class="hide-m">Sessions</th><th scope="col" class="num hide-m">Open tasks</th><th scope="col" class="num">Needs you</th><th scope="col"${aria('activity')} hide-m">Last activity${ar('activity')}</th><th scope="col" class="hide-m">Git</th></tr>`;
const SESS = [['working', 's-working', '●', 'working'], ['waiting', 's-waiting', '◐', 'waiting'], ['idle', 's-idle', '–', 'idle']];
// A row's figures come from more reads than its project list (workspace-read.mjs orgRows). A
// figure whose reads failed in the row's organization says "not read", never 0 or none.
const DEP = { sessions: ['agents', 'claims', 'threads', 'tasks'], tasks: ['tasks'], needs: ['inbox', 'tasks'], activity: ['tasks', 'inbox', 'agents', 'claims', 'threads'] };
const notRead = (p, key) => missOf(o.rows.find(r => r.org === p.org), DEP[key]);
const nr = why => `<span class="muted" title="${esc(why)}">not read</span>`;
const sessCell = p => { const s = p.sessions || {}, have = SESS.filter(([k]) => num(s[k]) > 0); if (!have.length) return '<span class="muted">none</span>'; const [k, cls, glyph, word] = have[0]; return `${dot(cls, glyph, `${num(s[k])} ${word}`, true)}${have.length > 1 ? `<span class="t2">${have.slice(1).map(([k2, , , w2]) => `${num(s[k2])} ${w2}`).join(' · ')}</span>` : ''}`; };
const row = p => {
const href = `${orgHref(p.org)}/projects/${enc(p.id)}`, active = p.status === 'active';
return `<tr class="ws-edge ws-o" style="${o.style(p.org)}" data-ws-project="${esc(`${p.org}:${p.id}`)}">
${flat ? `<td class="hide-m">${o.ref(p.org)}</td>` : ''}
<td><a class="t1 nm" href="${esc(href)}" data-focus-key="${esc(`prow:${p.org}:${p.id}`)}">${av(o.meta(p.org), 'xs mob')}${esc(p.name || p.key || p.id)}<span class="ws-sr"> in ${esc(o.name(p.org))}</span></a>${p.key ? `<span class="t2 mono hide-m">${esc(p.key)}</span>` : ''}<span class="t2 show-m">${p.lastActivity ? esc(ago(p.lastActivity)) : 'No activity yet'}</span></td>
<td>${active ? dot('s-ok', '●', 'active', true) : dot('s-idle', '–', 'archived', true)}</td>
<td class="hide-m">${notRead(p, 'sessions') ? nr(notRead(p, 'sessions')) : sessCell(p)}</td>
<td class="num hide-m">${notRead(p, 'tasks') ? nr(notRead(p, 'tasks')) : num(p.openTasks)}</td>
<td class="num">${notRead(p, 'needs') ? nr(notRead(p, 'needs')) : p.needsYou ? `<span class="ws-n hot">${num(p.needsYou)}</span>` : '<span class="muted">0</span>'}</td>
<td class="hide-m">${p.lastActivity ? `<time datetime="${esc(p.lastActivity)}">${esc(ago(p.lastActivity))}</time>${notRead(p, 'activity') ? '<span class="t2">some reads missing</span>' : ''}` : notRead(p, 'activity') ? nr(notRead(p, 'activity')) : '<span class="muted">none yet</span>'}</td>
<td class="hide-m">${p.git ? `<span class="ws-chip outline">${esc(p.git)}</span>` : '<span class="muted">none</span>'}</td>
</tr>`;
};
const unreadRow = r => `<tr class="ws-unread"><td colspan="${cols}">${dot(...orgState(r), true)} <span class="muted">Not read${r.at ? ` at ${esc(hm(r.at))}` : ''}${r.reason ? `: ${esc(r.reason)}` : ''}</span></td></tr>`;
let tbody;
if (flat) tbody = rows.length ? rows.map(row).join('') : `<tr><td colspan="${cols}" class="muted">${noneRead(o, ['projects']) ? 'Projects not read' : f.org || f.state || f.name || f.status.join() !== 'active' ? 'No projects match these filters' : 'No projects yet'}</td></tr>`;
else {
const groups = (f.sort === 'org' && f.order === 'desc' ? [...o.rows].reverse() : o.rows);
tbody = groups.map(r => {
const mine = rows.filter(p => p.org === r.org);
const h = `<tr class="ws-grp-row ws-o" style="${o.style(r.org)}" data-ws-group-org="${esc(r.org)}"><th colspan="${cols}" scope="colgroup">${av(o.meta(r.org))}<span>${esc(o.name(r.org))}</span>${r.state === 'ok' && !missOf(r, ['projects']) ? `<span class="ws-n">${mine.length}</span>` : ''}</th></tr>`;
const why = missOf(r, ['projects']);
return h + (r.state !== 'ok' ? unreadRow(r) : why ? `<tr class="ws-unread"><td colspan="${cols}">${dot('s-unknown', '?', 'not read', true)} <span class="muted">Projects not read: ${esc(why)}</span></td></tr>` : mine.length ? mine.map(row).join('') : `<tr><td colspan="${cols}" class="muted">No projects match</td></tr>`);
}).join('') || `<tr><td colspan="${cols}" class="muted">No organizations yet</td></tr>`;
}
const unreadNote = flat && o.unread.length ? `<p class="note ws-unread-note" data-ws-unread>Not in this list: ${andList(o.unread.map(stateWords))}.</p>` : '';
// Projects that were read can still lack figures from the other reads; the cells say "not read".
const missNote = (flat ? missLine(o, ['projects'], 'projects') : '') + missLine({ ...o, rows: o.rows.filter(r => !missOf(r, ['projects'])) }, DEP.activity, 'figures', 'Some figures not read in');
const shown = new Set(rows.map(p => p.org)).size;
const sub = `${plural(rows.length, 'project')} in ${plural(shown, 'organization')}, sorted by ${SORT_WORD[f.sort]}${f.sort === 'activity' ? (f.order === 'desc' ? ', newest first' : ', oldest first') : f.order === 'desc' ? ', descending' : ''}. Each row names its organization by mark and colour edge.`;
const statusVal = f.status.join(',');
const stateVal = f.state?.length === 1 ? f.state[0] : '';
return `<section class="ws ws-projects" data-ws-projects>
<div class="ws-vh"><h1 tabindex="-1">Projects</h1><div class="ws-right"><a class="ws-btn" href="${esc(selHref('/chat'))}" data-focus-key="proj-new">${icon('plus')}New project</a></div><p class="sub">${sub}</p></div>
<div class="ws-toolbar">
<label class="ws-search">${icon('search', 'sm')}<span class="ws-sr">Filter projects by name</span><input type="search" data-ws-name data-focus-key="proj-name" placeholder="Filter by name" maxlength="200" autocomplete="off" value="${esc(String(q?.get?.('name') || '').slice(0, 200))}"></label>
${orgChips(every, counts, f.org, arr((all || body).projects).length, ['projects'])}
<label class="ws-sel"><span class="ws-label">Status</span><select data-ws-status data-focus-key="proj-status">${STATUS_OPTS.map(([v, l]) => `<option value="${v}"${v === statusVal ? ' selected' : ''}>${l}</option>`).join('')}</select></label>
<label class="ws-sel"><span class="ws-label">State</span><select data-ws-state data-focus-key="proj-state">${STATE_OPTS.map(([v, l]) => `<option value="${v}"${v === stateVal ? ' selected' : ''}>${l}</option>`).join('')}</select></label>
<div class="ws-right"><span class="ws-label hide-m" id="ws-sort-l">Sort</span><span class="ws-seg" role="group" aria-labelledby="ws-sort-l">${['activity', 'name', 'org', 'status'].map(s => `<button type="button" data-ws-sort="${s}" data-focus-key="sort:${s}" aria-pressed="${f.sort === s}"${f.sort === s ? ` title="Reverse the order"` : ''}>${titleCase(s === 'org' ? 'org' : s)}${f.sort === s ? `<span class="ws-sr">, ${f.order === 'desc' ? 'descending' : 'ascending'}</span>` : ''}</button>`).join('')}</span>
<span class="ws-seg" role="group" aria-label="Project layout"><button type="button" data-ws-group="org" data-focus-key="pgroup-org" aria-pressed="${!flat}">By org</button><button type="button" data-ws-group="flat" data-focus-key="pgroup-flat" aria-pressed="${flat}">Flat</button></span></div>
</div>
${unreadNote}${missNote}
<div class="ws-tbl"><table class="ws-ptable" data-ws-table data-grouping="${flat ? 'flat' : 'org'}"><caption class="ws-sr">Projects across organizations</caption><thead>${head}</thead><tbody>${tbody}</tbody></table></div>
</section>`;
}
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, refresh: () => refresh() });
// ---- inbox across organizations (sample 05) ----
async function inboxPage(q) {
const org = listParam(q, 'org', id => ORG_ID.test(id));
const want = String(q?.get?.('d') || '');
let body, all;
try {
[body, all] = await Promise.all([data.workspace({ sections: ['inbox'], org }), org ? data.workspace({ sections: ['inbox'] }) : null, ensureThemes()]);
took(body);
} catch (err) { return failedPage('ws-inbox', 'data-ws-inbox', 'Inbox', err); }
const every = orgsOf(all || body), o = orgsOf(body);
const everyRows = arr((all || body).inbox);
if (!noneRead(every, ['inbox'])) tellInbox(everyRows);
const counts = new Map();
for (const d of everyRows) counts.set(d.org, (counts.get(d.org) || 0) + 1);
const rows = arr(body.inbox);
const picked = want === 'none' ? null : rows.find(d => itemKey(d) === want) || (want ? null : rows[0] || null);
const qs = d => { const p = new URLSearchParams(); if (org) p.set('org', org.join(',')); p.set('d', d ? itemKey(d) : 'none'); return `/inbox?${p}`; };
const row = d => {
const on = picked && itemKey(d) === itemKey(picked);
return `<tr class="ws-row ws-edge ws-o${on ? ' on' : ''}" style="${o.style(d.org)}" data-ws-item="${esc(itemKey(d))}">
<td class="src hide-m">${o.ref(d.org)}<span class="via">${fromWords(o, d, false)}</span></td>
<td><span class="ws-msrc show-m">${av(o.meta(d.org), 'xs')}${fromWords(o, d)}</span><a class="q" href="${esc(selHref(qs(d)))}" data-ws-select="${esc(itemKey(d))}" data-focus-key="${esc(`item:${itemKey(d)}`)}"${on ? ' aria-current="true"' : ''}>${esc(d.question || `Decision ${d.id}`)}</a><span class="via">decision <span class="mono">${esc(d.id)}</span>${d.taskRef ? ` · task ${esc(d.taskRef)}` : ''}</span><span class="show-m">${classChip(d.class)}${d.blocking ? ' <span class="ws-chip block">blocking</span>' : ''}</span></td>
<td class="hide-m">${classChip(d.class)}${d.blocking ? ' <span class="ws-chip block">blocking</span>' : ''}</td>
<td class="num">${d.at ? `<time datetime="${esc(d.at)}">${esc(span(d.at))}</time>` : '<span class="muted">not reported</span>'}</td>
</tr>`;
};
const unread = (o.unread.length ? `<p class="note ws-unread-note" data-ws-unread>Not in this list: ${andList(o.unread.map(stateWords))}.</p>` : '') + missLine(o, ['inbox'], 'inbox');
const table = rows.length ? `<div class="ws-tbl"><table class="ws-ibx" data-ws-items><caption class="ws-sr">Items that need you</caption><thead><tr><th scope="col" class="hide-m">Source</th><th scope="col">Needs</th><th scope="col" class="hide-m">Class</th><th scope="col" class="num">Waiting</th></tr></thead><tbody>${rows.map(row).join('')}</tbody></table></div>` : `<p class="ws-panel tight note" data-ws-items>${noneRead(o, ['inbox']) ? 'Decisions not read' : org ? 'Nothing needs you in these organizations' : 'Nothing needs you'}</p>`;
let insp = '';
if (picked) {
const d = picked, id = esc(d.id);
const decideHref = `${orgHref(d.org)}/inbox/${enc(d.id)}`;
const opts = arr(d.options);
const optList = opts.length ? `<ul class="ws-opts">${opts.map(x => `<li><div class="row"><span class="k">${esc(x.key)}</span><b>${esc(x.text || '')}</b></div><div class="row"><a class="ws-btn" href="${esc(decideHref)}" data-focus-key="${esc(`answer:${itemKey(d)}:${x.key}`)}">${d.console ? `Answer ${esc(x.key)}…` : 'Open the decision'}<span class="ws-sr"> on ${esc(o.name(d.org))}'s bus</span></a></div></li>`).join('')}</ul>` : `<p class="note">No options were given. <a href="${esc(decideHref)}" data-focus-key="${esc(`answer:${itemKey(d)}`)}">Open the decision</a></p>`;
const cmds = arr(d.decide).filter(c => typeof c?.command === 'string' && c.command);
const term = cmds.length ? `<div data-ws-cmds><span class="ws-label">Answer from a terminal</span>${cmds.map(c => `<div class="ws-cmd"><code>${esc(c.command)}</code><button type="button" class="ws-btn" data-copy="${esc(c.command)}" data-focus-key="${esc(`copy:${itemKey(d)}:${c.key}`)}" aria-label="Copy the command for option ${esc(c.key)}" title="Copy">${icon('copy')}</button></div>`).join('')}<p class="note">The inbox mixes organizations, so the copied command always names the organization's bus.</p><p class="small muted" id="copy-status" role="status"></p></div>` : '<p class="note" data-ws-cmds>No terminal command for this item: its id or options cannot be passed to a shell safely.</p>';
const by = roleOf(d);
insp = `<aside class="ws-insp ws-o" style="${o.style(d.org)}" aria-label="Decision ${id}" data-ws-insp="${esc(itemKey(d))}">
<header>${av(o.meta(d.org))}<span class="ws-label">Decision <span class="mono">${id}</span></span>${classChip(d.class)}<a class="x" href="${esc(selHref(qs(null)))}" data-ws-select="none" data-focus-key="insp-close" aria-label="Close the decision">${icon('close')}</a></header>
<div class="body">
<h3>${esc(d.question || `Decision ${d.id}`)}</h3>
<div class="ws-auth">${esc(o.name(d.org))} · ${esc(d.project?.name || 'No project')}${by ? ` · raised by <b>${esc(by)}</b>` : ''}${d.at ? ` at ${esc(hm(d.at))}` : ''}.${d.class === 'gated' ? ' Only you can answer a gated decision.' : ''}</div>
${d.task?.title || d.taskRef ? `<p class="note">Task ${esc(d.taskRef || '')}${d.task?.title ? ` · ${esc(d.task.title)}` : ''}</p>` : ''}
${optList}
${term}
<div><span class="ws-label">History</span><p class="note">Not built yet.</p></div>
</div>
</aside>`;
}
const orgsWith = new Set(rows.map(d => d.org)).size;
const sub = rows.length ? `${plural(rows.length, 'item')} need${rows.length === 1 ? 's' : ''} you, from ${plural(orgsWith, 'organization')}. Each row names where it came from. An item leaves only when it is answered.` : `${noneRead(o, ['inbox']) ? 'No organization\'s inbox could be read.' : missed(o, ['inbox']).length || o.unread.length ? 'Nothing needs you in the organizations that were read.' : 'Nothing needs you right now.'} An item leaves only when it is answered.`;
return `<section class="ws ws-inbox" data-ws-inbox>
<div class="ws-vh"><h1 tabindex="-1">Inbox</h1><p class="sub">${sub}</p></div>
<div class="ws-toolbar">
${orgChips(every, counts, org, everyRows.length, ['inbox'])}
<div class="ws-right"><span class="ws-seg" role="group" aria-label="State" aria-describedby="ws-tabs-why"><button type="button" aria-pressed="true" data-focus-key="tab-needs">Needs you</button><button type="button" aria-pressed="false" disabled title="Not built yet">Seen</button><button type="button" aria-pressed="false" disabled title="Not built yet">Answered</button></span><span class="note" id="ws-tabs-why">Seen and Answered are not built yet.</span></div>
</div>
${unread}
<div class="ws-ibx-grid${insp ? ' with-insp' : ''}"><div class="ws-ibx-list">${table}
${secH('Answered today', 'kept for 24 hours, then in the trail')}
<p class="ws-panel tight note" data-ws-answered>Answered and expired items are not built yet.</p></div>
${insp}
</div>
</section>`;
}
// ---- controls: filters write the hash query and repaint in place ----
const pathOf = () => route().split('?')[0];
const queryOf = () => new URLSearchParams(route().split('?')[1] || '');
function setQuery(change, render = true) {
const q = queryOf();
for (const [k, v] of Object.entries(change)) { if (v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length)) q.delete(k); else q.set(k, Array.isArray(v) ? v.join(',') : String(v)); }
const s = q.toString();
history.replaceState(history.state, '', `${selHref(pathOf())}${s ? `?${s}` : ''}`);
if (render) document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
}
function setGrouping(g) {
grouping = g === 'flat' ? 'flat' : 'org'; save(GROUPING, grouping); paint();
if (pathOf() === '/projects') document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
}
document.addEventListener('click', e => {
const page = e.target.closest?.('[data-ws-projects],[data-ws-inbox]');
if (!page) return;
const chip = e.target.closest('[data-ws-org]');
if (chip) {
const id = chip.dataset.wsOrg, now = listParam(queryOf(), 'org', x => ORG_ID.test(x)) || [];
const next = !id ? [] : now.includes(id) ? now.filter(x => x !== id) : [...now, id];
setQuery(page.hasAttribute('data-ws-inbox') ? { org: next, d: null } : { org: next });
return;
}
const sort = e.target.closest('[data-ws-sort]');
if (sort) {
const f = projectQuery(queryOf()), s = sort.dataset.wsSort;
setQuery(s === f.sort ? { sort: s, order: f.order === 'desc' ? 'asc' : 'desc' } : { sort: s, order: null });
return;
}
const grp = e.target.closest('[data-ws-group]');
if (grp) { setGrouping(grp.dataset.wsGroup); return; }
const pick = e.target.closest('[data-ws-select]');
if (pick && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) { e.preventDefault(); setQuery({ d: pick.dataset.wsSelect }); }
});
document.addEventListener('change', e => {
if (e.target.matches?.('[data-ws-projects] [data-ws-status]')) setQuery({ status: e.target.value === 'active' ? null : e.target.value });
else if (e.target.matches?.('[data-ws-projects] [data-ws-state]')) setQuery({ state: e.target.value || null });
});
// Typing writes the hash at once, so a poll repaint keeps what was typed; the read waits a beat.
let typing;
document.addEventListener('input', e => {
if (!e.target.matches?.('[data-ws-projects] [data-ws-name]')) return;
setQuery({ name: e.target.value.slice(0, 200) || null }, false);
clearTimeout(typing); typing = setTimeout(() => document.dispatchEvent(new CustomEvent('mosaic-workspace-render')), 250);
});
// U4b: the org mark (colour and initials) for the task board (tasks-board.js).
const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials });
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
refresh();
})();
+9 -3
View File
@@ -21,6 +21,8 @@ import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.m
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
import { consoleRestartHTTP } from './console-restart-http.mjs';
import { workspaceHTTP } from './workspace-read.mjs';
import { agentLevelsHTTP } from './agent-levels-read.mjs';
import { TASK_WRITE_ROUTE, workspaceTasksHTTP } from './workspace-tasks.mjs';
import { actor } from './action-trail.mjs';
import { devHostName } from '../../cli/src/dev-local-owner.mjs';
@@ -58,12 +60,12 @@ 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', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.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', 'workspace-data.js', 'workspace.js', 'tasks-board.js', 'panes.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']],
['/files.css', ['files.css', 'text/css; charset=utf-8']],
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css', 'tasks-board.css', 'panes.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
['/display.js', ['display.js', 'text/javascript; charset=utf-8']],
['/inbox.js', ['inbox.js', 'text/javascript; charset=utf-8']],
['/inbox.css', ['inbox.css', 'text/css; charset=utf-8']],
@@ -771,7 +773,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, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = 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, agentLevels = null } = {}) {
// 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.
@@ -808,6 +810,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
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 }) }));
// #1873 A1: the agent levels behind the Agents grid. Read-only; owner only, like the workspace.
registered.add('GET', '/api/agents/levels', (req, res, context) => agentLevelsHTTP(req, res, { ...context, organizations, levels: agentLevels, log, ...(workspaceTimeoutMs === undefined ? {} : { timeoutMs: workspaceTimeoutMs }) }));
// U4b (#1873): the task board's writes, each to the one organization it names (workspace-tasks.mjs).
registered.add('POST', TASK_WRITE_ROUTE, (req, res, context) => workspaceTasksHTTP(req, res, { ...context, organizations, trail, log }));
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.
+53 -8
View File
@@ -23,6 +23,16 @@ const PROJECT_SELECTOR = /^([a-z0-9][a-z0-9._-]{0,63}):([A-Za-z0-9._-]{1,128})$/
const SHELL_SAFE = /^[A-Za-z0-9._:-]{1,128}$/;
const WEEK_MS = 7 * 24 * 3600 * 1000;
const PER_ORG_CONCURRENCY = 3;
// U4b (#1873): the task board's states, the built-in store's (broker STORE_STATES), in board order.
export const TASK_STATES = Object.freeze(['todo', 'in-progress', 'in-review', 'blocked', 'done']);
// U4b: at most this many tasks per organization reach one answer. The bus's tasks read has no
// cursor for the merged form, so the cap bounds the answer and the board's DOM. It applies last:
// projects, metrics, sessions and the inbox count every task, and the filters run before it, so a
// filter reaches every task the org holds. An org past it keeps its most recently changed tasks
// and says how many matched (`tasksHeld`); the board says the list is cut.
export const TASKS_PER_ORG = 500;
const ROLE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const DIGEST = /^[0-9a-f]{64}$/;
// The broker reads each section needs. Each name is one human-side read the org pages already make.
const NEEDS = {
@@ -57,7 +67,7 @@ function oneOf(name, value, allowed) {
// 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 };
const q = { sections: new Set(WORKSPACE_SECTIONS), orgs: null, status: new Set(['active']), state: null, name: null, sort: 'activity', order: null, project: null, taskState: null, assignee: null, label: null };
for (const [key, value] of params) {
if (seen.has(key)) bad(`${key} is given twice`);
seen.add(key);
@@ -77,6 +87,17 @@ export function parseWorkspaceQuery(search = '') {
const m = PROJECT_SELECTOR.exec(value);
if (!m) bad('project takes <org>:<project id or key>');
q.project = { org: m[1], key: m[2] };
// U4b: the task filters. A Vikunja task whose bucket the bus does not name has state null and
// matches only `open`.
} else if (key === 'task-state') q.taskState = oneOf('task-state', value, [...TASK_STATES, 'open']);
else if (key === 'assignee') {
const names = list(value);
if (!names.length || names.some(n => !ROLE_NAME.test(n))) bad('assignee takes role names, comma separated');
q.assignee = new Set(names);
} else if (key === 'label') {
const ids = list(value).map(Number);
if (!ids.length || ids.some(n => !Number.isSafeInteger(n) || n < 1)) bad('label takes label ids, comma separated');
q.label = new Set(ids);
} else bad(`unknown query key ${key}`);
}
q.order ??= q.sort === 'activity' ? 'desc' : 'asc';
@@ -172,7 +193,8 @@ function validRead(name, value) {
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)));
// A Vikunja row's bucket is the bucket's numeric id (tasks digest.mjs taskFields); a store row's is its state.
if (name === 'tasks') return strings(v, ['task_ref', 'project_key', 'at']) && optional(v.fields, f => record(f) && strings(f, ['title']) && optional(f.bucket, b => string(b) || Number.isSafeInteger(b)) && ['assignees', 'labels'].every(k => optional(f[k], Array.isArray)));
if (name === 'decisions') return strings(v, ['at']) && optional(v.closed, c => record(c) && strings(c, ['op', 'at']));
return true;
});
@@ -188,14 +210,25 @@ function orgRows(org, data, doc, nowMs) {
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 taskRows = arr(data.tasks).filter(t => t && typeof t.task_ref === 'string' && !t.fields?.gone && t.archived !== true);
const bots = new Map(Object.entries(doc?.roles ?? {}).filter(([, r]) => r?.tracker?.botId !== undefined).map(([role, r]) => [r.tracker.botId, role]));
const tasks = taskRows.map(t => {
const f = t.fields ?? {};
// U4b: a built-in store row (source 'store') carries role names, its state and its group; the
// board files a store task under the org project whose id or key is its group. A Vikunja row
// carries bot ids, which map to roles through the org file, and a numeric bucket the bus does
// not name, so its state is only known when it is done.
const store = t.source === 'store';
const state = store ? (TASK_STATES.includes(f.state) ? f.state : null) : (f.done ? 'done' : null);
const group = store && typeof f.group === 'string' ? f.group : null;
const project = store ? (group === null ? null : byId.get(group) ?? byKey.get(group) ?? null) : (typeof t.project_key === 'string' ? byKey.get(t.project_key) : null);
return {
org: id, ref: t.task_ref, tracker: 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,
org: id, ref: t.task_ref, tracker: store ? 'built-in' : t.source === undefined ? org.tracker : 'vikunja', title: f.title ?? null, done: Boolean(f.done), bucket: f.bucket ?? null,
state, assignees: store ? arr(f.assignees).filter(r => typeof r === 'string').sort() : arr(f.assignees).filter(u => bots.has(u)).map(u => bots.get(u)).sort(),
labels: arr(f.labels), priority: t.priority ?? null, dueDate: typeof f.due_date === 'string' ? f.due_date : null,
requirement: t.requirement ?? null, project: ref(project), group, changedAt: t.at ?? null,
// The digest a write sends back as `expect`; only a store task is written from the board.
digest: store && typeof t.digest === 'string' && DIGEST.test(t.digest) ? t.digest : null, writable: store,
};
});
const taskByRef = new Map(tasks.map(t => [t.ref, t]));
@@ -303,8 +336,20 @@ export async function readWorkspace({ organizations, session, query = parseWorks
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));
const matched = merged.tasks.filter(t => (!p || (t.org === p.org && (t.project?.id === p.key || t.project?.key === p.key)))
&& (!query.taskState || query.taskState.has(t.state ?? 'open'))
&& (!query.assignee || t.assignees.some(a => query.assignee.has(a)))
&& (!query.label || t.labels.some(l => query.label.has(l))));
// The cap, per org, after the filters: the most recently changed tasks stay.
const byOrg = Map.groupBy(matched, t => t.org), kept = [];
for (const r of read) {
const rows = byOrg.get(r.org.org) ?? [];
if (rows.length > TASKS_PER_ORG) {
r.org.tasksHeld = rows.length;
kept.push(...rows.sort((a, b) => (b.changedAt ?? '').localeCompare(a.changedAt ?? '') || b.ref.localeCompare(a.ref, undefined, { numeric: true })).slice(0, TASKS_PER_ORG));
} else kept.push(...rows);
}
body.tasks = kept.sort((a, b) => a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref, undefined, { numeric: true }));
}
if (query.sections.has('metrics')) {
const m = merged.metrics;
+133
View File
@@ -0,0 +1,133 @@
// U4b (#1873, PRD 1.20): the task board's writes, POST /api/workspace/tasks.
//
// One write goes to one organization's bus. The body names the org; the org is resolved for this
// session through the same admission the org pages use (organizations.mjs `resolve`), and the write
// goes out on that org's own transport (`taskWrite`, console-bus.mjs) and nowhere else. The verbs are
// the person's built-in store verbs (tasks.create, tasks.edit, tasks.archive): the bus runs its own
// human check on every one, so a Console started by an agent gets human-required and writes nothing.
//
// 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 and a strict JSON
// body. A refusal is answered with the bus's code and a fixed message; nothing is retried.
import { actor } from './action-trail.mjs';
import { TASK_STATES } from './workspace-read.mjs';
export const TASK_WRITE_ROUTE = '/api/workspace/tasks';
const MAX_BODY = 16384;
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const STORE_REF = /^task:[1-9][0-9]{0,14}$/;
const DIGEST = /^[0-9a-f]{64}$/;
const REQUIREMENT = /^REQ-[A-Z]+-[1-9][0-9]*$/;
const ROLE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const DUE = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/;
// Each op: its bus verb, the keys it takes and the ones it needs. The board edits title, state,
// assignees, priority and due date; a create also names the requirement and the project (group).
const OPS = {
create: { verb: 'tasks.create', keys: ['title', 'requirement', 'group', 'state', 'assignees', 'priority', 'due_date'], need: ['title', 'requirement'] },
// An edit or archive names the digest the board read, so a change made since is a task-conflict
// and never a silent overwrite. The bus checks it; the route refuses a write without one.
edit: { verb: 'tasks.edit', keys: ['task_ref', 'expect', 'title', 'state', 'assignees', 'priority', 'due_date'], need: ['task_ref', 'expect'] },
archive: { verb: 'tasks.archive', keys: ['task_ref', 'expect'], need: ['task_ref', 'expect'] },
};
const FIELD_CHECKS = {
task_ref: v => typeof v === 'string' && STORE_REF.test(v),
expect: v => typeof v === 'string' && DIGEST.test(v),
title: v => typeof v === 'string' && v.trim().length >= 1 && v.length <= 250 && !v.includes('\0'),
requirement: v => typeof v === 'string' && REQUIREMENT.test(v),
group: v => v === null || (typeof v === 'string' && v.length >= 1 && v.length <= 64 && !/[\u0000-\u001f\u007f]/.test(v)),
state: v => TASK_STATES.includes(v),
assignees: v => Array.isArray(v) && v.length <= 50 && v.every(r => typeof r === 'string' && ROLE_NAME.test(r)),
priority: v => Number.isSafeInteger(v) && v >= 0 && v <= 5,
due_date: v => v === null || (typeof v === 'string' && DUE.test(v) && Number.isFinite(Date.parse(v)) && new Date(v).toISOString() === v),
};
// The answers a person reads on the board, by bus code. Each says whether anything was written.
const REFUSALS = {
'human-required': [403, 'The bus takes task changes only from a person at the Console. Nothing was written.'],
'tracker-not-store': [409, 'This organization keeps its tasks in Vikunja. The board does not write to Vikunja; nothing was written.'],
'task-conflict': [409, 'The task changed after the board read it. Nothing was written; the board now shows the current task.'],
'task-archived': [409, 'The task is archived. Nothing was written.'],
'task-already-archived': [409, 'The task was already archived. Nothing was written.'],
'task-not-found': [404, 'The task is not on this organization\'s bus. Nothing was written.'],
'task-position-full': [409, 'This project has no room for another task position. Nothing was written.'],
'invalid-state': [400, 'The bus does not have that state. Nothing was written.'],
'unknown-role': [400, 'An assignee is not a role in this organization. Nothing was written.'],
'invalid-request': [400, 'The bus refused the change as invalid. Nothing was written.'],
'credential-leak': [400, 'The change holds part of a stored credential, so the bus refused it. Nothing was written.'],
'no-bus-host': [503, 'This organization\'s bus is not running. Nothing was written.'],
'outcome-unknown': [503, 'The bus did not answer. The change may or may not have been written; the board shows it on its next read.'],
};
const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large', 'outcome-unknown-revoked'];
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);
class BodyError extends Error { constructor(status, message) { super(message); this.status = status; } }
async function readBody(req) {
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(one(req, 'content-type') ?? '')) throw new BodyError(415, 'JSON body required');
let size = 0;
const chunks = [];
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY) throw new BodyError(413, 'body too large');
chunks.push(chunk);
}
let value;
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new BodyError(400, 'invalid JSON'); }
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BodyError(400, 'the body is an object');
return value;
}
// Strict: an unknown key, a missing required key or a malformed value is refused before any bus.
export function parseTaskWrite(body) {
const { op, org, ...rest } = body;
const spec = OPS[op];
if (!spec) throw new BodyError(400, `op takes ${Object.keys(OPS).join(', ')}`);
if (typeof org !== 'string' || !ORG_ID.test(org)) throw new BodyError(400, 'org takes an organization id');
for (const key of Object.keys(rest)) if (!spec.keys.includes(key)) throw new BodyError(400, `${op} does not take ${key}`);
for (const key of spec.need) if (!Object.hasOwn(rest, key)) throw new BodyError(400, `${op} needs ${key}`);
for (const [key, value] of Object.entries(rest)) if (!FIELD_CHECKS[key](value)) throw new BodyError(400, `${key} is not valid`);
if (op === 'edit' && !Object.keys(rest).some(k => !['task_ref', 'expect'].includes(k))) throw new BodyError(400, 'edit needs a field to change');
return { op, org, verb: spec.verb, args: rest };
}
// `organizations.resolve(id, session)` admits only an org this session belongs to and answers its
// options, `taskWrite` among them. Nothing here holds another org's transport.
export async function workspaceTasksHTTP(req, res, { session = null, owner = false, send = false, organizations = null, trail = null, log = () => {} }) {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if (!organizations || typeof organizations.resolve !== 'function') return json(res, 404, { error: 'org-unavailable' });
if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'the task board\'s changes are the owner\'s' });
if (!send) return json(res, 403, { error: 'read-only', message: 'start the Console with --send to make changes' });
let write;
try { write = parseTaskWrite(await readBody(req)); }
catch (e) { if (e instanceof BodyError) return json(res, e.status, { error: 'invalid-request', message: e.message, sent: false }); throw e; }
let selected;
try { selected = await organizations.resolve(write.org, session); }
catch (e) {
if (e?.code === 'org-archived') return json(res, 409, { error: 'org-archived', message: 'organization is archived', sent: false });
return json(res, 404, { error: 'org-unavailable', sent: false });
}
if (!selected) return json(res, 404, { error: 'org-unavailable', sent: false });
if (selected.busHealthy === false) return json(res, 503, { error: 'no-bus-host', message: REFUSALS['no-bus-host'][1], sent: false });
if (typeof selected.taskWrite !== 'function') return json(res, 403, { error: 'read-only', message: 'this organization takes no task changes from this Console', sent: false });
const note = outcome => {
try { trail?.({ by: actor(session), org: write.org, action: write.verb, ...(write.args.task_ref ? { task: write.args.task_ref } : {}), outcome }); }
catch { log(`${new Date().toISOString()} trail write failed for ${write.verb}`); }
};
let result;
try { result = await selected.taskWrite(write.verb, write.args); }
catch (e) {
const code = typeof e?.code === 'string' && /^[a-z-]{1,64}$/.test(e.code) ? e.code : 'outcome-unknown';
const open = OPEN.includes(code);
const [status, message] = REFUSALS[open ? 'outcome-unknown' : code] ?? [502, `The bus refused the change (${code}). Nothing was written.`];
log(`${new Date().toISOString()} task board: ${write.verb} on ${write.org} refused ${code}`);
note(open ? 'unknown' : `refused ${code}`);
return json(res, status, { error: open ? 'outcome-unknown' : code, message, sent: open ? 'unknown' : false });
}
note('written');
const task = result && typeof result === 'object' && typeof result.task_ref === 'string' ? result.task_ref : write.args.task_ref ?? null;
return json(res, 200, { ok: true, org: write.org, op: write.op, task_ref: task, digest: typeof result?.digest === 'string' ? result.digest : null });
}
@@ -0,0 +1,93 @@
// #1873 Phase A slice A1: GET /api/agents/levels, the read behind the Agents grid's level and source
// columns. Owner only, GET only; a bad root file, organization or project hides only itself, and
// no body carries a host path.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { mkdtempSync, writeFileSync } 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 { agentLevelsHTTP } from '../src/agent-levels-read.mjs';
import { checkRootAgents, loadRootAgents } from '../../organization/src/index.mjs';
import { close } from './fixture.mjs';
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
const CTX = { configDir: '/home/owner/.config/mosaic-test', dataRoot: '/home/owner/.mosaic-test', instance: 'test' };
const DOCS = {
acme: { roles: { chief: { definition: 'chief-of-staff' }, writer: { definition: 'copywriter', vars: { model: 'sonnet-5.5' } } },
projects: { web: { root: '/srv/secret/web', agents: { checker: { definition: 'fact-checker' }, writer: { vars: { 'limits.network': 'none' } } } },
broken: { root: '/srv/secret/broken', agents: { ghost: { exclude: true } } } } },
bad: null,
};
const members = async () => [{ id: 'acme', doc: DOCS.acme }, { id: 'bad', invalid: true, reason: 'organization file is not valid JSON' }];
const root = () => ({ state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { assistant: { definition: 'assistant' }, reviewer: { definition: 'reviewer', excludeOrgs: ['bad'] } } }, { rolesDir }) });
function call(options, method = 'GET') {
const res = { status: 0, body: null, writeHead(status) { this.status = status; }, end(text) { this.body = JSON.parse(text); } };
return agentLevelsHTTP({ method }, res, options).then(() => res);
}
test('the route refuses a write, a missing read, a non-owner and an unreadable registry', async () => {
const levels = { loadRoot: root, rolesDir, ctx: CTX };
assert.equal((await call({ owner: true, session: {}, organizations: { members }, levels }, 'POST')).status, 405);
assert.equal((await call({ owner: true, session: {}, organizations: null, levels })).status, 404);
assert.equal((await call({ owner: true, session: {}, organizations: { members }, levels: null })).status, 404);
const denied = await call({ owner: false, session: {}, organizations: { members }, levels });
assert.equal(denied.status, 403); assert.equal(denied.body.error, 'owner-only');
const down = await call({ owner: true, session: {}, organizations: { members: async () => { throw new Error('registry down'); } }, levels, log: () => {} });
assert.equal(down.status, 503); assert.doesNotMatch(JSON.stringify(down.body), /registry down/);
});
test('the owner reads every level; a bad project or organization hides only itself; no host path', async t => {
const levels = { loadRoot: root, rolesDir, ctx: CTX };
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: { list: async () => [], resolve: async () => null, members }, agentLevels: levels, log: () => {} });
t.after(() => close(server));
const response = await fetch(`http://127.0.0.1:${server.address().port}/api/agents/levels`);
assert.equal(response.status, 200);
const text = await response.text(), body = JSON.parse(text);
assert.equal(body.root.state, 'ok'); assert.equal(body.root.assistant, 'assistant');
assert.deepEqual(body.root.agents.map(a => [a.name, a.excludeOrgs]), [['assistant', []], ['reviewer', ['bad']]]);
const acme = body.orgs.find(o => o.id === 'acme');
assert.equal(acme.state, 'ok');
assert.deepEqual(acme.base.agents.map(a => `${a.level}:${a.name}`), ['root:reviewer', 'org:chief', 'org:writer']);
assert.deepEqual(acme.projects.web.agents.map(a => `${a.level}:${a.name}`), ['root:reviewer', 'org:chief', 'org:writer', 'project:checker']);
assert.deepEqual(acme.projects.web.agents.find(a => a.name === 'writer'), { name: 'writer', definition: 'copywriter', level: 'org', model: 'sonnet-5.5', narrowed: ['limits.network'], overrides: [], scope: { kind: 'org', org: 'acme' } });
assert.equal(acme.projects.broken.state, 'invalid'); assert.match(acme.projects.broken.reason, /names no root or organization agent to exclude/);
assert.deepEqual(acme.defined.web.map(e => e.form).sort(), ['define', 'narrow']);
assert.deepEqual(body.orgs.find(o => o.id === 'bad'), { id: 'bad', state: 'invalid', reason: 'organization file is not valid JSON' });
// Only the model and the names of narrowed keys travel; never a root path or another var's value.
assert.doesNotMatch(text, /\/srv\/secret|"none"|"vars"/);
// Signed out: the hook fence refuses before the route runs.
const anon = await startServer({ port: 0, sso: { ...createDevOwner(), session: () => null }, organizations: { list: async () => [], resolve: async () => null, members }, agentLevels: levels, log: () => {} });
t.after(() => close(anon));
assert.notEqual((await fetch(`http://127.0.0.1:${anon.address().port}/api/agents/levels`, { redirect: 'manual' })).status, 200);
});
test('a refused root file hides the root level only, and says why without a host path', async () => {
const levels = { loadRoot: () => loadRootAgents('/nonexistent-dir-for-test', { rolesDir }), rolesDir, ctx: CTX };
// A missing file is no root agents, not a refusal.
const absent = await call({ owner: true, session: {}, organizations: { members }, levels });
assert.deepEqual(absent.body.root, { state: 'absent', agents: [], assistant: null });
const refusing = { ...levels, loadRoot: () => { const e = new Error(`root agent file must belong to uid 1000 (${CTX.configDir}/agents.json)`); e.name = 'BusinessError'; throw e; } };
const res = await call({ owner: true, session: {}, organizations: { members }, levels: refusing });
assert.equal(res.status, 200); assert.equal(res.body.root.state, 'invalid');
assert.match(res.body.root.reason, /must belong to uid 1000/); assert.doesNotMatch(res.body.root.reason, /\/home\/owner/);
assert.equal(res.body.orgs.find(o => o.id === 'acme').state, 'ok');
const crash = await call({ owner: true, session: {}, organizations: { members }, levels: { ...levels, loadRoot: () => { throw new Error('raw parser bytes {"x"'); } } });
assert.equal(crash.body.root.reason, 'could not be read');
// A malformed file read through the real loader: the reason names the file, never the parser's quote of its bytes.
const dir = mkdtempSync(join(tmpdir(), 'a'));
writeFileSync(join(dir, 'agents.json'), '{"agentsVersion": 1, "agents": {"x": secret-marker-7731}}', { mode: 0o600 });
const bad = await call({ owner: true, session: {}, organizations: { members }, levels: { ...levels, loadRoot: () => loadRootAgents(dir, { rolesDir }) } });
assert.equal(bad.body.root.state, 'invalid');
assert.equal(bad.body.root.reason, 'root agent file is not valid JSON (agents.json)');
assert.doesNotMatch(JSON.stringify(bad.body), /secret-marker|Unexpected token|position/);
// A var value the root file sets wrongly never reaches the body; the key does.
writeFileSync(join(dir, 'agents.json'), JSON.stringify({ agentsVersion: 1, agents: { x: { definition: 'reviewer', vars: { thinking: 'sk-secret-marker-7731' } } } }));
const badVar = await call({ owner: true, session: {}, organizations: { members }, levels: { ...levels, loadRoot: () => loadRootAgents(dir, { rolesDir }) } });
assert.equal(badVar.body.root.state, 'invalid');
assert.match(badVar.body.root.reason, /variable thinking must be one of/);
assert.doesNotMatch(JSON.stringify(badVar.body), /secret-marker/);
});
+2 -2
View File
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.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']);
// 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. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.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', '/panes.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);
@@ -131,7 +131,8 @@ test('owner can start a down bus after setup recorded done and sees recovery gui
const f = await fixture(t);
await f.b.evaluate(`(()=>{const old=window.fetch;window.fetch=async (input,init={})=>{const u=String(input);if(u.endsWith('/setup') && (!init.method || init.method==='GET'))return new Response(JSON.stringify({notifications:{state:'done'},queue:{state:'done'},projects:[],bus:{state:'done',verb:'mosaic bus start alpha'}}),{status:200});if(u.endsWith('/setup/bus') && init.method==='POST')return new Response(JSON.stringify({error:'stale-lock',message:'stale bus host lock'}),{status:409});return old(input,init)}})()`);
await f.open('alpha');
assert.equal(await f.b.evaluate(`!!document.querySelector('[data-step="bus"] [data-run="bus"]:not(:disabled)')`), true);
// The setup steps render after the editor title, once the setup and organization list reads answer.
await f.wait(`!!document.querySelector('[data-step="bus"] [data-run="bus"]:not(:disabled)')`);
await f.b.evaluate(`document.querySelector('[data-step="bus"] [data-run="bus"]').click()`);
await f.wait(`document.querySelector('[data-step="bus"]').innerText.includes('stale bus host lock')`);
assert.match(await f.b.evaluate(`document.querySelector('[data-step="bus"]').innerText`), /mosaic bus recover alpha/);
@@ -31,7 +31,7 @@ test('an allowed LAN name loads the org page and creates a project', { timeout:
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const input = (selector, value) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
const click = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`);
await b.navigate(`http://lan.test:${port}/#/orgs/alpha/projects`);
await b.navigate(`http://lan.test:${port}/#/orgs/alpha/chat`);
assert.deepEqual(await b.evaluate('({ host: location.host, secure: isSecureContext, uuid: typeof crypto.randomUUID })'), { host: `lan.test:${port}`, secure: false, uuid: 'undefined' });
await wait('window.MosaicOrg?.id === "alpha" && !!document.querySelector("#chat-repo option[value=work]")');
await click('.project-create summary');
@@ -31,7 +31,7 @@ const bannerText = "document.getElementById('org-list-banner')?.textContent || '
test('a registry error shows on a selected org page and Retry recovers', async t => {
const f = await setup(t), file = join(f.ctx.configDir, 'org-runtime.json');
const original = await fs.readFile(file, 'utf8'); await fs.writeFile(file, '{');
await f.b.navigate(`${f.base}/#/orgs/alpha/`);
await f.b.navigate(`${f.base}/#/orgs/alpha/board`);
await wait(f.b, `(${bannerText}).includes('Organization registry could not be read') && document.querySelector('#held [data-retry]')`);
const before = f.requests(); await fs.writeFile(file, original);
await f.b.evaluate("document.querySelector('#held [data-retry]').click()");
@@ -44,7 +44,7 @@ test('a broken org banner is replaced, not stacked, and clears after repair', as
const f = await setup(t), file = join(f.ctx.configDir, 'org-runtime.json');
const original = await fs.readFile(file, 'utf8'), reg = JSON.parse(original);
reg.orgs.beta.digest = '0'.repeat(64); await fs.writeFile(file, JSON.stringify(reg));
await f.b.navigate(`${f.base}/#/orgs/alpha/`);
await f.b.navigate(`${f.base}/#/orgs/alpha/board`);
await wait(f.b, `(${bannerText}).includes('beta: invalid') && document.querySelector('#held [data-retry]')`);
await f.b.evaluate('window.MosaicRefreshOrganizations()');
await wait(f.b, `(${bannerText}).includes('beta: invalid')`);
+1 -1
View File
@@ -49,7 +49,7 @@ try {
await wait('document.querySelector("#org-setup") && !document.querySelector("#org-setup").hidden');
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/alpha\/settings\/organizations\?org=alpha&step=setup$/);
await b.screenshot(join(shots, '03-resume.png'));
await b.evaluate(`location.hash='#/orgs/alpha/projects'`);
await b.evaluate(`location.hash='#/orgs/alpha/chat'`);
await wait('!!document.querySelector("#chat-repo option[value=work]")');
await click('.project-create summary');
await input('#chat-project-name', 'Work'); await input('#chat-repo', 'work');
@@ -118,7 +118,7 @@ test('an organization offers its request status, and a conversation whose messag
test('an organization project overview reports no active request instead of unavailable', { timeout: 60000 }, async t => {
const { base, diagnostics } = await consoleFor(t);
const { b, wait } = await page(t, base, diagnostics);
await b.navigate(`${base}#/orgs/alpha/projects`);
await b.navigate(`${base}#/orgs/alpha/chat`);
await wait(`document.querySelector('[data-project-status="${P}"] .project-card-status-line')`);
assert.match(await b.evaluate(`document.querySelector('[data-project-status="${P}"]').textContent`), /^No active requests · 1 conversation/);
// The overview renders after its reads settle, so the metrics are final once they exist.
@@ -123,7 +123,7 @@ test('project creation offers repositories from the scoped business read', async
};
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/projects`);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/chat`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#chat-repo option[value=work]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('repository choice missing')})()`);
assert.equal(await b.evaluate(`document.querySelector('#chat-repo option[value=work]').value`), 'work');
});
@@ -269,7 +269,7 @@ test('a stopped organization bus has truthful chat guidance', async t => {
};
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, send: async () => ({}), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/projects`);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/chat`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.chat-area')?.innerText.includes('The bus is stopped'))return;await new Promise(r=>setTimeout(r,30))}throw Error('stopped-bus guidance missing')})()`);
const text = await b.evaluate(`document.querySelector('.chat-area').innerText`);
assert.match(text, /bus is stopped.*Start the bus/i);
@@ -0,0 +1,340 @@
// U5 (#1873, sample 07): Side by side shows two panes, each holding one page of any organization.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
// Three organizations: two running buses and one stopped (home). `state.flip` changes what the
// running organizations report, so a refresh repaints both panes.
function orgs(state = { flip: false, seen: [] }) {
const ORGS = [
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
];
const PROJECTS = { ddigital: [{ id: 'spring-launch', name: 'Spring launch' }, { id: 'brand-refresh', name: 'Brand refresh' }], forge: [{ id: 'api-gateway', name: 'API gateway' }], home: [] };
const INSTANCES = { ddigital: ['chief', 'copywriter', 'producer', 'editor', 'researcher', 'designer', 'analyst', 'scheduler'], forge: ['chief', 'builder', 'tester', 'reviewer', 'operator', 'scribe'] };
const THREADS = { ddigital: [{ id: 't0', name: 'Kickoff notes', project: 'spring-launch', instance: 'chief' }, { id: 't1', name: 'Launch email', project: 'spring-launch', instance: 'copywriter' }], forge: [{ id: 'f0', name: 'Gateway auth', project: 'api-gateway', instance: 'chief' }] };
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
instances: Object.fromEntries(INSTANCES[id].map((n, i) => [n, { definition: n, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [`r${i}`], alive: [] }])) });
const agents = id => INSTANCES[id].map((role, i) => ({ role, holder_run: `r${i}`, state: (i % 2 === 0) === state.flip ? 'working' : 'waiting' }));
const business = id => ({ business: { id, human: 'jason', arbiters: ['chief'], projects: PROJECTS[id].map(p => p.id), vars: {}, instances: INSTANCES[id].map(n => ({ instance: n, vars: {} })), appearance: { theme: ORGS.find(o => o.id === id).theme } } });
return {
list: async () => ORGS,
resolve: async id => {
state.seen.push(id);
const org = ORGS.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
return {
busHealthy: true,
reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', launch: { limits: { total: 4 } } }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => agents(id), 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] },
chat: { projects: async () => PROJECTS[id], threads: async () => THREADS[id] },
projectFiles: async () => ({ head: 'a'.repeat(40), files: [] }),
};
},
};
}
async function open(t, { width = 1440, height = 900, hash = '#/orgs/ddigital/side-by-side', state } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs(state), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(width, height);
const base = `http://127.0.0.1:${server.address().port}/`;
await b.navigate(base + hash);
return { b, base, wait: waitFor(b) };
}
const text = key => `(document.querySelector("#pane-${key} [data-pane-body]")?.innerText || "")`;
const title = key => `(document.querySelector("#pane-${key} [data-pane-ttl]")?.textContent || "")`;
const query = '(() => Object.fromEntries(new URLSearchParams(location.hash.split("?")[1] || "")))()';
const pick = (b, key, value) => b.evaluate(`(() => { const s = document.querySelector("#pane-${key}-pick"); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error("no option " + ${JSON.stringify(value)}); s.dispatchEvent(new Event("change", { bubbles: true })); })()`);
test('the Side by side nav item opens two panes with different organizations, and the URL restores both after a reload', async t => {
const state = { flip: false, seen: [] };
const { b, base, wait } = await open(t, { hash: '#/orgs/ddigital/organization', state });
await wait('document.querySelector("[data-org-dashboard]")');
await b.evaluate('[...document.querySelectorAll("#sections > li > a")].find(a => a.textContent.trim() === "Side by side").click()');
await wait(`document.querySelectorAll(".panes > .pane:not([hidden])").length === 2 && ${title('a')}.includes("Distinctly Digital") && ${title('b')}.includes("Forge")`);
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /Side by side is not built yet/i);
assert.equal(await b.evaluate('document.querySelector("#bus-view h1").textContent'), 'Side by side');
assert.equal(await b.evaluate('document.title'), 'Side by side · Mosaic Console');
assert.deepEqual(await b.evaluate(query), { a: 'ddigital/organization', b: 'forge/organization', w: '50', show: 'a' });
await wait(`${text('a')}.includes("Distinctly Digital") && ${text('b')}.includes("Forge")`);
// Each pane's links name its own organization.
assert.equal(await b.evaluate('[...document.querySelectorAll("#pane-b [data-pane-body] a[href^=\\"#/\\"]")].every(a => a.getAttribute("href").startsWith("#/orgs/forge/"))'), true);
assert.equal(await b.evaluate('document.querySelectorAll("#pane-a [data-pane-body] h1[aria-level=\\"2\\"]").length > 0'), true);
// The picker lists every organization and its projects.
const groups = await b.evaluate('[...document.querySelectorAll("#pane-b-pick optgroup")].map(g => g.label)');
assert.deepEqual(groups, ['Distinctly Digital', 'Forge', 'Home · bus unavailable']);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#pane-b-pick option")].map(o => o.value).filter(Boolean)'),
['ddigital/organization', 'ddigital/agents', 'ddigital/projects/spring-launch', 'ddigital/projects/brand-refresh', 'forge/organization', 'forge/agents', 'forge/projects/api-gateway', 'home/organization', 'home/agents']);
// Pick a project in the right pane; the left keeps its page.
await pick(b, 'b', 'forge/projects/api-gateway');
await wait(`${title('b')}.startsWith("API gateway") && ${text('b')}.includes("API gateway") && location.hash.includes("b=forge/projects/api-gateway")`);
assert.match(await b.evaluate(title('a')), /^Dashboard/);
// A project tab opens in the pane, not the full view.
await b.evaluate('[...document.querySelectorAll("#pane-b nav.project-tabs a")].find(a => /Tasks/.test(a.textContent)).click()');
await wait(`location.hash.includes("b=forge/projects/api-gateway/tasks") && ${title('b')}.includes("Tasks")`);
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/ddigital\/side-by-side\?/);
// Reload: both panes come back from the URL.
await b.call('Page.reload');
await wait('document.readyState === "complete"');
await wait(`${title('a')}.startsWith("Dashboard") && ${title('a')}.includes("Distinctly Digital") && ${title('b')}.includes("API gateway") && ${title('b')}.includes("Tasks")`);
assert.deepEqual(await b.evaluate(query), { a: 'ddigital/organization', b: 'forge/projects/api-gateway/tasks', w: '50', show: 'a' });
// A shared link opens exactly what it names, whatever this browser remembered.
await b.navigate(`${base}#/orgs/forge/side-by-side?a=forge/agents&b=ddigital/projects/spring-launch&w=40&show=b`);
await wait(`${title('a')}.startsWith("Agents") && ${title('a')}.includes("Forge") && ${title('b')}.startsWith("Spring launch")`);
assert.equal(await b.evaluate('document.querySelector("[data-sbs-divider]").getAttribute("aria-valuenow")'), '40');
// A link with a route a pane does not draw falls back to the remembered layout or the default.
await b.navigate(`${base}#/orgs/ddigital/side-by-side?a=ddigital/settings&b=nope!/organization`);
await wait(`document.querySelectorAll(".panes > .pane:not([hidden])").length === 2 && ${title('a')}.length > 0 && !location.hash.includes("settings")`);
// Leaving the page stops both panes reading.
await b.evaluate('location.hash = "#/orgs/ddigital/agents"');
await wait('!document.querySelector("[data-side-by-side]") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Agents")');
assert.ok(state.seen.includes('forge') && state.seen.includes('ddigital'));
});
test('the divider resizes the panes by mouse and by keyboard', async t => {
const { b, wait } = await open(t);
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`);
const sep = '[role=separator][data-sbs-divider]';
assert.deepEqual(await b.evaluate(`(() => { const d = document.querySelector("${sep}"); return [d.getAttribute("aria-valuenow"), d.getAttribute("aria-valuemin"), d.getAttribute("aria-valuemax"), d.getAttribute("aria-orientation"), d.tabIndex]; })()`), ['50', '25', '75', 'vertical', 0]);
const ratio = `(() => { const a = document.getElementById("pane-a").getBoundingClientRect().width, b = document.getElementById("pane-b").getBoundingClientRect().width; return Math.round(a / (a + b) * 100); })()`;
// Mouse: drag the divider to 30% of the panes' width.
const box = await b.evaluate(`(() => { const p = document.querySelector("[data-sbs-panes]").getBoundingClientRect(), d = document.querySelector("${sep}").getBoundingClientRect(); return { left: p.left, width: p.width, x: d.left + d.width / 2, y: d.top + 200 }; })()`);
const target = box.left + box.width * 0.3;
await b.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y });
await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', x: box.x, y: box.y, button: 'left', buttons: 1, clickCount: 1 });
for (const step of [0.25, 0.5, 0.75, 1]) await b.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x + (target - box.x) * step, y: box.y, button: 'left', buttons: 1 });
await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: target, y: box.y, button: 'left', buttons: 0, clickCount: 1 });
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "30" && location.hash.includes("w=30")`);
assert.ok(Math.abs(await b.evaluate(ratio) - 30) <= 1);
// Keyboard: the separator has focus after the drag; arrows step by 5, Home and End reach the limits.
assert.equal(await b.evaluate('document.activeElement.getAttribute("role")'), 'separator');
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "35"`);
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "25"`);
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
assert.equal(await b.evaluate(`document.querySelector("${sep}").getAttribute("aria-valuenow")`), '25');
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'End', code: 'End', windowsVirtualKeyCode: 35 });
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'End', code: 'End', windowsVirtualKeyCode: 35 });
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "75" && location.hash.includes("w=75")`);
assert.ok(Math.abs(await b.evaluate(ratio) - 75) <= 1);
// Closing a pane goes back to one; the divider leaves with it. Add pane brings a second back.
await b.evaluate('document.querySelector("#pane-a [data-pane-close]").click()');
await wait(`document.getElementById("pane-b").hidden && document.querySelector("${sep}").hidden && !new URLSearchParams(location.hash.split("?")[1]).has("b") && ${title('a')}.includes("Forge")`);
assert.equal(await b.evaluate('document.activeElement.id'), 'pane-a-pick');
assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-close]").hidden'), true);
assert.match(await b.evaluate('document.querySelector("[data-sbs-note]").textContent'), /^1 pane ·/);
await b.evaluate('document.querySelector("[data-pane-add]").click()');
await wait(`!document.getElementById("pane-b").hidden && ${title('b')}.length > 0 && new URLSearchParams(location.hash.split("?")[1]).has("b")`);
assert.equal(await b.evaluate('document.querySelector("[data-pane-add]").disabled'), true);
});
test('each pane keeps its own focus and scroll through a refresh', async t => {
const state = { flip: false, seen: [] };
const { b, wait } = await open(t, { height: 520, state, hash: '#/orgs/ddigital/side-by-side?a=ddigital/agents&b=forge/agents' });
await wait(`${text('a')}.includes("copywriter") && ${text('b')}.includes("builder")`);
const scrollable = await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollHeight - el.clientHeight)');
assert.ok(scrollable.every(n => n > 60), `both panes scroll: ${scrollable}`);
const focused = await b.evaluate('(() => { const a = document.querySelector("#pane-a [data-pane-body] a[href]"); a.focus(); return a.getAttribute("href"); })()');
await b.evaluate('document.querySelector("#pane-a [data-pane-body]").scrollTop = 40; document.querySelector("#pane-b [data-pane-body]").scrollTop = 60;');
const before = await b.evaluate(`[${text('a')}, ${text('b')}]`);
state.flip = true;
await b.evaluate('document.getElementById("refresh").click()');
await wait(`${text('a')} !== ${JSON.stringify(before[0])} && ${text('b')} !== ${JSON.stringify(before[1])}`);
assert.equal(await b.evaluate('document.querySelector("#pane-a").contains(document.activeElement) && document.activeElement.getAttribute("href")'), focused);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollTop)'), [40, 60]);
// Focus in the right pane stays there while the left repaints.
const right = await b.evaluate('(() => { const a = document.querySelector("#pane-b [data-pane-body] a[href]"); a.focus({ preventScroll: true }); return a.getAttribute("href"); })()');
state.flip = false;
await b.evaluate('document.getElementById("refresh").click()');
await wait(`${text('a')} === ${JSON.stringify(before[0])}`);
assert.equal(await b.evaluate('document.querySelector("#pane-b").contains(document.activeElement) && document.activeElement.getAttribute("href")'), right);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollTop)'), [40, 60]);
});
test('a pane whose organization is down shows its state and the other pane still works', async t => {
const { b, wait } = await open(t, { hash: '#/orgs/ddigital/side-by-side?a=home/organization&b=forge/organization' });
await wait(`${title('a')}.includes("Home") && ${title('b')}.includes("Forge") && ${text('b')}.includes("Forge")`);
await wait('/Home: bus unavailable/.test(document.querySelector("#pane-a [data-pane-line]").textContent)');
assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-line]").getAttribute("role")'), 'status');
assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-state]").innerText.trim().length > 0'), true);
assert.equal(await b.evaluate('document.querySelector("#pane-b [data-pane-line]").hidden'), true);
await pick(b, 'b', 'forge/agents');
await wait(`${title('b')}.startsWith("Agents") && ${text('b')}.includes("builder")`);
await pick(b, 'b', 'forge/projects/api-gateway');
await wait(`${title('b')}.startsWith("API gateway") && ${text('b')}.includes("API gateway")`);
assert.match(await b.evaluate(title('a')), /Home/);
// An organization that is not on the list is one honest line; nothing is read for it.
await b.evaluate('location.hash = "#/orgs/ddigital/side-by-side?a=ghost/organization&b=forge/agents"');
await wait(`/ghost is not on your organization list/.test(document.querySelector("#pane-a [data-pane-line]").textContent) && ${text('b')}.includes("builder")`);
});
test('on a phone one pane shows at a time, switched from the bar, with no horizontal scroll', async t => {
const { b, wait } = await open(t, { width: 390, height: 844 });
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0 && ${text('a')}.length > 0`);
const visible = '[...document.querySelectorAll(".panes > .pane")].filter(p => p.getBoundingClientRect().height > 0).map(p => p.id)';
assert.deepEqual(await b.evaluate(visible), ['pane-a']);
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("[data-sbs-tabs]")).display'), 'none');
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("[data-sbs-divider]")).display'), 'none');
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("[data-pane-add]")).display'), 'none');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-sbs-tabs] button")].map(x => x.getAttribute("aria-pressed"))'), ['true', 'false']);
assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll');
await b.evaluate('document.querySelector("[data-sbs-tabs] [data-pane-show=b]").click()');
await wait(`JSON.stringify(${visible}) === '["pane-b"]' && location.hash.includes("show=b")`);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-sbs-tabs] button")].map(x => x.getAttribute("aria-pressed"))'), ['false', 'true']);
assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll on the second pane');
await pick(b, 'b', 'ddigital/projects/spring-launch');
await wait(`${text('b')}.includes("Spring launch")`);
assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll on a project page');
});
// Review fixes (#1873 U5 review, findings 1-4). `hold(url)` makes the page's next read of that URL
// wait until `release()`; the test then changes the pane while the read is pending.
const hold = url => `(() => { const real = window.fetch; window.held = { url: ${JSON.stringify(url)}, pending: false };
window.fetch = async (u, o) => { if (window.held?.url === u && !window.held.done) { window.held.pending = true; await new Promise(r => { window.held.release = () => { window.held.done = true; r(); }; }); } return real(u, o); }; })()`;
const settled = b => b.evaluate('new Promise(r => setTimeout(r, 400))');
test('a pane closed and opened again rewrites new links for its current organization only', async t => {
const { b, wait } = await open(t);
await wait(`${title('b')}.includes("Forge") && document.querySelector("#pane-b-pick")?.options.length > 4`);
await b.evaluate('document.querySelector("#pane-b [data-pane-close]").click()');
await wait('document.querySelector("#pane-b").hidden && !document.querySelector("[data-pane-add]").disabled');
await b.evaluate('document.querySelector("[data-pane-add]").click()');
await wait(`!document.querySelector("#pane-b").hidden && ${title('b')}.length > 0`);
await pick(b, 'b', 'ddigital/organization');
await wait(`${title('b')}.includes("Distinctly Digital") && location.hash.includes("b=ddigital/organization")`);
// Content a page appends after its own reads (projects.js does this) gets the pane's organization.
await b.evaluate(`document.querySelector("#pane-b [data-pane-body]").insertAdjacentHTML("beforeend", '<a data-late href="#/docs/example">late</a><a data-late-api href="/api/files/x">file</a>')`);
await wait('document.querySelector("#pane-b [data-late]").getAttribute("href").startsWith("#/orgs/")');
assert.equal(await b.evaluate('document.querySelector("#pane-b [data-late]").getAttribute("href")'), '#/orgs/ddigital/docs/example');
assert.equal(await b.evaluate('document.querySelector("#pane-b [data-late-api]").getAttribute("href")'), '/api/orgs/ddigital/files/x');
// Leaving the page stops every pane observer: later content in the old element is left alone.
await b.evaluate('location.hash = "#/orgs/ddigital/agents"');
await wait('!document.querySelector("[data-side-by-side]")');
});
test('a slow project read never paints over the page or organization chosen since', async t => {
const { b, wait } = await open(t);
await wait(`${title('b')}.includes("Forge") && document.querySelector("#pane-b-pick")?.options.length > 4`);
// Page change while the project's thread read is pending.
await b.evaluate(hold('/api/orgs/forge/chat/threads'));
await pick(b, 'b', 'forge/projects/api-gateway');
await wait('window.held.pending');
await pick(b, 'b', 'forge/agents');
await wait(`${title('b')}.startsWith("Agents") && ${text('b')}.includes("builder")`);
await b.evaluate('window.held.release()');
await settled(b);
assert.equal(await b.evaluate(`${text('b')}.includes("API gateway")`), false, 'the old project did not replace Agents');
assert.match(await b.evaluate(title('b')), /^Agents/);
// Organization change while the read is pending: the old organization's project is not painted.
await b.evaluate(hold('/api/orgs/forge/chat/threads'));
await pick(b, 'b', 'forge/projects/api-gateway');
await wait('window.held.pending');
await pick(b, 'b', 'ddigital/projects/spring-launch');
await wait(`${title('b')}.startsWith("Spring launch") && ${text('b')}.includes("Spring launch")`);
await b.evaluate('window.held.release()');
await settled(b);
assert.equal(await b.evaluate(`${text('b')}.includes("API gateway")`), false, 'the old organization did not replace Spring launch');
assert.equal(await b.evaluate('[...document.querySelectorAll("#pane-b [data-pane-body] a[href^=\\"#/orgs/\\"]")].every(a => a.getAttribute("href").startsWith("#/orgs/ddigital/"))'), true);
});
test('the same project in both panes keeps a request choice per pane', async t => {
const { b, wait } = await open(t);
await wait(`${title('b')}.includes("Forge")`);
const { stub, P } = await import('./helpers/projects-review-fixture.mjs');
// The fixture answers the project reads of both organizations; the organization list stays real.
await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`));
await b.evaluate('window.MosaicPanes.refresh()');
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=forge/projects/${P}/requirements'`);
const values = `[...document.querySelectorAll('[data-pane-body] #project-request-select')].map(e => e.value)`;
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2`);
assert.deepEqual(await b.evaluate(values), ['request-1', 'request-1']);
await b.evaluate(`(() => { const e = document.querySelector('#pane-a #project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
await wait(`${values}[0] === 'request-2'`);
await b.evaluate('window.MosaicPanes.refresh()');
await settled(b);
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2`);
assert.deepEqual(await b.evaluate(values), ['request-2', 'request-1']);
});
test('a pane keeps its request choice and PRD disclosure when a new conversation repaints the project', async t => {
const { b, wait } = await open(t);
await wait(`${title('b')}.includes("Forge")`);
const { stub, P } = await import('./helpers/projects-review-fixture.mjs');
await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`));
await b.evaluate('window.MosaicPanes.refresh()');
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
// The same organization and project in both panes.
await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=ddigital/projects/${P}/requirements'`);
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`);
const state = `({ selected: [...document.querySelectorAll('[data-pane-body] #project-request-select')].map(e => e.value), expanded: [...document.querySelectorAll('[data-pane-body] [data-prd]')].map(e => e.classList.contains('expanded')) })`;
await b.evaluate(`(() => { const e = document.querySelector('#pane-a #project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
await wait(`document.querySelector('#pane-a #project-request-select').value === 'request-2'`);
await b.evaluate(`document.querySelector('#pane-a .project-prd-toggle').click()`);
await wait(`document.querySelector('#pane-a [data-prd]')?.classList.contains('expanded')`);
// A new conversation changes the header's count, so each pane replaces its project content.
const before = await b.evaluate(`document.querySelector('#pane-a .project-header p')?.textContent || ''`);
await b.evaluate(`window.fixtureThreads.push({ ...window.fixtureThreads[0], id: '20000000-0000-4000-8000-000000000004', name: 'A new conversation' }); window.MosaicPanes.refresh()`);
await wait(`(() => { const t = document.querySelector('#pane-a .project-header p')?.textContent || ''; return t !== ${JSON.stringify(before)} && /4 conversations/.test(t) && /4 conversations/.test(document.querySelector('#pane-b .project-header p')?.textContent || ''); })()`);
await settled(b);
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2 && document.querySelectorAll('[data-pane-body] [data-prd]').length === 2`);
assert.deepEqual(await b.evaluate(state), { selected: ['request-2', 'request-1'], expanded: [true, false] });
});
test('a pane navigation that finishes late does not take focus from the other pane', async t => {
const { b, wait } = await open(t);
await wait(`document.querySelector('#pane-a [data-focus-key="agents-all"]') && document.querySelector('#pane-b-pick')?.options.length > 4`);
await b.evaluate(hold('/api/orgs/ddigital/bus/instances'));
await b.evaluate(`document.querySelector('#pane-a [data-focus-key="agents-all"]').click()`);
await wait('window.held.pending');
await b.evaluate(`document.querySelector('#pane-b-pick').focus(); window.held.release()`);
await wait(`${title('a')}.startsWith('Agents')`);
await settled(b);
assert.equal(await b.evaluate('document.activeElement.id'), 'pane-b-pick');
// Without a move elsewhere, the same navigation still lands on the new page's heading.
await pick(b, 'a', 'ddigital/organization');
await wait(`document.querySelector('#pane-a [data-focus-key="agents-all"]')`);
await b.evaluate(`document.querySelector('#pane-a [data-focus-key="agents-all"]').click()`);
await wait(`${title('a')}.startsWith('Agents') && document.activeElement?.tagName === 'H1' && document.querySelector('#pane-a').contains(document.activeElement)`);
});
// A1's "Available in a project" chooser, with an Agents pane per organization: each pane's choice
// redraws its own table and survives a refresh, keyed by that pane's organization.
test('each Agents pane keeps its own project choice for its organization', async t => {
const { b, wait } = await open(t, { hash: '#/orgs/ddigital/side-by-side?a=ddigital/agents&b=forge/agents' });
const a = (name, level = 'org', definition = name) => ({ name, level, definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] });
const set = agents => ({ state: 'ok', agents, excluded: [] });
const levels = { root: { state: 'ok', assistant: null, agents: [] }, orgs: [
{ id: 'ddigital', state: 'ok', base: set([a('chief')]), defined: { 'spring-launch': [{ name: 'social-scheduler', form: 'define' }] },
projects: { 'spring-launch': set([a('chief'), a('social-scheduler', 'project', 'coder')]), 'brand-refresh': set([a('chief')]) } },
{ id: 'forge', state: 'ok', base: set([a('chief')]), defined: { 'api-gateway': [{ name: 'load-tester', form: 'define' }] },
projects: { 'api-gateway': set([a('chief'), a('load-tester', 'project', 'coder')]), ops: set([a('chief')]) } },
] };
await b.evaluate(`(() => { const real = window.fetch; window.fetch = (url, init) => String(url).endsWith('/api/agents/levels') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(levels))}, { headers: { 'content-type': 'application/json' } })) : real(url, init); })()`);
await b.evaluate('window.MosaicPanes.refresh()');
const select = key => `document.querySelector('#pane-${key} [data-agents-project]')`;
const rows = key => `[...document.querySelectorAll('#pane-${key} [data-agents-available] tr[data-agent]')].map(r => r.dataset.agent)`;
await wait(`${select('a')} && ${select('b')} && ${rows('a')}.includes('social-scheduler') && ${rows('b')}.includes('load-tester')`);
await b.evaluate(`(() => { const s = ${select('b')}; s.value = 'ops'; s.dispatchEvent(new Event('change', { bubbles: true })); })()`);
await wait(`!${rows('b')}.includes('load-tester')`);
assert.equal(await b.evaluate(`${rows('a')}.includes('social-scheduler')`), true);
assert.equal(await b.evaluate(`${rows('a')}.includes('load-tester')`), false);
await b.evaluate('window.MosaicPanes.refresh()');
await settled(b);
await wait(`${select('a')} && ${select('b')}`);
assert.deepEqual(await b.evaluate(`[${select('a')}.value, ${select('b')}.value]`), ['spring-launch', 'ops']);
assert.equal(await b.evaluate(`!${rows('b')}.includes('load-tester') && ${rows('a')}.includes('social-scheduler')`), true);
});
@@ -0,0 +1,313 @@
// U4b (#1873, PRD 1.20): the task board across organizations (sample 06) in a real browser.
// Three orgs: forge keeps its tasks in the built-in store, ddigital in Vikunja, home's bus is down.
// Every bus is a fake in this file; each org's taskWrite records what reached that org only.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const D = n => String(n).repeat(64);
function boardOrgs({ refuse = null, tasksFail = [] } = {}) {
const ORGS = [
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
];
const DOCS = {
ddigital: { appearance: { theme: 'rosewood' }, tracker: { kind: 'vikunja' }, roles: { copywriter: { tracker: { botId: 7 } }, editor: { tracker: { botId: 8 } } } },
forge: { appearance: { theme: 'cobalt' }, roles: { coder: {}, reviewer: {} } },
home: { appearance: { theme: 'grove' } },
};
const PROJECTS = {
ddigital: [{ id: 'spring-launch', repo: 'spring', name: 'Spring launch', at: '2026-11-09T10:00:00.000Z' }],
forge: [{ id: 'api-gateway', repo: 'gateway', name: 'API gateway', at: '2026-11-09T10:00:00.000Z' }, { id: 'billing', repo: 'billing', name: 'Billing', at: '2026-11-08T10:00:00.000Z' }],
};
const store = [
{ n: 41, title: 'Rate limit the public API', state: 'todo', group: 'api-gateway', assignees: ['coder'], priority: 4, due: '2026-11-20T00:00:00.000Z' },
{ n: 42, title: 'Invoice export', state: 'in-progress', group: 'billing', assignees: [], priority: 0, due: null },
{ n: 43, title: 'Retire the v1 gateway', state: 'done', group: 'api-gateway', assignees: ['reviewer'], priority: 2, due: null },
].map(t => ({ ...t, rev: 0 }));
// A task's digest changes with every write to it, as the store's does.
const digest = t => D((t.n + t.rev) % 10);
const TASKS = {
forge: () => store.map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: t.priority, digest: digest(t), requirement: 'REQ-API-1',
fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: t.due } })),
ddigital: () => [
{ task_ref: 'vikunja:3/112', project_key: 'spring', priority: 3, at: '2026-11-09T11:00:00.000Z', fields: { title: 'Launch email, draft 2', done: false, assignees: [7], bucket: 12, labels: ['launch'] } },
{ task_ref: 'vikunja:3/118', project_key: 'spring', priority: 0, at: '2026-11-08T11:00:00.000Z', fields: { title: 'Teaser reel', done: true, assignees: [8], bucket: 14, labels: [] } },
],
};
const writes = [];
const taskWrite = org => async (verb, args) => {
writes.push([org, verb, args]);
if (refuse) throw Object.assign(new Error('refused'), { code: refuse });
const t = store.find(s => `task:${s.n}` === args.task_ref);
if (t && args.expect && args.expect !== digest(t)) throw Object.assign(new Error('conflict'), { code: 'task-conflict' });
if (verb === 'tasks.edit' && t) { for (const f of ['title', 'state', 'assignees']) if (args[f] !== undefined) t[f] = args[f]; t.rev++; }
return { task_ref: args.task_ref, digest: t ? digest(t) : D(9) };
};
const options = id => ({
bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => { if (tasksFail.includes(id)) throw Object.assign(new Error('tasks'), { code: 'tracker-unavailable' }); return TASKS[id](); }, messages: async () => ({ messages: [], unread: 0 }) },
chat: { projects: async () => PROJECTS[id], threads: async () => [] },
});
const healthy = async () => ({ healthy: true, pid: 4242, boot: null });
const down = async () => ({ healthy: false, pid: null, boot: null });
return {
writes, store,
list: async () => ORGS,
members: async () => ORGS.map(o => ({ id: o.id, doc: DOCS[o.id], status: o.busHealthy ? healthy : down, options: () => options(o.id) })),
resolve: async id => {
const org = ORGS.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] }, taskWrite: taskWrite(id) };
return { busHealthy: true, reads: { business: async () => ({ business: { id, projects: PROJECTS[id].map(p => p.id) } }) }, ...options(id), taskWrite: taskWrite(id) };
},
};
}
async function open(t, { refuse = null, tasksFail = [], send = true, width = 1440, height = 900, orgs = boardOrgs({ refuse, tasksFail }) } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, ...(send ? { send: async () => ({}) } : {}), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(width, height);
const url = `http://127.0.0.1:${server.address().port}/#/orgs/forge/tasks`;
await b.navigate(url);
const wait = waitFor(b);
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
return { b, wait, orgs, url };
}
const cards = b => b.evaluate('[...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask)');
test('the board merges every organization\'s tasks, tags each with its org, and a down org keeps its state', async t => {
const { b } = await open(t);
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'kanban');
assert.deepEqual((await cards(b)).sort(), ['ddigital|vikunja:3/112', 'ddigital|vikunja:3/118', 'forge|task:41', 'forge|task:42', 'forge|task:43']);
// Each card carries its org's mark and the id in its tracker's form.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-card")].map(c => [c.dataset.org, c.querySelector(".id").textContent, !!c.querySelector(".ws-av"), c.querySelector(".tb-trk").textContent]).sort()'),
[['ddigital', 'V-112', true, 'Vikunja'], ['ddigital', 'V-118', true, 'Vikunja'], ['forge', '#41', true, 'built-in'], ['forge', '#42', true, 'built-in'], ['forge', '#43', true, 'built-in']]);
// Columns: the store's states, plus one for the Vikunja task whose bucket the bus does not name.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-col h3")].map(h => h.textContent)'), ['To do', 'In progress', 'Review', 'Blocked', 'Done', 'State not read']);
assert.deepEqual(await b.evaluate('[...document.querySelector("[data-tb-col=todo] [data-tb-task=\'forge|task:41\'] .bot").children].map(e => e.textContent.trim())'), ['API gateway', 'coder', 'P4', 'due 20 Nov']);
// Only built-in store cards can be dragged; a Vikunja card is read only.
assert.equal(await b.evaluate('document.querySelector("[data-tb-task=\'forge|task:41\']").draggable'), true);
assert.equal(await b.evaluate('document.querySelector("[data-tb-task=\'ddigital|vikunja:3/112\']").draggable'), false);
// home's bus is down: it shows its state and reason, and the other orgs still render.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-tracker]")].map(e => [e.dataset.tbTracker, e.dataset.state])'), [['ddigital', 'ok'], ['forge', 'ok'], ['home', 'down']]);
assert.match(await b.evaluate('document.querySelector("[data-tb-tracker=home]").textContent'), /Home.*bus down.*the bus is not running/s);
assert.match(await b.evaluate('document.querySelector("[data-tb-org=home]").textContent'), /Home\s*unavailable/);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /5 tasks from 2 of 3 organizations.*Vikunja tasks are read only here/);
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /not built yet/);
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), false);
});
test('org and project filters narrow the board', async t => {
const { b, wait } = await open(t);
await b.evaluate('document.querySelector("[data-tb-org=forge]").click()');
await wait('document.querySelector("[data-tb-org=forge]").getAttribute("aria-pressed") === "true"');
assert.deepEqual((await cards(b)).sort(), ['forge|task:41', 'forge|task:42', 'forge|task:43']);
assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 3 of 5 tasks.');
// The project list follows the org filter.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-filter=project] option")].map(o => o.value).sort()'), ['', '-', 'forge:api-gateway', 'forge:billing']);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
assert.deepEqual(await cards(b), ['forge|task:42']);
assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 1 task that matches the filters.');
// Back to every org; the project filter stays on forge's project.
await b.evaluate('document.querySelector("[data-tb-org=\'\']").click()');
await wait('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed") === "true"');
assert.deepEqual(await cards(b), ['forge|task:42']);
// A project of another org, then the assignee filter on a Vikunja role.
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "ddigital:spring-launch"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 2');
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=assignee]"); s.value = "copywriter"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
assert.deepEqual(await cards(b), ['ddigital|vikunja:3/112']);
});
test('the layout toggle is remembered across a reload', async t => {
const { b, wait } = await open(t);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "list" && document.querySelector("[data-tb-list]")');
assert.equal(await b.evaluate('document.querySelector("[data-tb-layout=list]").getAttribute("aria-pressed")'), 'true');
await b.evaluate('location.reload()');
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'list');
assert.equal(await b.evaluate('document.querySelectorAll("[data-tb-list] tr.tb-row").length'), 5);
});
test('a phone opens on List with no sideways scroll, and Kanban is one tap away', async t => {
const { b, wait } = await open(t, { width: 390, height: 844 });
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'list');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
await b.evaluate('document.querySelector("[data-tb-layout=kanban]").click()');
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "kanban"');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
// One column on a phone.
assert.equal(await b.evaluate('getComputedStyle(document.querySelector(".tb-board")).gridTemplateColumns.split(" ").length'), 1);
});
test('a move writes only to its own org, and a polled repaint keeps focus', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
// Vikunja rows have no State control; a store row does.
assert.equal(await b.evaluate('document.querySelector("[data-tb-move^=ddigital]")'), null);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:41\']"); s.focus(); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('/moved to Review in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:41', expect: D(1), state: 'in-review' }]]);
assert.equal(await b.evaluate('document.querySelector("[data-tb-move=\'forge|task:41\']").value'), 'in-review');
// The search keeps focus and its caret through the poll's repaint.
await b.evaluate('(() => { const q = document.querySelector("[data-tb-q]"); q.focus(); q.value = "inv"; q.dispatchEvent(new Event("input", { bubbles: true })); q.setSelectionRange(1, 1); })()');
await wait('document.querySelectorAll("[data-tb-list] tr.tb-row").length === 1');
// bus.js repaints the view on its own 10 s poll; wait for that repaint to replace the board.
assert.equal(await b.evaluate('(async () => { const before = document.querySelector("[data-tasks-board]"); for (let i = 0; i < 140 && document.querySelector("[data-tasks-board]") === before; i++) await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 50)); return document.querySelector("[data-tasks-board]") !== before; })()'), true);
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement?.selectionStart, document.querySelector("[data-tb-q]").value]'), ['tb-q', 1, 'inv']);
});
test('a write the bus refuses for want of a person is shown, and nothing else is written', async t => {
const { b, wait, orgs } = await open(t, { refuse: 'human-required' });
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'forge|task:42\']")');
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:42\']"); s.value = "done"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelector("[data-tb-notice][role=alert]")');
assert.match(await b.evaluate('document.querySelector("[data-tb-notice]").textContent'), /#42 was not moved\. The bus takes task changes only from a person at the Console\. Nothing was written\./);
assert.deepEqual(orgs.writes.map(w => w[0]), ['forge']);
// The board still shows the task where the bus has it.
assert.equal(await b.evaluate('document.querySelector("[data-tb-move=\'forge|task:42\']").value'), 'in-progress');
// The edit dialog shows the same refusal.
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
await wait('/only from a person/.test(document.querySelector("#tb-dialog [data-tb-form-status]").textContent)');
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]);
});
test('a Console without --send shows a read-only board and the write route refuses', async t => {
const { b, orgs } = await open(t, { send: false });
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), true);
assert.equal(await b.evaluate('document.querySelectorAll(".tb-card[draggable=true]").length'), 0);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /board is read only/);
const status = await b.evaluate('fetch("/api/workspace/tasks", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ op: "edit", org: "forge", task_ref: "task:41", state: "done" }) }).then(r => r.status)');
assert.equal(status, 403);
assert.deepEqual(orgs.writes, []);
});
// bus.js repaints the view on its own 10 s poll; this resolves once that repaint has replaced the board.
const POLLED = '(async () => { const before = document.querySelector("[data-tasks-board]"); for (let i = 0; i < 140 && document.querySelector("[data-tasks-board]") === before; i++) await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 50)); return document.querySelector("[data-tasks-board]") !== before; })()';
test('an edit dialog left open while another writer changes the task saves against what it showed and gets a conflict', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
// Another writer renames the task and assigns it; a poll then reads the new version.
const t42 = orgs.store.find(s => s.n === 42);
Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 });
assert.equal(await b.evaluate(POLLED), true);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export');
// Save sends the digest and the changes against the task as the dialog showed it.
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
await wait('/changed after the board read it/.test(document.querySelector("#tb-dialog [data-tb-form-status]")?.textContent)');
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]]);
assert.equal(t42.title, 'Invoice export (renamed elsewhere)');
// The dialog reopens on the current task, so the next save is checked against what it now shows.
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']);
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), false);
});
test('a poll that answers after focus has moved leaves focus where the person put it', async t => {
const { b, wait } = await open(t);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
// Hold the next workspace read until focus has moved from the search to a row's State control.
await b.evaluate(`(() => {
const real = window.fetch;
window.__held = null;
window.fetch = (u, o) => String(u).startsWith('/api/workspace?') && !window.__held
? new Promise(go => { window.__held = () => real(u, o).then(go); })
: real(u, o);
document.querySelector("[data-tb-q]").focus();
})()`);
// bus.js polls every 10 s, longer than waitFor allows.
assert.equal(await b.evaluate('(async () => { for (let i = 0; i < 140 && !window.__held; i++) await new Promise(r => setTimeout(r, 100)); return !!window.__held; })()'), true);
await b.evaluate('(window.__board = document.querySelector("[data-tasks-board]"), document.querySelector("[data-tb-move=\'forge|task:43\']").focus())');
await b.evaluate('window.__held()');
await wait('document.querySelector("[data-tasks-board]") !== window.__board');
await b.evaluate('new Promise(r => setTimeout(r, 100))');
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'move:forge|task:43');
});
test('an org whose tasks read fails shows its tasks as not read, not as none', async t => {
const { b } = await open(t, { tasksFail: ['forge'] });
assert.deepEqual((await cards(b)).sort(), ['ddigital|vikunja:3/112', 'ddigital|vikunja:3/118']);
assert.match(await b.evaluate('document.querySelector("[data-tb-tracker=forge]").textContent'), /Forge.*tasks not read.*tracker-unavailable/s);
assert.match(await b.evaluate('document.querySelector("[data-tb-org=forge]").textContent'), /Forge\s*unavailable/);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /2 tasks from 1 of 3 organizations/);
// No org that can take a write is left, so New task is off.
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), true);
});
test('a board with no tasks read at all says so instead of showing an empty board', async t => {
const { b } = await open(t, { tasksFail: ['forge', 'ddigital'] });
assert.deepEqual(await cards(b), []);
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]").textContent'), /No organization answered with its tasks, so no tasks could be read\./);
});
test('a committed write whose answer cannot be read is shown as unknown, not as refused', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
// The write reaches the bus; a proxy then replaces the answer with a page that is not JSON.
await b.evaluate(`(() => { const real = window.fetch; window.fetch = async (u, o) => { const r = await real(u, o); return String(u) === '/api/workspace/tasks' ? new Response('<html>Bad gateway</html>', { status: 502, headers: { 'content-type': 'text/html' } }) : r; }; })()`);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:41\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('/may or may not have been written/.test(document.querySelector("[data-tb-notice]").textContent)');
const text = await b.evaluate('document.querySelector("[data-tb-notice]").textContent');
assert.doesNotMatch(text, /was not moved|Nothing was written/);
assert.match(text, /^#41: /);
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:41', expect: D(1), state: 'in-review' }]]);
// The board reads again and shows where the bus has the task.
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']").value === "in-review"');
});
test('a filter change asks the server for the matching tasks', async t => {
const { b, wait } = await open(t);
await b.evaluate(`(() => { const real = window.fetch; window.__reads = []; window.fetch = (u, o) => { if (String(u).startsWith('/api/workspace?')) window.__reads.push(String(u)); return real(u, o); }; })()`);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('window.__reads.length');
const q = new URLSearchParams(await b.evaluate('window.__reads[0].split("?")[1]'));
assert.equal(q.get('project'), 'forge:billing');
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
assert.deepEqual(await cards(b), ['forge|task:42']);
});
test('a conflict on a task another writer moved out of the board\'s filter reopens on the current task', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "in-progress"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
// Another writer finishes the task, so the board's In progress read no longer holds it.
const t42 = orgs.store.find(s => s.n === 42);
Object.assign(t42, { state: 'done', rev: t42.rev + 1 });
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
await wait('document.querySelector("#tb-dialog form")?.state.value === "done"');
assert.match(await b.evaluate('document.querySelector("#tb-dialog [data-tb-form-status]").textContent'), /changed after the board read it/);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), true);
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
});
test('an empty result names the organizations whose tasks were not read', async t => {
const { b, wait } = await open(t, { tasksFail: ['ddigital'] });
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "blocked"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('/No tasks match/.test(document.querySelector("[data-tb-results]").textContent)');
assert.equal(await b.evaluate('document.querySelector("[data-tb-results] .ws-panel").textContent'),
'No tasks match these filters in the organizations that answered. Tasks from Distinctly Digital, Home could not be read.');
});
@@ -5,6 +5,7 @@ import { createDevOwner } from '../src/dev-owner.mjs';
import { createOrgAdmin } from '../src/org-admin.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { fileURLToPath } from 'node:url';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
@@ -109,12 +110,8 @@ test('sidebar follows sample 01 and Queue opens from the organization dashboard,
assert.equal(queue, '#/queue');
await b.evaluate('document.querySelector("[data-org-queue]").click()');
await wait('location.hash === "#/orgs/ddigital/queue" && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Queue")');
// Views not built yet open and say so.
for (const [route, title] of [['inbox', 'Inbox'], ['tasks', 'Tasks'], ['side-by-side', 'Side by side']]) {
await b.evaluate(`location.hash = "#/orgs/ddigital/${route}"`);
await wait(`document.getElementById("bus-view").innerText.includes("not built yet") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === ${JSON.stringify(title)})`);
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /error|failed/i, route);
}
// Every sidebar view is built: Inbox (U3, workspace-pages-browser.test.mjs), Tasks (U4b,
// tasks-board-browser.test.mjs) and Side by side (U5, side-by-side-browser.test.mjs).
});
test('organization dashboard renders every section from the per-organization reads', async t => {
@@ -312,7 +309,9 @@ test('agents view includes organization-scoped instances without inventing root
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-agents]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('organization agent grid missing')})()`);
const text=await b.evaluate('document.querySelector("[data-org-agents]").innerText');
assert.match(text,/Alpha/); assert.match(text,/chief/); assert.match(text,/Beta/);
assert.match(text,/Root agents are not available yet/); assert.match(text,/Project agents are not available yet/);
// No levels read is wired here (no organizations.members), so root and project say unavailable, never none.
assert.match(text,/Root agents unavailable\. The levels read did not answer/); assert.match(text,/Project agents unavailable/);
assert.doesNotMatch(text,/No root agents/);
});
test('an unavailable selected bus does not hide other organizations on Agents', async t => {
@@ -363,7 +362,7 @@ test('agents grid shows the reported state and a model Change link', async t =>
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/agents`);
await waitFor(b)('document.querySelectorAll("[data-org-agents] .ws-tbl tbody tr").length === 4');
const rows = await b.evaluate('[...document.querySelectorAll("[data-org-agents] .ws-tbl tbody tr")].map(tr => [tr.cells[0].innerText.split("\\n")[0].trim(), tr.cells[3].innerText.replace(/\\s+/g, " ").trim(), tr.cells[4].innerText.replace(/\\s+/g, " ").trim()])');
const rows = await b.evaluate('[...document.querySelectorAll("[data-org-agents] .ws-tbl tbody tr")].map(tr => [tr.cells[0].innerText.split("\\n")[0].trim(), tr.cells[4].innerText.replace(/\\s+/g, " ").trim(), tr.cells[5].innerText.replace(/\\s+/g, " ").trim()])');
assert.deepEqual(rows, [['chief', '◐ waiting on you', 'sonnet-5.5 Change'], ['copywriter', '● working', 'sonnet-5.5 Change'], ['montage-producer', '● running', 'sonnet-5.5 Change'], ['editor', '– not running', 'sonnet-5.5 Change']]);
const pills = await b.evaluate('[...document.querySelectorAll("[data-org=ddigital] .ws-agent")].map(p => p.querySelector(".ws-dot").title)');
assert.deepEqual(pills, ['waiting on you', 'working', 'running', 'not running']);
@@ -371,3 +370,70 @@ test('agents grid shows the reported state and a model Change link', async t =>
const label = await b.evaluate('(() => { const a = document.querySelector("[data-org-agents] a.ws-change"); a.focus(); return document.activeElement === a && a.getAttribute("aria-label"); })()');
assert.equal(label, 'Change the model for chief');
});
// #1873 A1: the three levels read from their files. Root agents (reviewer turned off for Home), each
// organization's own instances and two projects' entries: one defines, narrows and keeps; one excludes.
test('agents grid shows each agent\'s level and where it is defined, per project', async t => {
const { checkRootAgents } = await import('../../organization/src/index.mjs');
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
const root = { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: {
assistant: { definition: 'assistant', vars: { model: 'opus-5.5' } },
researcher: { definition: 'research', vars: { model: 'opus-5.5' } },
reviewer: { definition: 'reviewer', excludeOrgs: ['home'] },
} }, { rolesDir }) };
const DOCS = {
ddigital: { roles: { chief: { definition: 'chief-of-staff' }, copywriter: { definition: 'copywriter' }, 'montage-producer': { definition: 'producer' }, editor: { definition: 'editor' } },
projects: { 'spring-launch': { root: '/p/spring', agents: { 'social-scheduler': { definition: 'coder', vars: { model: 'haiku-5.5' } }, editor: { vars: { 'limits.network': 'none' } } } },
'brand-refresh': { root: '/p/brand', agents: { copywriter: { exclude: true } } } } },
forge: { roles: { chief: { definition: 'chief-of-staff' } }, projects: { 'api-gateway': { root: '/p/api', agents: { 'load-tester': { definition: 'coder' } } } } },
home: { roles: {}, projects: {} },
};
const orgs = workspaceOrgs();
const organizations = { ...orgs, members: async () => Object.entries(DOCS).map(([id, doc]) => ({ id, doc })) };
const agentLevels = { loadRoot: () => root, rolesDir, ctx: { configDir: '/cfg', dataRoot: '/data', instance: 'test' } };
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, agentLevels, log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/agents`);
await waitFor(b)('document.querySelectorAll("[data-org-agents] [data-agents-available] tbody tr").length === 8');
// The levels grid: a copy of each root agent per organization, the opt-out said, project-defined agents with their project.
const cells = await b.evaluate('Object.fromEntries([...document.querySelectorAll(".ws-levels [data-org]")].map((c, i) => [`${c.dataset.org}:${i}`, c.innerText.replace(/\\s+/g, " ").trim()]))');
const cell = (org, n) => Object.entries(cells).filter(([k]) => k.startsWith(`${org}:`)).map(([, v]) => v)[n];
assert.match(cell('ddigital', 0), /researcher copy.*reviewer copy/);
assert.match(cell('home', 0), /researcher copy/); assert.doesNotMatch(cell('home', 0), /reviewer copy/); assert.match(cell('home', 0), /reviewer turned off for Home/);
assert.match(cell('ddigital', 2), /social-scheduler Spring launch/); assert.doesNotMatch(cell('ddigital', 2), /editor|copywriter/);
assert.match(cell('forge', 2), /load-tester API gateway/); assert.match(cell('home', 2), /none/);
// The table for the first project: level, where it is defined, memory, status and model per row.
const table = () => b.evaluate('[...document.querySelectorAll("[data-agents-available] tbody tr")].map(tr => [...tr.cells].map(c => c.innerText.replace(/\\s+/g, " ").trim()))');
const rows = await table();
assert.deepEqual(rows.map(r => [r[0].split(' ')[0], r[1], r[2], r[3]]), [
['assistant', 'ROOT', 'Your root configuration', 'Shared across all organizations'],
['researcher', 'ROOT', 'Your root configuration', 'This organization only'],
['reviewer', 'ROOT', 'Your root configuration', 'This organization only'],
['chief', 'ORG', 'Distinctly Digital file', 'This organization only'],
['copywriter', 'ORG', 'Distinctly Digital file', 'This organization only'],
['editor', 'ORG', 'Distinctly Digital file', 'This organization only'],
['montage-producer', 'ORG', 'Distinctly Digital file', 'This organization only'],
['social-scheduler', 'PROJECT', 'Spring launch project', 'This organization only'],
]);
const byName = Object.fromEntries(rows.map(r => [r[0].split(' ')[0], r]));
assert.equal(byName.researcher[4], '– not running'); assert.equal(byName.researcher[5], 'opus-5.5');
assert.equal(byName.reviewer[5], 'not set'); assert.equal(byName.assistant[4], '? status not reported');
// The model the root file sets for the assistant, shown as configured.
assert.equal(byName.assistant[5], 'opus-5.5');
assert.equal(byName.chief[4], '◐ waiting on you'); assert.equal(byName.chief[5], 'sonnet-5.5 Change');
assert.match(byName.editor[0], /narrowed here: limits\.network/);
assert.equal(byName['social-scheduler'][4], '– not running'); assert.equal(byName['social-scheduler'][5], 'haiku-5.5');
assert.match(byName['social-scheduler'][0], /coder/);
assert.match(await b.evaluate('document.querySelector("[data-agents-available] .ws-away").innerText'), /Not available here: every Forge or Home agent\./);
// Another project: its exclusion and the first project's own agent are said, not hidden.
await b.evaluate('(() => { const s = document.querySelector("[data-agents-project]"); s.value = "brand-refresh"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await waitFor(b)('!document.querySelector("[data-agents-available] tr[data-agent=copywriter]")');
const names = (await table()).map(r => r[0].split(' ')[0]);
assert.deepEqual(names, ['assistant', 'researcher', 'reviewer', 'chief', 'editor', 'montage-producer']);
assert.match(await b.evaluate('document.querySelector("[data-agents-available] .ws-away").innerText'), /social-scheduler \(Spring launch only\), copywriter \(excluded in this project\) and every Forge or Home agent/);
// Home, where the reviewer is turned off: the table says so.
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/home/agents`);
await waitFor(b)('document.querySelector("[data-org-agents] h2")?.innerText === "Agents in Home"');
assert.match(await b.evaluate('document.querySelector("[data-agents-available]").innerText'), /reviewer \(turned off for Home\)/);
});
@@ -0,0 +1,369 @@
// U3 (#1873, PRD 1.20 REQ-WS-1..6): the cross-organization Dashboard, Projects and Inbox pages
// (samples 02, 04 and 05). Every bus here is a fake: two running organizations and one stopped,
// read through the real GET /api/workspace (workspace-read.mjs) and the real page code.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
// acme and beta answer; gamma's bus is stopped. Decision ids are long so a shortened id would show.
const ACME_DECISION = 'dec-acme-7f3c9a21-5b8e-4d1f-9c2a-0e6b7d8f9a10';
const BETA_DECISION = 'dec-beta-1a2b3c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d';
function fixture({ pollMs, fail = {}, down = [] } = {}) {
const LIST = [
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false },
];
const DATA = {
acme: {
projects: [
{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) },
{ id: 'p-zeta', repo: 'zeta', name: 'Zeta API', archived: false, at: at(300) },
{ id: 'p-old', repo: 'old', name: 'Old brochure', archived: true, at: at(60 * 24 * 20) },
],
tasks: [{ task_ref: 'local:1', project_key: 'site', at: at(20), fields: { title: 'Hero copy', done: false, bucket: 'Doing' } }],
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
claims: [], threads: [],
inbox: [{ id: ACME_DECISION, question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', task_ref: 'local:1', at: at(10), options: [{ key: 'yes', text: 'Ship it' }, { key: 'no', text: 'Hold' }] }],
decisions: [],
},
beta: {
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }],
tasks: [], agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }], claims: [], threads: [],
inbox: [{ id: BETA_DECISION, question: 'Pick a launch price', class: 'within-role', blocking: false, raised_by_role: 'pm', at: at(5), options: [{ key: 'low', text: 'Low' }] }],
decisions: [],
},
};
// fail: { org: [read names] } makes those reads refuse the way a disabled chat channel does.
const value = (id, name) => fail[id]?.includes(name) ? Promise.reject(Object.assign(new Error('off'), { code: 'channel-disabled' })) : Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
const opts = id => ({
bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') },
chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') },
});
const member = org => ({
id: org.id, doc: { appearance: { theme: org.theme } },
// down: the cross-organization read finds these buses stopped; the page's own reads still answer.
status: async () => (org.busHealthy && !down.includes(org.id) ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
options: () => opts(org.id),
});
return {
list: async () => LIST,
members: async () => LIST.map(member),
resolve: async id => {
const org = LIST.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
return {
busHealthy: true,
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) },
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
};
},
...(pollMs ? { pollMs } : {}),
};
}
async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs, fail, down } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, down }), log: () => {}, ...(workspaceTimeoutMs ? { workspaceTimeoutMs } : {}) });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(...viewport);
const base = `http://127.0.0.1:${server.address().port}`;
await b.navigate(`${base}/#/orgs/acme${hash}`);
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
}
test('Dashboard renders every section across organizations, and a stopped one shows its state while the others render', async t => {
const { b, wait, text } = await setup(t);
await wait('document.querySelector("[data-ws-home] [data-ws-tiles]") && document.querySelectorAll("[data-ws-orgcard]").length === 3');
// The Dashboard nav item is this page.
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Dashboard');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/');
assert.equal(await text('#bus-view h1'), 'Dashboard');
// Tiles: needs you, sessions, open tasks, projects, and an honest line for spend.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile)'), ['needs', 'sessions', 'tasks', 'projects', 'spend']);
assert.match(await text('[data-ws-tile=needs]'), /Needs you\s*2\s*items/i);
assert.match(await text('[data-ws-tile=projects]'), /Projects\s*4\s*3 active/i);
assert.match(await text('[data-ws-tile=spend]'), /No spend figures yet/);
assert.doesNotMatch(await text('[data-ws-tile=spend]'), /\$|\d/);
// Organization cards: two running with their read, the stopped one with its state and the time it was read.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgcard]")].map(e => [e.dataset.wsOrgcard, e.dataset.state])'), [['acme', 'ok'], ['beta', 'ok'], ['gamma', 'down']]);
assert.match(await text('[data-ws-orgcard=gamma]'), /Gamma Home/);
assert.match(await text('[data-ws-orgcard=gamma]'), /down/);
assert.match(await text('[data-ws-orgcard=gamma]'), /stopped by the owner/);
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").getAttribute("href")'), '#/orgs/beta/organization');
assert.match(await text('[data-ws-home] .sub'), /2 of 3 organizations answered/);
assert.match(await text('[data-ws-home] .sub'), /Gamma Home down, read \d\d:\d\d/);
// Recent project activity across both running organizations, newest first, each linking into its org.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-recent] a")].map(a => a.getAttribute("href"))'), ['#/orgs/acme/projects/p-site', '#/orgs/beta/projects/p-shop', '#/orgs/acme/projects/p-zeta']);
assert.match(await text('[data-ws-states]'), /Doing\s*1/);
assert.match(await text('[data-ws-states]'), /Done this week\s*0/);
// Needs you lists both organizations' decisions, newest raised first as the read orders them.
const needs = await b.evaluate('[...document.querySelectorAll("[data-ws-needs] a.q")].map(a => [a.textContent, a.getAttribute("href")])');
assert.equal(needs.length, 2);
assert.ok(needs.some(([q, h]) => q === 'Ship the hero?' && h === `#/orgs/acme/inbox?d=${encodeURIComponent(`acme:${ACME_DECISION}`)}`), JSON.stringify(needs));
assert.ok(needs.some(([q]) => q === 'Pick a launch price'));
// Sessions now: the working and the waiting role, each with its organization.
assert.match(await text('[data-ws-sessions]'), /coder/);
assert.match(await text('[data-ws-sessions]'), /pm/);
// The sidebar Inbox count follows the read across organizations.
assert.equal(await b.evaluate('document.querySelector("#sections a[href=\'#/inbox\'] .ws-n")?.textContent'), '2');
// The per-organization board moved to #/board; the processing link goes there.
assert.equal(await b.evaluate('document.getElementById("processing-link").getAttribute("href")'), '#/board');
});
test('Projects sorts and filters by each key, remembers the grouping, and rows open the project in its organization', async t => {
const { b, wait, text } = await setup(t, { hash: '/projects' });
const names = 'JSON.stringify([...document.querySelectorAll("[data-ws-project]")].map(r => r.dataset.wsProject))';
const settle = async (hash, expected) => { await wait(`location.hash === ${JSON.stringify(hash)} && ${names} === ${JSON.stringify(JSON.stringify(expected))}`); };
await wait('document.querySelector("[data-ws-projects] [data-ws-table]")');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Projects');
// Default: active only, newest activity first, grouped by organization with the stopped one marked.
assert.equal(await b.evaluate(names), JSON.stringify(['acme:p-site', 'acme:p-zeta', 'beta:p-shop']));
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-group-org]")].map(r => r.dataset.wsGroupOrg)'), ['acme', 'beta', 'gamma']);
assert.match(await text('[data-ws-table] tr.ws-unread'), /Not read.*the bus/);
// Rows open the existing project page in their own organization.
assert.equal(await b.evaluate('document.querySelector("[data-ws-project=\'beta:p-shop\'] a.nm").getAttribute("href")'), '#/orgs/beta/projects/p-shop');
// Sort by name (ascending), then again to reverse it.
await b.evaluate('document.querySelector("[data-ws-sort=name]").click()');
await b.evaluate('document.querySelector("[data-ws-group=flat]").click()');
await settle('#/orgs/acme/projects?sort=name', ['beta:p-shop', 'acme:p-site', 'acme:p-zeta']);
assert.equal(await b.evaluate('document.querySelector("[data-ws-table] th[aria-sort]").textContent.trim()'), 'Project↑');
await b.evaluate('document.querySelector("[data-ws-sort=name]").click()');
await settle('#/orgs/acme/projects?sort=name&order=desc', ['acme:p-zeta', 'acme:p-site', 'beta:p-shop']);
// Sort by organization and by status.
await b.evaluate('document.querySelector("[data-ws-sort=org]").click()');
await wait('location.hash === "#/orgs/acme/projects?sort=org" && document.querySelector("[data-ws-sort=org]").getAttribute("aria-pressed") === "true"');
assert.deepEqual(JSON.parse(await b.evaluate(names)).map(k => k.split(':')[0]), ['acme', 'acme', 'beta']);
await b.evaluate('document.querySelector("[data-ws-sort=status]").click()');
await wait('location.hash === "#/orgs/acme/projects?sort=status" && document.querySelector("[data-ws-sort=status]").getAttribute("aria-pressed") === "true"');
// Filter by status: archived and both.
await b.evaluate('(() => { const s = document.querySelector("[data-ws-status]"); s.value = "archived"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await settle('#/orgs/acme/projects?sort=status&status=archived', ['acme:p-old']);
await b.evaluate('(() => { const s = document.querySelector("[data-ws-status]"); s.value = "active,archived"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-ws-project]").length === 4');
// Filter by session state: only Website has a decision waiting.
await b.evaluate('(() => { const s = document.querySelector("[data-ws-state]"); s.value = "waiting"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait(`${names} === ${JSON.stringify(JSON.stringify(['acme:p-site']))}`);
await b.evaluate('(() => { const s = document.querySelector("[data-ws-state]"); s.value = ""; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
await wait('document.querySelectorAll("[data-ws-project]").length === 4');
// Filter by organization chip, then back to all.
await b.evaluate('document.querySelector("[data-ws-org=beta]").click()');
await wait(`${names} === ${JSON.stringify(JSON.stringify(['beta:p-shop']))} && document.querySelector("[data-ws-org=beta]").getAttribute("aria-pressed") === "true"`);
assert.match(location(await b.evaluate('location.hash')), /org=beta/);
await b.evaluate('document.querySelector("[data-ws-org=\'\']").click()');
await wait('document.querySelectorAll("[data-ws-project]").length === 4');
// Filter by name: typing keeps focus and the caret in the box while the list narrows.
await b.evaluate('(() => { const i = document.querySelector("[data-ws-name]"); i.focus(); i.value = "zet"; i.dispatchEvent(new Event("input", { bubbles: true })); })()');
await wait(`${names} === ${JSON.stringify(JSON.stringify(['acme:p-zeta']))}`);
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement.value, document.activeElement.selectionStart]'), ['proj-name', 'zet', 3]);
// Flat mode shows an Organization column and names the stopped organization; the choice is the
// sidebar's remembered one and survives a reload.
assert.equal(await b.evaluate('document.querySelector("[data-ws-table]").dataset.grouping'), 'flat');
assert.match(await text('[data-ws-unread]'), /Gamma Home down/);
assert.equal(await b.evaluate('document.querySelector("#workspace-nav [data-group=flat]").getAttribute("aria-pressed")'), 'true');
assert.equal(await b.evaluate('localStorage.getItem("mosaic-workspace-project-grouping-v1")'), 'flat');
await b.evaluate('location.reload()');
await wait('document.querySelector("[data-ws-table]")?.dataset.grouping === "flat" && document.querySelectorAll("[data-ws-project]").length === 1 && document.querySelector("#workspace-nav [data-group=flat]")?.getAttribute("aria-pressed") === "true"');
// The sidebar's toggle switches this page back.
// The nav repaints after async reads, so the click retries until the page switches.
await wait('(document.querySelector("#workspace-nav [data-group=org]")?.click(), document.querySelector("[data-ws-table]")?.dataset.grouping === "org")');
await wait('document.querySelector("[data-ws-table]")?.dataset.grouping === "org" && document.querySelector("[data-ws-group=org]").getAttribute("aria-pressed") === "true"');
});
const location = hash => decodeURIComponent(hash);
test('Inbox lists both organizations, copies a command with --org and the full id, and deciding needs human proof', async t => {
const { b, base, wait, text } = await setup(t, { hash: '/inbox' });
await wait('document.querySelectorAll("[data-ws-item]").length === 2 && document.querySelector("[data-ws-insp]")');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Inbox');
assert.equal(await b.evaluate('document.querySelector("[data-not-built]")'), null);
const items = await b.evaluate('[...document.querySelectorAll("[data-ws-item]")].map(r => [r.dataset.wsItem, r.innerText])');
assert.deepEqual(items.map(([k]) => k).sort(), [`acme:${ACME_DECISION}`, `beta:${BETA_DECISION}`]);
// Each row names its organization, project and session.
const acme = items.find(([k]) => k.startsWith('acme'))[1];
assert.match(acme, /Acme Studio/); assert.match(acme, /Website/); assert.match(acme, /coder/); assert.match(acme, /gated/);
const beta = items.find(([k]) => k.startsWith('beta'))[1];
assert.match(beta, /Beta Works/); assert.match(beta, /No project/); assert.match(beta, /pm/);
assert.match(await text('[data-ws-unread]'), /Gamma Home down/);
// Tabs the data can't back say so.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-inbox] .ws-seg button")].map(x => [x.textContent, x.disabled])'), [['Needs you', false], ['Seen', true], ['Answered', true]]);
assert.match(await text('[data-ws-inbox]'), /not built yet/);
// Select the acme decision; the inspector shows it, and the selection stays in the hash.
// A pointer click focuses the link before it activates; .click() alone does not.
await b.evaluate(`(() => { const a = document.querySelector('[data-ws-select="acme:${ACME_DECISION}"]'); a.focus(); a.click(); })()`);
await wait(`document.querySelector("[data-ws-insp]")?.dataset.wsInsp === ${JSON.stringify(`acme:${ACME_DECISION}`)}`);
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), `item:acme:${ACME_DECISION}`);
const cmds = await b.evaluate('[...document.querySelectorAll("[data-ws-cmds] [data-copy]")].map(x => [x.dataset.copy, x.previousElementSibling.tagName, x.previousElementSibling.textContent])');
assert.deepEqual(cmds, [[`mosaic decide ${ACME_DECISION} yes --org acme`, 'CODE', `mosaic decide ${ACME_DECISION} yes --org acme`], [`mosaic decide ${ACME_DECISION} no --org acme`, 'CODE', `mosaic decide ${ACME_DECISION} no --org acme`]]);
// Copy puts the full command on the clipboard (or selects it when there is none) and says so.
await b.evaluate('document.querySelector("[data-ws-cmds] [data-copy]").click()');
await wait('document.getElementById("copy-status")?.textContent.length > 0');
const status = await text('#copy-status');
assert.ok(status.includes(`mosaic decide ${ACME_DECISION} yes --org acme`) || /clipboard is unavailable/.test(status), status);
// An answer opens the existing per-organization decision page.
assert.equal(await b.evaluate('document.querySelector("[data-ws-insp] .ws-opts a").getAttribute("href")'), `#/orgs/acme/inbox/${ACME_DECISION}`);
// Without a signed-in human session and its proof, the per-organization decide path refuses.
const refused = await b.evaluate(`(async () => { const r = await fetch('/api/orgs/acme/decisions/${ACME_DECISION}/confirmation', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'yes' }) }); return [r.status, (await r.json().catch(() => ({}))).error ?? null]; })()`);
assert.ok(refused[0] >= 400 && refused[0] < 500, JSON.stringify(refused));
// The decision page itself offers no answer controls without that proof.
await b.navigate(`${base}/#/orgs/acme/inbox/${ACME_DECISION}`);
await wait('[...document.querySelectorAll("#bus-view h1")].length > 0 && !document.querySelector("#bus-view .loading")');
assert.equal(await b.evaluate('document.querySelector("#decide-section [data-decide]")'), null);
// Filter to one organization.
await b.navigate(`${base}/#/orgs/acme/inbox`);
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
await b.evaluate('document.querySelector("[data-ws-org=beta]").click()');
await wait(`document.querySelectorAll("[data-ws-item]").length === 1 && document.querySelector("[data-ws-item]").dataset.wsItem === ${JSON.stringify(`beta:${BETA_DECISION}`)}`);
});
test('A poll repaints in place: focus, caret and scroll stay where they were', async t => {
const { b, wait } = await setup(t, { hash: '/projects', viewport: [1000, 420] });
await wait('document.querySelector("[data-ws-table]") && document.querySelectorAll("[data-ws-project]").length === 3');
assert.ok(await b.evaluate('window.MosaicWorkspace.pollMs') >= 1000);
// Focus a row link, scroll the page, and repaint as a poll does.
await b.evaluate('document.querySelector("[data-ws-project=\'beta:p-shop\'] a.nm").focus({ preventScroll: true }); (document.scrollingElement.scrollTop = 120); document.getElementById("main").scrollTop = 120;');
const before = await b.evaluate('[document.scrollingElement.scrollTop, document.getElementById("main").scrollTop]');
assert.ok(before.some(v => v > 0), `nothing scrolled: ${before}`);
await b.evaluate('window.__marker = document.querySelector("[data-ws-table]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait('document.querySelector("[data-ws-table]") !== window.__marker');
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'prow:beta:p-shop');
assert.deepEqual(await b.evaluate('[document.scrollingElement.scrollTop, document.getElementById("main").scrollTop]'), before);
// The search box keeps its caret across a repaint.
await b.evaluate('(() => { const i = document.querySelector("[data-ws-name]"); i.focus(); i.value = "shop"; i.dispatchEvent(new Event("input", { bubbles: true })); })()');
await wait('location.hash.includes("name=shop") && document.querySelectorAll("[data-ws-project]").length === 1 && document.activeElement?.dataset.focusKey === "proj-name"');
await b.evaluate('document.activeElement.setSelectionRange(2, 2)');
await b.evaluate('window.__marker = document.querySelector("[data-ws-table]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait('document.querySelector("[data-ws-table]") !== window.__marker');
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement.selectionStart]'), ['proj-name', 2]);
// The Dashboard and the Inbox repaint the same way.
await b.evaluate('location.hash = "#/orgs/acme/"');
await wait('document.querySelectorAll("[data-ws-orgcard]").length === 3');
await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").focus()');
await b.evaluate('window.__marker = document.querySelector("[data-ws-orgs]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
await wait('document.querySelector("[data-ws-orgs]") !== window.__marker');
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'orgcard:beta');
// No loading view replaced the page during that repaint.
assert.equal(await b.evaluate('document.querySelector("#bus-view .loading")'), null);
});
test('Phone width: no horizontal scroll on any of the three pages, and the drawer opens and closes', async t => {
const { b, wait } = await setup(t, { viewport: [390, 844] });
for (const [hash, ready] of [['/', '[data-ws-orgcard]'], ['/projects', '[data-ws-project]'], ['/inbox', '[data-ws-item]']]) {
await b.evaluate(`location.hash = "#/orgs/acme${hash}"`);
await wait(`document.querySelector(${JSON.stringify(ready)})`);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, hash);
}
await b.evaluate('document.getElementById("nav-open").click()');
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "true"');
assert.equal(await b.evaluate('getComputedStyle(document.getElementById("console-nav")).visibility !== "hidden" && document.getElementById("console-nav").getBoundingClientRect().right > 0'), true);
await b.evaluate('document.getElementById("nav-close").click()');
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "false"');
// A sidebar link closes the drawer and opens its page.
await b.evaluate('document.getElementById("nav-open").click()');
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "true"');
await b.evaluate('document.querySelector("#sections a[href=\'#/projects\']").click()');
await wait('location.hash === "#/orgs/acme/projects" && document.querySelector("[data-ws-project]")');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
// An inbox row at phone width puts its source on its own line above the question.
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
await wait('document.querySelector("[data-ws-item] .ws-msrc")');
assert.equal(await b.evaluate(`(() => { const r = document.querySelector('[data-ws-item]'), s = r.querySelector('.ws-msrc').getBoundingClientRect(), q = r.querySelector('a.q').getBoundingClientRect(); return s.height > 0 && s.bottom <= q.top + 1; })()`), true, 'source above question');
});
test('A read that an answering organization could not make says not read, never zero', async t => {
// beta answers, but its project and inbox reads refuse.
const { b, wait, text } = await setup(t, { fail: { beta: ['projects', 'inbox'] } });
await wait('document.querySelector("[data-ws-tiles]")');
assert.match(await text('[data-ws-tile=projects] [data-ws-miss=projects]'), /Not read in Beta Works \(the chat channel is off\)/);
assert.match(await text('[data-ws-tile=needs] [data-ws-miss=needs]'), /Not read in Beta Works/);
assert.match(await text('[data-ws-miss=recent]'), /Not read in Beta Works/);
assert.match(await text('[data-ws-miss=needs]:not([data-ws-tile] *)'), /Not read in Beta Works/);
assert.doesNotMatch(await text('.ws-home .sub'), /and \d+ projects?/, 'no project total when a project read failed');
// Projects, grouped: beta's group says why and shows no count or "No projects match".
await b.evaluate('location.hash = "#/orgs/acme/projects"');
await wait('document.querySelector("[data-ws-group-org=beta]")');
assert.equal(await b.evaluate(`document.querySelector('[data-ws-group-org=beta] .ws-n')`), null);
const betaRow = await b.evaluate(`document.querySelector('[data-ws-group-org=beta]').nextElementSibling.innerText`);
assert.match(betaRow, /Projects not read: the chat channel is off/);
assert.match(await text('[data-ws-org=beta] .n'), /not read/i);
// Flat: the note names it.
await b.evaluate('document.querySelector("[data-ws-group=flat]").click()');
await wait('document.querySelector("[data-ws-miss=projects]")');
assert.match(await text('[data-ws-miss=projects]'), /Not read in Beta Works/);
// Inbox: the note names it, and the chip carries no count.
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
await wait('document.querySelector("[data-ws-miss=inbox]")');
assert.match(await text('[data-ws-miss=inbox]'), /Not read in Beta Works \(the chat channel is off\)/);
assert.match(await text('[data-ws-org=beta] .n'), /not read/i);
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-item]").length'), 1);
});
test('When every answering organization failed a read, its tile says Not read instead of a number', async t => {
const { wait, text } = await setup(t, { fail: { acme: ['projects'], beta: ['projects'] } });
await wait('document.querySelector("[data-ws-tiles]")');
assert.match(await text('[data-ws-tile=projects]'), /Not read/i);
assert.equal(await text('[data-ws-tile=projects] .ws-big'), null);
});
// Review P2 (org-u3): a project row's figures come from reads beyond the project list.
test('Project rows and per-organization figures say not read when the reads behind them failed', async t => {
const { b, wait, text } = await setup(t, { fail: { beta: ['inbox', 'tasks', 'agents', 'claims', 'threads'] }, hash: '/projects' });
await wait('document.querySelector("[data-ws-project=\'beta:p-shop\']")');
const cells = await b.evaluate(`[...document.querySelector("[data-ws-project='beta:p-shop']").cells].map(c => c.innerText.trim())`);
// Grouped columns: Project, Status, Sessions, Open tasks, Needs you, Last activity, Git.
assert.deepEqual(cells.slice(2, 5), ['not read', 'not read', 'not read'], 'sessions, open tasks and needs you');
assert.match(cells[5], /some reads missing/);
const acme = await b.evaluate(`[...document.querySelector("[data-ws-project='acme:p-site']").cells].map(c => c.innerText.trim())`);
assert.equal(acme[3], '1'); assert.equal(acme[4], '1');
assert.match(await text('[data-ws-miss=figures]'), /Some figures not read in Beta Works/);
// Dashboard: the per-organization figures leave Beta out instead of showing its 0.
await b.evaluate('location.hash = "#/orgs/acme/"');
await wait('document.querySelector("[data-ws-tiles]")');
for (const tile of ['needs', 'sessions', 'tasks']) {
const by = await b.evaluate(`document.querySelector('[data-ws-tile=${tile}] .ws-by')?.textContent ?? ''`);
assert.doesNotMatch(by, /Beta Works/, tile);
assert.match(await text(`[data-ws-tile=${tile}] [data-ws-miss=${tile}]`), /Not read in Beta Works/, tile);
}
assert.doesNotMatch(await b.evaluate(`[...document.querySelectorAll('[data-ws-states] .ws-srow')].find(r => /Done this week/.test(r.textContent))?.querySelector('.ws-by')?.textContent ?? ''`), /Beta Works/);
});
test('With every organization down the dashboard and inbox show no totals and no empty-list claims', async t => {
const { b, wait, text } = await setup(t, { down: ['acme', 'beta'] });
await wait('document.querySelector("[data-ws-tiles]")');
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-home] .ws-big").length'), 0);
assert.match(await text('[data-ws-tiles]'), /No organization answered/);
assert.equal(await text('[data-ws-needs]'), 'Decisions not read');
assert.equal(await text('[data-ws-states]'), 'Tasks not read');
assert.equal(await text('[data-ws-recent]'), 'Projects not read');
assert.equal(await text('[data-ws-sessions]'), 'Sessions not read');
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
await wait('document.querySelector("[data-ws-items]")');
assert.equal(await text('[data-ws-items]'), 'Decisions not read');
await b.evaluate('location.hash = "#/orgs/acme/projects?grouping=flat"');
await wait('document.querySelector("[data-ws-table]")');
await b.evaluate('document.querySelector("[data-ws-group=flat]").click()');
await wait('document.querySelector("[data-ws-table][data-grouping=flat]")');
assert.match(await text('[data-ws-table] tbody'), /Projects not read/);
});
test('When no organization could read its tasks, Tasks by state shows no Done this week figure', async t => {
const { wait, text } = await setup(t, { fail: { acme: ['tasks'], beta: ['tasks'] } });
await wait('document.querySelector("[data-ws-states]")');
assert.equal(await text('[data-ws-states]'), 'Tasks not read');
assert.match(await text('[data-ws-tile=tasks]'), /Not read/);
});
// Review P3: the session's organization is in the accessible text, not only in the hidden avatar.
test('Sessions now names each session\'s organization for a screen reader', async t => {
const { b, wait } = await setup(t);
await wait('document.querySelector("[data-ws-sessions] td.who")');
const who = await b.evaluate('[...document.querySelectorAll("[data-ws-sessions] td.who")].map(c => c.textContent)');
assert.ok(who.some(w => /Acme Studio: coder/.test(w)), who.join('|'));
assert.ok(who.some(w => /Beta Works: pm/.test(w)), who.join('|'));
});
@@ -0,0 +1,383 @@
// U4b (#1873, PRD 1.20): the task board's server side. The write route POST /api/workspace/tasks
// (strict body, the hook fence, owner and --send, one org per write, the bus's refusals), the task
// rows the cross-organization read adds, and a Vikunja organization's tasks read through the real
// tasks adapter from a fake Vikunja listening on port 0. Every bus here is a real Broker over a Store
// in this test's own scratch, or a fake options object; no test reaches a live bus or a real Vikunja.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { storeAdapter } from '../../tasks/src/store.mjs';
import { FakeVikunja } from '../../tasks/src/fake.mjs';
import { world, sleep } from '../../tasks/tests/world.mjs';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { actionTrail } from '../src/action-trail.mjs';
import { parseTaskWrite, TASK_WRITE_ROUTE } from '../src/workspace-tasks.mjs';
import { parseWorkspaceQuery, readWorkspace, WorkspaceQueryError, TASKS_PER_ORG } from '../src/workspace-read.mjs';
import { close } from './fixture.mjs';
const DIGEST = /^[0-9a-f]{64}$/;
const D = n => String(n).repeat(64);
const scratch = (t, prefix) => { const root = mkdtempSync(join(tmpdir(), prefix)); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
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 }); });
});
req.on('error', reject);
req.end(body);
});
}
// One organization's bus: a real Broker over its own Store. `taskTracker` names the module the bus
// runtime chose (a Vikunja org's is not the store). `human` is the person's cap, `agent` a role's.
const AUTHORITY = { withinRole: ['task.update.assigned', 'message.send'], crossRole: [] };
async function bus(t, id, { taskTracker = null } = {}) {
const root = scratch(t, 'tb-');
const businesses = { [id]: { id, human: 'jason', arbiters: { technical: 'reviewer', delivery: 'coder' }, roles: { coder: { definition: 'coder', authority: AUTHORITY }, reviewer: { definition: 'reviewer', authority: AUTHORITY } } } };
const store = new Store(root);
const broker = new Broker({ store, businesses, ...(taskTracker ? { taskTracker } : {}) });
const adapter = await storeAdapter()({ broker, businesses });
t.after(() => { adapter.close(); store.close(); });
const human = broker.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true });
const agent = broker.bindLaunch({ business: id, role: 'coder', run: 'coder-run', harness: 'pi' });
broker.request(agent, { verb: 'role.claim' });
const read = () => broker.request(broker.bindReader({ business: id }), { verb: 'tasks', args: {} });
return { broker, human, agent, read };
}
// The registry the route resolves through: `resolve(id, session)` admits an org the session belongs
// to and answers its own transport. Every resolve and every bus call is recorded by org.
async function registry(t) {
const forge = await bus(t, 'forge');
const ddigital = await bus(t, 'ddigital', { taskTracker: () => 'vikunja' });
const robot = await bus(t, 'robot');
const resolved = [], sent = [];
const writer = (org, b, cap) => async (verb, args) => { sent.push([org, verb, args]); return b.broker.request(cap, { verb, args }); };
const orgs = {
forge: { busHealthy: true, taskWrite: writer('forge', forge, forge.human) },
ddigital: { busHealthy: true, taskWrite: writer('ddigital', ddigital, ddigital.human) },
// A Console started by an agent: the bus sees no person and answers human-required.
robot: { busHealthy: true, taskWrite: writer('robot', robot, robot.agent) },
home: { busHealthy: false, taskWrite: async (...a) => { sent.push(['home', ...a]); return {}; } },
lost: { busHealthy: true, taskWrite: async (...a) => { sent.push(['lost', ...a]); throw new Error('socket closed'); } },
odd: { busHealthy: true, taskWrite: async (...a) => { sent.push(['odd', ...a]); throw Object.assign(new Error('x'), { code: 'brand-new-code' }); } },
quiet: { busHealthy: true, taskWrite: null },
};
const organizations = {
list: async () => [], members: async () => [],
async resolve(id, session) {
resolved.push([id, session?.sub ?? null]);
if (id === 'old') throw Object.assign(new Error('archived'), { code: 'org-archived' });
if (id === 'broken') throw new Error('/srv/x/org.json: Unexpected token');
return orgs[id] ?? null;
},
};
return { organizations, resolved, sent, forge, ddigital, robot };
}
async function serve(t, { organizations, send = true, sso = createDevOwner() }) {
const dir = join(scratch(t, 'tw-'), 'console');
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, organizations, log: () => {}, trail: actionTrail(dir), ...(send ? { send: async () => ({}) } : {}) });
t.after(() => close(server));
const port = server.address().port, host = `127.0.0.1:${port}`, origin = `http://${host}`;
const post = (body, headers = {}) => call({ host: '127.0.0.1', port }, { method: 'POST', path: TASK_WRITE_ROUTE, body: typeof body === 'string' ? body : JSON.stringify(body),
headers: { host, origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...headers } });
const trail = () => (existsSync(join(dir, 'actions.jsonl')) ? readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []);
return { post, trail, origin };
}
test('parseTaskWrite takes the three ops strictly and refuses before any bus', () => {
assert.deepEqual(parseTaskWrite({ op: 'create', org: 'forge', title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', state: 'todo', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' }),
{ op: 'create', org: 'forge', verb: 'tasks.create', args: { title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', state: 'todo', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' } });
assert.deepEqual(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), state: 'in-review' }),
{ op: 'edit', org: 'forge', verb: 'tasks.edit', args: { task_ref: 'task:4', expect: D(1), state: 'in-review' } });
assert.deepEqual(parseTaskWrite({ op: 'archive', org: 'forge', task_ref: 'task:4', expect: D(1) }), { op: 'archive', org: 'forge', verb: 'tasks.archive', args: { task_ref: 'task:4', expect: D(1) } });
assert.equal(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), due_date: null }).args.due_date, null);
const refused = [
[{ op: 'delete', org: 'forge', task_ref: 'task:1' }, /^op takes create, edit, archive$/],
[{ op: 'unarchive', org: 'forge', task_ref: 'task:1' }, /^op takes/],
[{ op: 'edit', org: 'Forge!', task_ref: 'task:1', title: 'x' }, /^org takes/],
[{ op: 'edit', task_ref: 'task:1', title: 'x' }, /^org takes/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', description: 'x' }, /^edit does not take description$/],
[{ op: 'archive', org: 'forge', task_ref: 'task:1', title: 'x' }, /^archive does not take title$/],
[{ op: 'create', org: 'forge', title: 'x' }, /^create needs requirement$/],
[{ op: 'edit', org: 'forge', title: 'x' }, /^edit needs task_ref$/],
// An edit or archive without the digest the board read is refused before any bus (no silent overwrite).
[{ op: 'edit', org: 'forge', task_ref: 'task:1', title: 'x' }, /^edit needs expect$/],
[{ op: 'archive', org: 'forge', task_ref: 'task:1' }, /^archive needs expect$/],
[{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(2) }, /^edit needs a field to change$/],
// A Vikunja task is never written from the board: its ref is refused here.
[{ op: 'edit', org: 'ddigital', expect: D(1), task_ref: 'vikunja:3/112', title: 'x' }, /^task_ref is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:0', title: 'x' }, /^task_ref is not valid$/],
[{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: 'abc', title: 'x' }, /^expect is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: ' ' }, /^title is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: 'x'.repeat(251) }, /^title is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', state: 'doing' }, /^state is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 6 }, /^priority is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 1.5 }, /^priority is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: ['coder', 'bad name'] }, /^assignees is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: 'coder' }, /^assignees is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-11-20' }, /^due_date is not valid$/],
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-02-30T00:00:00.000Z' }, /^due_date is not valid$/],
[{ op: 'create', org: 'forge', title: 'x', requirement: 'req-1' }, /^requirement is not valid$/],
[{ op: 'create', org: 'forge', title: 'x', requirement: 'REQ-TASK-1', group: 'a\nb' }, /^group is not valid$/],
];
for (const [body, message] of refused) assert.throws(() => parseTaskWrite(body), e => e.status === 400 && message.test(e.message), JSON.stringify(body));
});
test('route: the fence, the owner and --send come before any org is resolved', async t => {
const r = await registry(t);
const { post } = await serve(t, { organizations: r.organizations });
const body = { op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(1), title: 'x' };
const fenced = [
[{ origin: null }, 'cross-origin request refused'],
[{ origin: 'http://evil.test' }, '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'],
];
for (const [headers, error] of fenced) {
const res = await post(body, headers);
assert.equal(res.status, 403, JSON.stringify(headers)); assert.equal(res.body.error, error);
}
// Body checks: content type, size and shape, each with sent:false.
const bad = [
[await post(JSON.stringify(body), { 'content-type': 'text/plain' }), 415],
[await post(JSON.stringify({ ...body, title: 'x'.repeat(17000) })), 413],
[await post('{not json'), 400],
[await post('[1]'), 400],
[await post({ ...body, extra: 1 }), 400],
];
for (const [res, status] of bad) { assert.equal(res.status, status); assert.equal(res.body.error, 'invalid-request'); assert.equal(res.body.sent, false); }
assert.deepEqual(r.resolved, []); assert.deepEqual(r.sent, []);
// Without --send the Console is read only.
const ro = await serve(t, { organizations: r.organizations, send: false });
const refused = await ro.post(body);
assert.equal(refused.status, 403); assert.equal(refused.body.error, 'read-only');
// A granted user's session is fenced as owner-only before the handler.
const granted = { ...createDevOwner(), session: () => ({ provider: 'local-dev', iss: 'local-dev', sub: 'alice-sub', user: 'alice', name: 'Alice' }) };
const user = await serve(t, { organizations: r.organizations, sso: granted });
const other = await user.post(body);
assert.equal(other.status, 403); assert.equal(other.body.error, 'owner-only');
assert.deepEqual(r.resolved, []); assert.deepEqual(r.sent, []);
});
test('route: each write reaches only the org it names, through that org\'s real bus, with the person\'s verbs', async t => {
const r = await registry(t);
const { post, trail } = await serve(t, { organizations: r.organizations });
const created = await post({ op: 'create', org: 'forge', title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', assignees: ['coder'], priority: 2 });
assert.equal(created.status, 200, JSON.stringify(created.body));
assert.equal(created.body.ok, true); assert.equal(created.body.org, 'forge'); assert.equal(created.body.op, 'create');
assert.equal(created.body.task_ref, 'task:1'); assert.match(created.body.digest, DIGEST);
assert.deepEqual(r.resolved, [['forge', 'local-dev-owner']]);
assert.deepEqual(r.sent.map(([org, verb]) => [org, verb]), [['forge', 'tasks.create']]);
const [task] = r.forge.read();
assert.equal(task.task_ref, 'task:1'); assert.equal(task.source, 'store'); assert.equal(task.fields.title, 'Rate limits');
assert.equal(task.digest, created.body.digest);
// No other org's bus holds anything.
assert.deepEqual(r.robot.read(), []);
// An edit with the digest the board read is written; a stale one is refused and changes nothing.
const moved = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: created.body.digest, state: 'in-review' });
assert.equal(moved.status, 200); assert.notEqual(moved.body.digest, created.body.digest);
assert.equal(r.forge.read()[0].fields.state, 'in-review');
const stale = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: created.body.digest, title: 'Lost update' });
assert.equal(stale.status, 409); assert.equal(stale.body.error, 'task-conflict'); assert.equal(stale.body.sent, false);
assert.match(stale.body.message, /Nothing was written/);
assert.equal(r.forge.read()[0].fields.title, 'Rate limits');
const missing = await post({ op: 'edit', org: 'forge', task_ref: 'task:9', expect: D(1), title: 'x' });
assert.equal(missing.status, 404); assert.equal(missing.body.error, 'task-not-found');
const role = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: moved.body.digest, assignees: ['nobody'] });
assert.equal(role.status, 400); assert.equal(role.body.error, 'unknown-role');
const archived = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: moved.body.digest });
assert.equal(archived.status, 200);
assert.deepEqual(r.forge.read(), [], 'an archived task leaves the read');
const again = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: archived.body.digest });
assert.equal(again.status, 409); assert.equal(again.body.error, 'task-already-archived');
// A Vikunja organization's bus refuses the store verbs; the board never writes to Vikunja.
const vik = await post({ op: 'create', org: 'ddigital', title: 'Launch post', requirement: 'REQ-TASK-1' });
assert.equal(vik.status, 409); assert.equal(vik.body.error, 'tracker-not-store'); assert.equal(vik.body.sent, false);
assert.match(vik.body.message, /keeps its tasks in Vikunja/);
assert.deepEqual(r.ddigital.read(), []);
// A Console started by an agent: the bus finds no person, so nothing is written.
const robot = await post({ op: 'create', org: 'robot', title: 'Sneaky', requirement: 'REQ-TASK-1' });
assert.equal(robot.status, 403); assert.equal(robot.body.error, 'human-required');
assert.match(robot.body.message, /only from a person/);
assert.deepEqual(r.robot.read(), []);
// An org outside the session, an archived org, an unreadable registry, a down bus, a Console
// without the write: each is refused before or without a bus call.
const before = r.sent.length;
const outside = await post({ op: 'edit', org: 'acme', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(outside.status, 404); assert.deepEqual(outside.body, { error: 'org-unavailable', sent: false });
const old = await post({ op: 'edit', org: 'old', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(old.status, 409); assert.equal(old.body.error, 'org-archived');
const broken = await post({ op: 'edit', org: 'broken', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(broken.status, 404); assert.deepEqual(broken.body, { error: 'org-unavailable', sent: false });
const down = await post({ op: 'edit', org: 'home', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(down.status, 503); assert.equal(down.body.error, 'no-bus-host'); assert.equal(down.body.sent, false);
const quiet = await post({ op: 'edit', org: 'quiet', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(quiet.status, 403); assert.equal(quiet.body.error, 'read-only');
assert.equal(r.sent.length, before);
// A lost answer is not a refusal: the change may have been written, and the person is told so.
const lost = await post({ op: 'edit', org: 'lost', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(lost.status, 503); assert.equal(lost.body.error, 'outcome-unknown'); assert.equal(lost.body.sent, 'unknown');
const odd = await post({ op: 'edit', org: 'odd', task_ref: 'task:1', expect: D(1), title: 'x' });
assert.equal(odd.status, 502); assert.equal(odd.body.error, 'brand-new-code'); assert.equal(odd.body.sent, false);
// Every resolve named exactly the org in the body, and each bus call went to that org.
for (const [org, verb] of r.sent) assert.ok(['tasks.create', 'tasks.edit', 'tasks.archive'].includes(verb), `${org} ${verb}`);
assert.deepEqual([...new Set(r.resolved.map(([id]) => id))], ['forge', 'ddigital', 'robot', 'acme', 'old', 'broken', 'home', 'quiet', 'lost', 'odd']);
// The action trail notes each outcome with who, which org and which verb.
const notes = trail().map(e => [e.org, e.action, e.task ?? null, e.outcome]);
assert.deepEqual(notes, [
['forge', 'tasks.create', null, 'written'],
['forge', 'tasks.edit', 'task:1', 'written'],
['forge', 'tasks.edit', 'task:1', 'refused task-conflict'],
['forge', 'tasks.edit', 'task:9', 'refused task-not-found'],
['forge', 'tasks.edit', 'task:1', 'refused unknown-role'],
['forge', 'tasks.archive', 'task:1', 'written'],
['forge', 'tasks.archive', 'task:1', 'refused task-already-archived'],
['ddigital', 'tasks.create', null, 'refused tracker-not-store'],
['robot', 'tasks.create', null, 'refused human-required'],
['lost', 'tasks.edit', 'task:1', 'unknown'],
['odd', 'tasks.edit', 'task:1', 'refused brand-new-code'],
]);
assert.ok(trail().every(e => e.by?.sub === 'local-dev-owner' && e.by?.provider === 'local-dev'));
});
// One org's reads as the workspace read sees them (options().bus and options().chat).
function member(id, data, doc = {}) {
const value = name => Promise.resolve(data[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
return {
id, doc, status: async () => ({ healthy: true, pid: 1, boot: null }),
options: () => ({
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 SESSION = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
const readTasks = (members, search = 'sections=tasks') => readWorkspace({ organizations: { members: async () => members }, session: SESSION, query: parseWorkspaceQuery(search), now: () => Date.parse('2026-11-10T12:00:00.000Z') });
test('read: a built-in store org\'s rows, from its real bus, carry state, project, roles and the digest a write sends back', async t => {
const forge = await bus(t, 'forge');
const p = (verb, args) => forge.broker.request(forge.human, { verb, args });
p('tasks.create', { title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' });
p('tasks.create', { title: 'Invoice PDF', requirement: 'REQ-TASK-1', group: 'billing', state: 'blocked' });
p('tasks.create', { title: 'Loose end', requirement: 'REQ-TASK-1' });
const gone = p('tasks.create', { title: 'Old idea', requirement: 'REQ-TASK-1' });
p('tasks.archive', { task_ref: gone.task_ref });
const projects = [{ id: 'p-api', repo: 'api-gateway', name: 'API gateway', archived: false, at: '2026-11-01T00:00:00.000Z' }, { id: 'p-bill', repo: 'billing', name: 'Billing', archived: false, at: '2026-11-01T00:00:00.000Z' }];
const body = await readTasks([member('forge', { tasks: forge.read(), projects }, { roles: { coder: {}, reviewer: {} } })]);
assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker]), [['forge', 'ok', 'built-in']]);
const rows = body.tasks.map(x => [x.ref, x.tracker, x.title, x.state, x.project?.key ?? null, x.group, x.assignees, x.priority, x.dueDate, x.writable, DIGEST.test(x.digest)]);
assert.deepEqual(rows, [
// A group matches a project by id or by key; a group naming neither has no project.
['task:1', 'built-in', 'Rate limits', 'todo', 'api-gateway', 'p-api', ['coder'], 3, '2026-11-20T00:00:00.000Z', true, true],
['task:2', 'built-in', 'Invoice PDF', 'blocked', 'billing', 'billing', [], 0, null, true, true],
['task:3', 'built-in', 'Loose end', 'todo', null, null, [], 0, null, true, true],
]);
// The digest is the bus's own, so an edit's `expect` matches the task the board shows.
assert.equal(body.tasks[0].digest, forge.read().find(x => x.task_ref === 'task:1').digest);
p('tasks.edit', { task_ref: 'task:1', expect: body.tasks[0].digest, state: 'done' });
const after = await readTasks([member('forge', { tasks: forge.read(), projects }, {})], 'sections=tasks&task-state=done');
assert.deepEqual(after.tasks.map(x => [x.ref, x.state, x.done]), [['task:1', 'done', true]]);
});
test('read: filters, the per-org cap, and rows the read refuses', async () => {
const store = (n, f = {}, extra = {}) => ({ task_ref: `task:${n}`, source: 'store', digest: D(n % 10), at: '2026-11-09T00:00:00.000Z', fields: { title: `T${n}`, state: 'todo', group: null, assignees: [], labels: [], ...f }, ...extra });
const tasks = [store(1, { state: 'in-review', assignees: ['coder'], labels: [4] }), store(2, { assignees: ['reviewer'] }), store(3, { state: 'nonsense' }), store(4, {}, { digest: 'not-a-digest' }), store(5, {}, { archived: true })];
const one = async search => (await readTasks([member('forge', { tasks })], `sections=tasks${search}`)).tasks.map(x => x.ref);
assert.deepEqual(await one(''), ['task:1', 'task:2', 'task:3', 'task:4']);
assert.deepEqual(await one('&task-state=in-review'), ['task:1']);
// An unknown state reads as null, which only `open` matches.
assert.deepEqual(await one('&task-state=open'), ['task:3']);
assert.deepEqual(await one('&assignee=reviewer,coder'), ['task:1', 'task:2']);
assert.deepEqual(await one('&label=4'), ['task:1']);
const all = (await readTasks([member('forge', { tasks })])).tasks;
assert.equal(all.find(x => x.ref === 'task:4').digest, null, 'a malformed digest is never sent back');
for (const search of ['task-state=doing', 'assignee=', 'assignee=a b', 'label=0', 'label=x', 'task-state=todo&task-state=done'])
assert.throws(() => parseWorkspaceQuery(search), WorkspaceQueryError, search);
// Past the cap the org keeps its TASKS_PER_ORG most recently changed rows and says how many
// matched; another org is untouched. Task n changed n minutes after midnight.
const minute = n => new Date(Date.parse('2026-11-09T00:00:00.000Z') + n * 60000).toISOString();
const many = Array.from({ length: TASKS_PER_ORG + 7 }, (_, i) => store(i + 1, i + 1 > TASKS_PER_ORG ? { group: 'late', state: 'blocked' } : { group: 'p1' }, { at: minute(i + 1), project_key: 'k' }));
const projects = [{ id: 'p1', repo: 'one', name: 'One', archived: false, at: minute(0) }, { id: 'late', repo: 'late', name: 'Late', archived: false, at: minute(0) }];
const big = [member('big', { tasks: many, projects }), member('small', { tasks: [store(1)] })];
const capped = await readTasks(big, 'sections=tasks,projects,metrics');
const bigRows = capped.tasks.filter(x => x.org === 'big');
assert.equal(bigRows.length, TASKS_PER_ORG);
assert.equal(capped.orgs.find(o => o.org === 'big').tasksHeld, TASKS_PER_ORG + 7);
assert.equal(capped.orgs.find(o => o.org === 'small').tasksHeld, undefined);
assert.equal(bigRows.some(x => x.ref === 'task:7'), false, 'the oldest changes are the ones cut');
assert.equal(bigRows.some(x => x.ref === 'task:8'), true);
// Store refs sort by number, so task:10 follows task:9.
assert.deepEqual(bigRows.slice(1, 4).map(x => x.ref), ['task:9', 'task:10', 'task:11']);
// Projects and metrics count every task the org holds, not only the ones the answer carries.
const proj = Object.fromEntries(capped.projects.filter(x => x.org === 'big').map(x => [x.id, x]));
assert.equal(proj.p1.openTasks, TASKS_PER_ORG); assert.equal(proj.late.openTasks, 7);
assert.equal(capped.metrics.orgs.find(m => m.org === 'big').openTasks, TASKS_PER_ORG + 7);
// A filter runs before the cap, so it reaches tasks past row TASKS_PER_ORG.
for (const search of ['sections=tasks&project=big:late', 'sections=tasks&task-state=blocked']) {
const filtered = await readTasks(big, search);
assert.deepEqual(filtered.tasks.map(x => x.ref), Array.from({ length: 7 }, (_, i) => `task:${TASKS_PER_ORG + i + 1}`), search);
assert.equal(filtered.orgs.find(o => o.org === 'big').tasksHeld, undefined, search);
}
});
test('read: a Vikunja org\'s tasks come through the real tasks adapter from a fake Vikunja on port 0', async t => {
const fake = new FakeVikunja();
const server = await fake.listen();
t.after(server.close);
assert.match(server.url, /^http:\/\/127\.0\.0\.1:\d+$/);
const w = await world(t, { fake, fetch: globalThis.fetch, tracker: { baseUrl: server.url } });
await w.adapter.start('demo');
assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused);
const pm = w.agent('pm');
const a = await w.call(pm, 'task.create', { title: 'Launch post', request: w.instruction(), requirement: 'REQ-TASK-1' });
const b = await w.call(pm, 'task.create', { title: 'Press kit', request: w.instruction(), requirement: 'REQ-TASK-1' });
const id = ref => Number(ref.slice(ref.indexOf('/') + 1));
// The owner works in the Vikunja UI; the adapter's poll picks it up.
assert.equal((await w.ui('POST', `/tasks/${id(a.task_ref)}/assignees`, { user_id: w.bots.coder })).status, 201);
assert.equal((await w.ui('POST', `/tasks/${id(a.task_ref)}/labels`, { label_id: w.label })).status, 201);
assert.equal((await w.ui('PATCH', `/tasks/${id(a.task_ref)}`, { due_date: '2026-11-20T00:00:00Z', percent_done: 0.5, description: 'not on the board' })).status, 200);
assert.equal((await w.ui('PATCH', `/tasks/${id(b.task_ref)}`, { done: true })).status, 200);
await sleep(20);
await w.adapter.tick('demo');
assert.ok(fake.requests.length > 0, 'the adapter read the fake over HTTP');
const rows = w.broker.request(w.human, { verb: 'tasks', args: {} });
const doc = w.businesses.demo;
const body = await readTasks([member('demo', { tasks: rows, projects: [] }, doc)]);
assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker]), [['demo', 'ok', 'vikunja']]);
const [x, y] = body.tasks;
// Mapped: ref, title, done (the only state the bus names), bot ids as roles, labels, priority,
// due date and the numeric bucket. Left out: description, percent_done, relations, comments.
assert.deepEqual({ ref: x.ref, tracker: x.tracker, title: x.title, state: x.state, done: x.done, assignees: x.assignees, labels: x.labels, dueDate: x.dueDate, writable: x.writable, digest: x.digest },
{ ref: a.task_ref, tracker: 'vikunja', title: 'Launch post', state: null, done: false, assignees: ['coder'], labels: [w.label], dueDate: '2026-11-20T00:00:00.000Z', writable: false, digest: null });
assert.ok(Number.isSafeInteger(x.bucket) && x.bucket > 0, 'the bucket is the board\'s numeric id');
assert.equal(x.requirement, 'REQ-TASK-1');
assert.deepEqual([y.ref, y.state, y.done], [b.task_ref, 'done', true]);
for (const key of ['description', 'percent_done', 'percentDone', 'relations', 'comments']) assert.equal(Object.hasOwn(x, key), false, key);
// The board's filters work on a Vikunja org too.
const open = await readTasks([member('demo', { tasks: rows, projects: [] }, doc)], 'sections=tasks&task-state=open&assignee=coder');
assert.deepEqual(open.tasks.map(t => t.ref), [a.task_ref]);
});