Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
821fbe8e9b | ||
|
|
d247b4eb61 | ||
|
|
e358886826 |
No files matched your search
@@ -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.
|
||||
@@ -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";
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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(?:\?|$)|agents\/levels(?:\?|$))/.test(input))
|
||||
input = `/api/orgs/${id}/${input.slice('/api/'.length)}`;
|
||||
return nativeFetch(input, init);
|
||||
};
|
||||
|
||||
@@ -529,6 +529,12 @@ 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}
|
||||
|
||||
|
||||
@@ -20,5 +20,7 @@
|
||||
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 });
|
||||
// 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, agentLevels });
|
||||
})();
|
||||
@@ -262,37 +262,132 @@
|
||||
}
|
||||
|
||||
// ---------- 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);
|
||||
});
|
||||
|
||||
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, refresh: () => refresh() });
|
||||
refresh();
|
||||
|
||||
@@ -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,7 @@ 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);
|
||||
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/);
|
||||
|
||||
@@ -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)})})()`);
|
||||
|
||||
@@ -312,7 +313,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 +366,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 +374,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\)/);
|
||||
});
|
||||
Reference in new issue
Block a user