Compare commits

...
14 Commits
Author SHA1 Message Date
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
jason.woltje 69af2fe94b docs(plans): accounts design draft 2, review fix round (#1873)
Applies the luna review (3 changes) and the sol review (10 changes):
per-role transport table with member reads limited to threads, ownsOrg
over every subject of the owning account, memberGrant adapter, verified
snapshot read after the check, authGen session invalidation, one lock
order, random org incarnation, explicit invite callback exception, a
build stamp that refuses the dev owner in stamped copies, and a rollback
that keeps OIDC. Member board reads and task writes move to slice M8
behind an owner ruling.
2026-11-10 12:32:06 -06:00
jason.woltje 4106bf7344 docs(plans): accounts, membership and invites design, Phase M (#1873) 2026-11-10 12:13:32 -06:00
32 changed files with 4137 additions and 45 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/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";
+237
View File
@@ -0,0 +1,237 @@
// 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`);
}
}
// A var value and every leaf it holds, as a var check's message quotes them (JSON.stringify).
function quoted(value, out = []) {
if (Array.isArray(value)) value.forEach((v) => quoted(v, out));
else if (isPlainObject(value)) Object.values(value).forEach((v) => quoted(v, out));
if (value !== undefined) out.push(JSON.stringify(value));
return out;
}
// checkVars, but a refusal never carries a value. Var checks quote the value they refuse, a value
// can be a secret pasted in the wrong place, and these refusals reach the Console. Each key is
// rechecked alone to name the one at fault; a message that quotes its value is replaced by one
// that names the key only. A rule across keys (the fallback set) names keys only already.
function checkVarsNamingKeys(vars, layer, where) {
try { return checkVars(vars, layer, where); }
catch (error) {
if (error?.name !== "BusinessError" || !isPlainObject(vars)) throw error;
for (const [key, value] of Object.entries(vars)) {
try { checkVars({ [key]: value }, layer, where); }
catch (one) {
if (one?.name !== "BusinessError") throw one;
if (quoted(value).some((text) => one.message.includes(text))) refuse(`${where}: variable ${key} has a value it can't take (the value is not shown)`);
throw one;
}
}
throw error;
}
}
// 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: checkVarsNamingKeys(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: checkVarsNamingKeys(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: checkVarsNamingKeys(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";
+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";
@@ -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`);
}
@@ -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} has a value it can't take`) && !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'])));
});
+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 });
+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 = {}) {
+32 -11
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.
@@ -1452,9 +1452,11 @@
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 === 'inbox' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.inboxPage(q);
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) return inboxView(get);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
@@ -1475,7 +1477,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 +1493,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 +1507,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,23 +1516,35 @@
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; }
@@ -1597,11 +1614,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 +1634,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 +1817,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}`;
+1 -1
View File
@@ -34,7 +34,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
}
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
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\/(?:orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|workspace(?:\?|$)|agents\/levels(?:\?|$))/.test(input))
input = `/api/orgs/${id}/${input.slice('/api/'.length)}`;
return nativeFetch(input, init);
};
+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],
+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}
}
+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 });
})();
+454 -13
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));
@@ -262,38 +262,479 @@
}
// ---------- agents levels (sample 08) ----------
// 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
let agentsAvailable = null; // repaints that section alone when the choice changes
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 = '') {
const [list] = await Promise.all([data.organizations().catch(() => null), ensureThemes()]);
const [list, levels] = await Promise.all([data.organizations().catch(() => null), data.agentLevels().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>`;
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 = 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 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 box = select && document.querySelector('[data-agents-available]');
if (!box || !agentsAvailable || !window.MosaicOrg) return;
agentsPick = { ...agentsPick, [window.MosaicOrg.id]: select.value };
box.innerHTML = agentsAvailable(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);
});
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, refresh: () => refresh(), home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
refresh();
})();
+4 -1
View File
@@ -21,6 +21,7 @@ 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 { actor } from './action-trail.mjs';
import { devHostName } from '../../cli/src/dev-local-owner.mjs';
@@ -771,7 +772,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 +809,8 @@ 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 }) }));
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.
@@ -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 has a value it can't take/);
assert.doesNotMatch(JSON.stringify(badVar.body), /secret-marker/);
});
@@ -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);
@@ -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)})})()`);
@@ -110,7 +111,8 @@ test('sidebar follows sample 01 and Queue opens from the organization dashboard,
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']]) {
// Inbox is built (U3, workspace-pages-browser.test.mjs).
for (const [route, title] of [['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);
@@ -312,7 +314,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 +367,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 +375,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('|'));
});