Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
821fbe8e9b | ||
|
|
d247b4eb61 | ||
|
|
e358886826 | ||
|
|
01b79e1db4 | ||
|
|
0a75cc82f3 | ||
|
|
c66cd58846 | ||
|
|
69af2fe94b | ||
|
|
4106bf7344 | ||
|
|
8630b8dfc0 | ||
|
|
5eb6dda049 | ||
|
|
8710cc0e1e | ||
|
|
364f1ae7a6 | ||
|
|
9b7bf11afb |
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,361 @@
|
||||
# Agent levels and the cross-organization assistant (Phase A design, #1873)
|
||||
|
||||
Status: design for lane org-a1, 2026-11-10. Slice A1 (read-only store, union
|
||||
resolution, Agents grid) is built on branch `org-a1`. Everything that gives an
|
||||
agent a new capability is in the roles proposal and the later slices listed in
|
||||
section 9; none of it is applied.
|
||||
|
||||
Direction: PRD 1.20 draft 2 (REQ-ASSIST-1, REQ-USER-1 amendment, REQ-AGENT-1
|
||||
to REQ-AGENT-3, REQ-COS-3, REQ-DEC-2, REQ-ROLE-3), owner direction rulings 4
|
||||
and 11, the U0 approval and the addendum in
|
||||
`docs/plans/2026-11-09_unified-workspace-owner-direction.md`, and the role model
|
||||
in `roles/` and `docs/plans/2026-09-02_atomic-mosaic-foundation.md`.
|
||||
|
||||
Owner words: "Agents are configured at three levels: the user's root, the
|
||||
organization and the project. A project's agents are the union of all three.
|
||||
The user's main interaction agent (the assistant) is the one exception to
|
||||
isolation. [...] Every other agent is isolated to the organization it works
|
||||
in." And: "the COS should be able to hire and fire agents to fulfill the
|
||||
user's needs."
|
||||
|
||||
## 1. Terms
|
||||
|
||||
- **Definition**: a named agent entry at one level. It names a role
|
||||
definition in `roles/` (`definition`) and may carry vars. It never names a
|
||||
credential, a holder, a tracker bot, an identity or a publish grant at the
|
||||
root or project level.
|
||||
- **Role definition**: `roles/<name>.json`, versioned and changed only by
|
||||
reviewed commits (invariant 7). A level chooses among role definitions and
|
||||
never creates one, so no level can grant an authority that `roles/` does
|
||||
not already hold.
|
||||
- **Copy**: a root definition as it runs inside one organization. Each
|
||||
organization runs its own copy, and each copy has that organization's
|
||||
memory, files and credentials.
|
||||
- **Union**: the agents available in one project of one organization.
|
||||
|
||||
## 2. Where each level lives
|
||||
|
||||
| Level | File | Writer today | Owner |
|
||||
|---|---|---|---|
|
||||
| root | `<configDir>/agents.json`, next to the instance's system config | the user, by hand (no writer in A1) | the instance's OS user |
|
||||
| org | `roles` in `<configDir>/businesses/<org>.json` (unchanged) | existing org writers | the organization |
|
||||
| project | `projects.<project>.agents` in the same organization file | the owner's `updateOrg` only; no agent writer can reach it | the organization |
|
||||
|
||||
### Root: `<configDir>/agents.json`
|
||||
|
||||
`configDir` is the directory of the instance's system config
|
||||
(`~/.config/mosaic-<instance>/`). The file sits outside the data root, so it is
|
||||
outside every organization root (`<dataRoot>/org-runtime/<org>`) and every
|
||||
bus, and no bus or sandbox mount reaches it. It is per instance, as the system
|
||||
config is (invariant 2). When accounts arrive (Phase M), it moves to a
|
||||
per-principal path under the same directory; the format stays.
|
||||
|
||||
```json
|
||||
{
|
||||
"agentsVersion": 1,
|
||||
"agents": {
|
||||
"assistant": { "definition": "assistant", "vars": { "model": "opus-5.5" } },
|
||||
"reviewer": { "definition": "reviewer", "excludeOrgs": ["home"] },
|
||||
"researcher": { "definition": "researcher" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Keys: `definition` (required), `vars` (agent layer, `checkVars(..., "agent")`),
|
||||
`excludeOrgs` (distinct organization ids; "reviewer turned off for Home" in
|
||||
sample 08). Any other key refuses.
|
||||
- The file must be a regular file opened without following a link, owned by
|
||||
the process uid and not writable by group or other, the same checks as the
|
||||
organization file. A missing file means no root agents. Anything else that
|
||||
is wrong refuses the whole root level (section 6).
|
||||
- Refused at the root: a role definition that is version 1 or missing, the
|
||||
`chief-of-staff` definition (each organization's chief is its own,
|
||||
REQ-COS-3), an `assistant` definition under any name but `assistant`, and
|
||||
`credentials`, `holder`, `tracker`, `identity`, `publish`, `hosts`.
|
||||
|
||||
### Organization: `roles`
|
||||
|
||||
Unchanged. Every organization instance stays as it is, validated by
|
||||
`validateBusinessDocument`, with its credential map, tracker bot and identity.
|
||||
|
||||
### Project: `projects.<project>.agents`
|
||||
|
||||
The project level is in the organization file, not in the project repository.
|
||||
The repository's `.mosaic/project.json` can be written by any agent that can
|
||||
push to the project, so a definition there would let an agent create agents.
|
||||
The organization file is reachable only by the trusted writer, and the
|
||||
existing projects fence (`organization-projects-protected` in
|
||||
`organization.mjs`) already refuses every general write that changes
|
||||
`projects`. No agent writer can add, change or remove a project agent. The
|
||||
owner's `updateOrg` can, and the Console's organization editor holds the key
|
||||
on the server (`SERVER_PROJECT_KEYS`), so the browser draft cannot change it in
|
||||
A1.
|
||||
|
||||
```json
|
||||
"projects": {
|
||||
"spring-launch": {
|
||||
"root": "...",
|
||||
"agents": {
|
||||
"social-scheduler": { "definition": "pm", "vars": { "model": "haiku-5.5" } },
|
||||
"coder": { "vars": { "limits.network": "none" } },
|
||||
"reviewer": { "exclude": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each entry has exactly one form:
|
||||
|
||||
- **define**: `{ "definition", "vars"? }`. Agent-layer vars. Not
|
||||
`chief-of-staff`, not `assistant`.
|
||||
- **narrow**: `{ "vars" }` only. Every key must be an intersect key
|
||||
(`limits.tools`, `limits.network`, `limits.authority`), checked at the
|
||||
project layer. A project narrows an inherited agent and never widens it.
|
||||
- **exclude**: `{ "exclude": true }`. The inherited agent is not available in
|
||||
this project.
|
||||
|
||||
`validateBusinessDocument` checks each entry's shape and role definition for
|
||||
every reader (bus, Console, CLI). It does not copy `agents` into the loaded
|
||||
business object, so a bus built from the file sees exactly what it saw before.
|
||||
A project that is deleted takes its agents with it, and so does a deleted
|
||||
organization.
|
||||
|
||||
Library projects (`kind: "library"`) keep their own mapping check and take no
|
||||
`agents` key.
|
||||
|
||||
This is an additive, optional key in the organization file. It is not a bus
|
||||
store (SQLite) schema change: no migration, no new table, no bus verb.
|
||||
|
||||
## 3. Union resolution
|
||||
|
||||
For organization O and project P:
|
||||
|
||||
1. Start with the root agents, minus any whose `excludeOrgs` names O.
|
||||
2. Lay the organization's `roles` over them. An org instance with the same
|
||||
name as a root agent replaces it entirely; the root entry is recorded as an
|
||||
override of that agent.
|
||||
3. Lay P's entries over the result:
|
||||
- **define** replaces any inherited agent of that name; the inherited
|
||||
entry is recorded as an override.
|
||||
- **narrow** merges vars through `mergeVars`, inherited layer first and the
|
||||
project layer after it, so limits only intersect. The keys it narrowed
|
||||
are recorded.
|
||||
- **exclude** removes the agent and records who removed it.
|
||||
|
||||
Rules:
|
||||
|
||||
- The most specific level that defines a name wins, and the whole entry
|
||||
wins: definition and vars, never a field-by-field mix.
|
||||
- A narrow or exclude entry naming an agent that no lower level provides
|
||||
refuses. A typo never silently does nothing.
|
||||
- A project entry of any form naming the organization's chief (an instance
|
||||
whose definition is `chief-of-staff`) or the assistant refuses. A project
|
||||
cannot remove, narrow or replace the chief or the assistant.
|
||||
- Two levels never merge vars, apart from a project narrowing. Root vars stay
|
||||
with the root definition, and an org instance of the same name brings its
|
||||
own.
|
||||
- The result for each agent: name, definition, level, source (which file),
|
||||
the overrides it shadows, the keys narrowed, and its scope. Every agent in a
|
||||
union is scoped to O, an organization's own `assistant` instance included.
|
||||
Only the root assistant, which no union holds, is cross-organization.
|
||||
- The union also lists what O or P does not get: root agents turned off for
|
||||
O and agents P excluded. A name that a more specific level then defines is
|
||||
available under that definition and is not listed.
|
||||
|
||||
Implementation: `packages/organization/src/agent-levels.mjs`
|
||||
(`loadRootAgents`, `checkRootAgents`, `checkProjectAgents`,
|
||||
`resolveAgentLevels`, `projectUnion`). It is pure apart from the one root file
|
||||
read.
|
||||
|
||||
## 4. Isolation of every non-assistant agent
|
||||
|
||||
Every agent except the root assistant resolves to scope `{ org: O }` in the
|
||||
organization it runs in, whichever level defined it. The scope fixes three
|
||||
things:
|
||||
|
||||
- **Memory and records**: under the organization's root,
|
||||
`<dataRoot>/org-runtime/O/...`, on that organization's bus, as every org
|
||||
instance has today. A root agent's copy in O and its copy in O2 share
|
||||
nothing: no thread, no record, no session file.
|
||||
- **Files**: the project workspaces of O only. The sandbox mounts come from
|
||||
O's project mapping, never from another organization's.
|
||||
- **Credentials**: only from O's credential map. A root definition carries
|
||||
none (refused, section 2). In A1 a root or project agent is shown as
|
||||
unbound: it has no credential and cannot be launched, because the bus
|
||||
builds instances from `roles` alone and the loaded business object does not
|
||||
carry project agents.
|
||||
|
||||
When root copies run (slice A3), each copy becomes a bus instance in O under
|
||||
O's writer, with O's credential bindings, through the same `checkRoles` path an
|
||||
org instance takes. A copy never reads the root file at run time; it gets its
|
||||
definition when O's bus boots.
|
||||
|
||||
## 5. The assistant across organizations
|
||||
|
||||
### Reads
|
||||
|
||||
The assistant reads another organization only through the Console's
|
||||
membership-checked cross-organization read: `organizations.members(session)`
|
||||
(`packages/webui/src/organizations.mjs`), the read `GET /api/workspace` uses.
|
||||
Each organization is admitted by `showOrg` (owner check, registry digest,
|
||||
document validation) and `resolveOrg` (marker, modes) before any transport is
|
||||
built, and each bus read goes over that organization's own socket with a
|
||||
deadline. The assistant process gets no org root path, no bus socket path and
|
||||
no file mount of any organization. It calls a read broker that the Console
|
||||
holds for the owner's session. A direct read of another organization's bus
|
||||
files does not exist as a code path: the assistant sandbox mounts only its own
|
||||
memory directory.
|
||||
|
||||
### Shared memory
|
||||
|
||||
The assistant's one memory lives at `<dataRoot>/assistant/` (mode 0700, owned
|
||||
by the instance user). It sits inside the instance data root and outside every
|
||||
`org-runtime/<org>` root, so deleting or archiving an organization never
|
||||
touches it, and no organization's agents can mount it. It holds the
|
||||
assistant's notes and session files. It never holds a copy of an
|
||||
organization's records; what the assistant learns from an organization stays
|
||||
in that organization, plus whatever the assistant chose to write in its own
|
||||
memory.
|
||||
|
||||
### Authority
|
||||
|
||||
REQ-ASSIST-1: the assistant talks with the user and hands each organization's
|
||||
work to that organization's chief. It does not direct agents, create tasks or
|
||||
launch sessions. Its role keeps `message.send` and `record.write` only. The
|
||||
roles proposal (section 8) adds no action to it.
|
||||
|
||||
### A1 reality
|
||||
|
||||
Today each organization file declares its own `assistant` instance. A1 shows
|
||||
those as org agents. A root `assistant` entry, if the user writes one, shows in
|
||||
the band as the root assistant with cross-organization scope. Nothing reads
|
||||
across organizations for it yet.
|
||||
|
||||
## 6. Fail-closed boundaries
|
||||
|
||||
| Boundary | A1 behavior | Later slices |
|
||||
|---|---|---|
|
||||
| Root file (new) | `O_NOFOLLOW` regular file, process uid, no group or other write; any problem refuses the whole root level and the grid says so; a missing file is "no root agents". The refusal names the file and the key at fault, never a value from the file: parser text and a var check's quote of the refused value are dropped, since a value can be a secret typed in the wrong place. Project var refusals drop the value the same way. | The root writer accepts only the human session (section 7). |
|
||||
| Organization file `projects.<p>.agents` (new key) | Checked by `validateBusinessDocument` for every reader; a bad entry refuses the whole organization file, as any bad key does. Not copied into the loaded business, so buses gain nothing. The projects fence keeps every agent writer out. | A project-agent writer verb, with its own guard, like `addProject`. |
|
||||
| Union | A narrow or exclude naming nothing refuses that project; touching the chief or assistant refuses that project; the grid shows the refusal for that project. | A launch resolver refuses to launch any agent in a project whose union refuses. |
|
||||
| Console read `GET /api/agents/levels` (new) | Owner only (granted users and the tester get `owner-only`, 403); organizations come only from `organizations.members(session)`; the body carries ids, names, role definition names, model var and level. It never carries a path, a credential reference or a var other than `model` and the narrowed key names. | Unchanged. |
|
||||
| Org isolation | Root and project agents are not instances on any bus; nothing reads across organizations except the existing Console read. | Each copy is materialized by O's own writer and runs under O's credentials. |
|
||||
| Assistant memory | Not built. | `<dataRoot>/assistant/` with 0700; the sandbox mounts it and nothing else. |
|
||||
| Roles | No change in commits. | The proposal patch, applied only after Jason approves it. |
|
||||
|
||||
A root file that refuses hides the root level everywhere rather than showing a
|
||||
partial list: an exclusion that did not parse must not let an excluded agent
|
||||
appear.
|
||||
|
||||
## 7. Chief-of-staff hire and fire, per level
|
||||
|
||||
Today `roles/chief-of-staff.json` lists `role.hire` and `role.fire` within its
|
||||
role, with `"hireable": []`: it can hire nothing. Each level adds a write path;
|
||||
none exists in A1.
|
||||
|
||||
- **Organization level** (REQ-COS-3): the chief hires or fires an
|
||||
organization instance through the organization's trusted writer, for a role
|
||||
in its `hireable` list. Each hire lands in the action trail. The user can
|
||||
override any setting at any time through REQ-ROLE-3.
|
||||
- **Project level**: the chief adds or removes a project entry through a new
|
||||
writer verb that changes only `projects.<p>.agents` (a guard like
|
||||
`addProject`'s), for a role in `hireable`. The projects fence still refuses
|
||||
every other change.
|
||||
- **Root level** (U0 ruling): the chief cannot write the root file. It raises
|
||||
an inbox decision (REQ-DEC-2) in its own organization that names the role
|
||||
definition, the agent name and the reason. The user approves or denies it in
|
||||
the Console inbox. On approval the Console, acting with the human's session,
|
||||
writes the root file through a root writer that refuses any actor other than
|
||||
the signed-in owner (`human-required`). A denied or expired decision changes
|
||||
nothing. A fire works the same way. The chief never learns the file's path.
|
||||
|
||||
`hireable` decides what can be hired at all; widening it is a `roles/` change
|
||||
and needs Jason's reviewed commit. The proposal (section 8) names the roles.
|
||||
|
||||
## 8. Authority changes proposed, not applied
|
||||
|
||||
The patch is at `/mnt/storage/scratch/mosaic-lead/reports/org-a1-roles-proposal.patch`.
|
||||
It is never applied on this branch. It applies with `git apply` on
|
||||
`origin/refactor` and needs a reviewed commit that Jason approves.
|
||||
|
||||
What it changes:
|
||||
|
||||
- `roles/chief-of-staff.json`: `hireable` goes from `[]` to `coder` and the
|
||||
five advisory chiefs (`chief-executive-officer`, `chief-financial-officer`,
|
||||
`chief-information-officer`, `chief-information-security-officer`,
|
||||
`chief-marketing-officer`). These are the only shipped definitions that pass
|
||||
today's hire check: version 2, not creative, and every action on
|
||||
`HIRE_ALLOWED`. The chief can then hire at the organization level with the
|
||||
existing `hire` verb. The project level (A4) uses the same list.
|
||||
- `roles/chief-of-staff.md`: drops "your empty hireable list", names the list,
|
||||
says a hired coder has no credential until the human binds one, and says the
|
||||
chief hires only in its own organization, never at the root.
|
||||
- `packages/organization/tests/chief-of-staff.test.mjs` and `role.test.mjs`:
|
||||
the tests that pin the shipped chief file move to the new list, and the
|
||||
contract test pins the new sentence. Two bus test comments that said "shipped chief hireable:[]" are
|
||||
reworded. These go in the same commit, or the organization suite fails.
|
||||
|
||||
What it leaves alone, and why:
|
||||
|
||||
- `roles/assistant.json`: no change. The cross-organization read is the
|
||||
Console's read broker acting for the owner's session (section 5), not a
|
||||
role action, so the assistant needs no new grant. It keeps `message.send`
|
||||
and `record.write`. Adding an action would widen it for no gain.
|
||||
- `roles/assistant.md`: no change yet. Its "one user in one business" text
|
||||
stays true until A6 builds the root assistant; the contract text for the
|
||||
cross-organization read and the shared memory lands with A6, in that
|
||||
slice's own reviewed commit.
|
||||
- Root-level hire text in `chief-of-staff.md` lands with A5, when the inbox
|
||||
decision exists. A contract sentence for a tool the chief doesn't have would
|
||||
invite it to try.
|
||||
- `HIRE_ALLOWED`: unchanged. `reviewer`, `security-reviewer` (`review.verdict`),
|
||||
`pm` (`task.create`), `cto` and `merge-gate` stay unhireable. Making any of
|
||||
them hireable is a separate vocabulary decision, not part of this proposal.
|
||||
|
||||
## 9. Phase A slices after the roles change is approved
|
||||
|
||||
1. **A2, root and project writers.** Owner-only root file writer (human
|
||||
session, trail entry, revision check) and the project-agent writer verb
|
||||
under the projects fence, both refusing every agent actor. Settings,
|
||||
Agents gets the edit forms.
|
||||
2. **A3, root copies run.** Each organization's bus boot materializes its
|
||||
root copies as instances through `checkRoles`, with the organization's
|
||||
credential bindings; unbound copies stay unlaunchable and say why. Project
|
||||
agents launch only in their project.
|
||||
3. **A4, chief hire and fire at org and project level**, bounded by the
|
||||
approved `hireable` list, trailed.
|
||||
4. **A5, root-level hire and fire as inbox decisions**, with the owner's
|
||||
approval writing the root file through A2's writer.
|
||||
5. **A6, the root assistant.** `<dataRoot>/assistant/` memory, the Console
|
||||
read broker over `organizations.members`, and the per-organization
|
||||
`assistant` instances retired in favor of the one root assistant.
|
||||
6. **A7, the Agents grid's write controls** (model Change, override markers,
|
||||
turn off for an organization), each a trailed human action.
|
||||
|
||||
## 10. Slice A1 as built
|
||||
|
||||
- **Store and union**: `packages/organization/src/agent-levels.mjs`, with the
|
||||
old-path re-export `packages/business/src/agent-levels.mjs` that the rename
|
||||
r2 stage B test requires of every organization module.
|
||||
`validateBusinessDocument` checks `projects.<p>.agents` and drops it from
|
||||
the loaded business.
|
||||
- **Read**: `GET /api/agents/levels` (`packages/webui/src/agent-levels-read.mjs`),
|
||||
wired in `cli.mjs` with `loadRoot` reading `<configDir>/agents.json`. The
|
||||
route is instance-wide, like `/api/workspace`, so `display.js` leaves it
|
||||
unscoped: its fetch wrapper would otherwise rewrite it to
|
||||
`/api/orgs/<id>/agents/levels`, which does not exist.
|
||||
- **Console editor**: `SERVER_PROJECT_KEYS` holds `agents`, so a browser draft
|
||||
of the organization file keeps the server's value. The CLI's full-document
|
||||
path (`mosaic org update --from`) is the owner's and can set project agents;
|
||||
A1 adds no other writer.
|
||||
- **Agents grid** (sample 08): the level band shows the root agents and their
|
||||
per-organization opt-outs, each organization's instances, and each
|
||||
project's own definitions. Below it, a project picker chooses which union
|
||||
the table shows: agent, level, defined in, memory, status, model. Root and
|
||||
project agents read "not running", because nothing launches them yet. The
|
||||
root assistant's status reads "status not reported" and its model is the
|
||||
one the root file sets, shown as configured. A
|
||||
narrowed agent names the keys, an override names what it replaces, and a
|
||||
line under the table lists what this project does not get and why. A
|
||||
refused root file, organization or project shows its reason in place of
|
||||
its rows. The grid has no write controls; those are A7.
|
||||
@@ -1117,3 +1117,14 @@ For lifecycle tasks, the first review request includes the exact raw JSON
|
||||
verification-document and verdict-body contract; unrelated reviews retain their
|
||||
existing free-text contract. The typed record tools describe the existing closed
|
||||
requirement schema without relaxing host validation.
|
||||
|
||||
## Built-in task store writes
|
||||
|
||||
The outside-agent human session can call `tasks.create` with `title` and a `requirement` ID
|
||||
(such as `REQ-TASK-1`), plus optional task fields. `tasks.edit` accepts `task_ref` and the
|
||||
fields to change, including `requirement`; archive, unarchive, and delete take `task_ref`.
|
||||
Each write records `human.input`. A task read returns a `digest` that can be supplied as
|
||||
`expect` to a later write; any intervening revision, including archive or unarchive, refuses
|
||||
`task-conflict`. Existing assignees may remain after a role is removed, but new assignees
|
||||
must be current roles. Automatic tail placement and an explicitly occupied position refuse
|
||||
`task-position-full` when there is no room; no task row is written.
|
||||
@@ -655,3 +655,44 @@ CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_t
|
||||
CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types
|
||||
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END;
|
||||
-- U4a (#1873): the task store built into each organization, the default task-tracker module
|
||||
-- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row
|
||||
-- and the rows are its history: who wrote it (a role and run, or the person), when, under which
|
||||
-- verb, and the event that records the write. Task numbers count up per organization and are
|
||||
-- never reused. A delete is a last row: the task leaves every read and its history stays until the
|
||||
-- organization is removed, which removes this whole database with it. Only a new append-only
|
||||
-- table, its index and its guards; no existing row changes and nothing is deleted.
|
||||
CREATE TABLE store_tasks (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
task INTEGER NOT NULL CHECK (task >= 1),
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
op TEXT NOT NULL CHECK (op IN ('create','change','archive','unarchive','delete')),
|
||||
verb TEXT NOT NULL,
|
||||
by_role TEXT,
|
||||
by_run TEXT,
|
||||
by_human TEXT,
|
||||
event TEXT NOT NULL REFERENCES events(id),
|
||||
fields TEXT NOT NULL CHECK (json_valid(fields) AND json_type(fields) = 'object'),
|
||||
CHECK ((by_human IS NULL) = (by_role IS NOT NULL AND by_run IS NOT NULL)),
|
||||
UNIQUE (business, task, revision)
|
||||
) STRICT;
|
||||
CREATE INDEX store_tasks_task ON store_tasks (business, task, seq);
|
||||
CREATE TRIGGER store_tasks_no_update BEFORE UPDATE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
|
||||
CREATE TRIGGER store_tasks_no_delete BEFORE DELETE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
|
||||
CREATE TRIGGER store_tasks_no_replace BEFORE INSERT ON store_tasks WHEN EXISTS (SELECT 1 FROM store_tasks WHERE seq = NEW.seq OR (business = NEW.business AND task = NEW.task AND revision = NEW.revision)) BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
|
||||
CREATE TRIGGER store_tasks_revision BEFORE INSERT ON store_tasks
|
||||
WHEN NEW.revision IS NOT COALESCE((SELECT MAX(revision) FROM store_tasks WHERE business = NEW.business AND task = NEW.task), 0) + 1
|
||||
BEGIN SELECT RAISE(ABORT, 'a store task revision follows the last one'); END;
|
||||
CREATE TRIGGER store_tasks_create BEFORE INSERT ON store_tasks
|
||||
WHEN (NEW.op = 'create') IS NOT (NEW.revision = 1)
|
||||
OR (NEW.op = 'create' AND NEW.task IS NOT COALESCE((SELECT MAX(task) FROM store_tasks WHERE business = NEW.business), 0) + 1)
|
||||
BEGIN SELECT RAISE(ABORT, 'a store task is created once, with the next number'); END;
|
||||
CREATE TRIGGER store_tasks_deleted BEFORE INSERT ON store_tasks
|
||||
WHEN EXISTS (SELECT 1 FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op = 'delete')
|
||||
BEGIN SELECT RAISE(ABORT, 'a deleted store task takes no more rows'); END;
|
||||
CREATE TRIGGER store_tasks_archive BEFORE INSERT ON store_tasks
|
||||
WHEN NEW.op IN ('archive','unarchive')
|
||||
AND (COALESCE((SELECT op FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op IN ('archive','unarchive') ORDER BY seq DESC LIMIT 1), 'unarchive') = 'archive') = (NEW.op = 'archive')
|
||||
BEGIN SELECT RAISE(ABORT, 'a store task archive row changes state'); END;
|
||||
+379
-11
@@ -95,7 +95,8 @@ export const REPLIES = 50;
|
||||
// Row 80 (#1574): how many stranded messages `agents` lists for each role, oldest first.
|
||||
export const STRANDED = 50;
|
||||
// Row 89 (#1583): the arguments `tasks` takes, and its page size by default and at most.
|
||||
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot'];
|
||||
// U4a (#1873): `archived` reads the built-in store's archived tasks; a Vikunja task is never archived.
|
||||
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot', 'archived'];
|
||||
export const TASK_PAGE = 50;
|
||||
export const TASK_PAGE_MAX = 200;
|
||||
// D3 (#797): raw UTF-8 pre-bound for the approved requirement texts quoted into one coder/reviewer
|
||||
@@ -295,6 +296,105 @@ function identifier(x) {
|
||||
return x;
|
||||
}
|
||||
const taskRef = (x) => typeof x === 'string' && /^vikunja:[1-9][0-9]*\/[1-9][0-9]*$/.test(x);
|
||||
// U4a (#1873, REQ-TASK-1): the task store built into each organization, the default task-tracker
|
||||
// module. Its rows live in the organization's own bus.sqlite (store_tasks, migration 3u-3v). These
|
||||
// shapes are exported for packages/tasks/src/store.mjs, the module, so the broker and the module
|
||||
// share one definition; they stay in this file because its import list is the sandbox's closure.
|
||||
// A task's fields, whole after every write:
|
||||
// title, description text, as the Vikunja verbs take them
|
||||
// due_date canonical ISO 8601 UTC with milliseconds, or null
|
||||
// priority 0 (unset) to 5
|
||||
// percent_done 0 to 1
|
||||
// state todo, in-progress, in-review, blocked or done
|
||||
// bucket, done what state implies, under the names the Console and `mosaic tasks` read
|
||||
// labels positive label ids, sorted, at most 50
|
||||
// assignees role instance names, sorted, at most 50
|
||||
// group null or a short name: the column or list the task sits in
|
||||
// position a whole number ordering tasks inside their group
|
||||
// comments, comment how many comments, and the latest ({ text, by })
|
||||
// verdict the review verdict a close cited
|
||||
// request, op what a role's create cited; null on a person's
|
||||
// requirement the requirement cited on create (or edited by a person)
|
||||
export const STORE_STATES = Object.freeze(['todo', 'in-progress', 'in-review', 'blocked', 'done']);
|
||||
// The states task.update.assigned moves between; done is task.close's (slice 1 field table).
|
||||
export const ASSIGNED_STATES = Object.freeze(STORE_STATES.filter((x) => x !== 'done'));
|
||||
// A built-in task's ref is its number in the organization, never reused.
|
||||
const STORE_REF = /^task:([1-9][0-9]{0,14})$/;
|
||||
export const storeRef = (x) => typeof x === 'string' && STORE_REF.test(x);
|
||||
export const storeTaskNumber = (x) => (storeRef(x) ? Number(STORE_REF.exec(x)[1]) : null);
|
||||
export const STORE_POSITION_MAX = 1_000_000_000;
|
||||
// What a person's tasks.create and tasks.edit may set. Roles write through the eight task verbs,
|
||||
// each held to the fields the field table gives it.
|
||||
export const STORE_EDIT_FIELDS = Object.freeze(['title', 'description', 'due_date', 'priority', 'percent_done', 'state', 'labels', 'assignees', 'group', 'position', 'requirement']);
|
||||
const isoMillis = (x) =>
|
||||
typeof x === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) && Number.isFinite(Date.parse(x)) && new Date(x).toISOString() === x;
|
||||
// One check per field: the raw value in, the stored value out, or a refusal.
|
||||
export function storeFieldChecks(roles) {
|
||||
const str = (x, max, min = 0) => {
|
||||
if (typeof x !== 'string' || x.length < min || x.length > max || x.includes('\0')) fail('invalid-request');
|
||||
return x;
|
||||
};
|
||||
const set = (x, item, order) => {
|
||||
if (!Array.isArray(x) || x.length > 50) fail('invalid-request');
|
||||
return [...new Set(x.map(item))].sort(order);
|
||||
};
|
||||
return {
|
||||
title: (x) => (str(x, 250, 1).trim() ? x : fail('invalid-request')),
|
||||
description: (x) => str(x, 65536),
|
||||
due_date: (x) => (x === null || isoMillis(x) ? x : fail('invalid-request')),
|
||||
priority: (x) => (Number.isSafeInteger(x) && x >= 0 && x <= 5 ? x : fail('invalid-request')),
|
||||
percent_done: (x) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1 ? x : fail('invalid-request')),
|
||||
state: (x) => (STORE_STATES.includes(x) ? x : fail('invalid-state')),
|
||||
labels: (x) => set(x, (l) => (Number.isSafeInteger(l) && l >= 1 ? l : fail('invalid-request')), (a, b) => a - b),
|
||||
assignees: (x) => set(x, (r) => (typeof r === 'string' && Object.hasOwn(roles, r) ? r : fail('unknown-role'))),
|
||||
group: (x) => (x === null ? null : str(x, 64, 1).trim() && !/[\u0000-\u001f\u007f]/.test(x) ? x : fail('invalid-request')),
|
||||
position: (x) => (Number.isSafeInteger(x) && x >= 0 && x <= STORE_POSITION_MAX ? x : fail('invalid-request')),
|
||||
requirement: (x) => (typeof x === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(x) ? x : fail('invalid-request')),
|
||||
};
|
||||
}
|
||||
// Checked edits on a copy of the fields. A state sets the bucket and done it implies. A move to
|
||||
// another group with no position goes to the end of that group (a null position, which the write settles).
|
||||
export function storeApply(fields, edits) {
|
||||
const out = { ...fields, ...edits };
|
||||
if (Object.hasOwn(edits, 'state')) Object.assign(out, { bucket: edits.state, done: edits.state === 'done' });
|
||||
if (Object.hasOwn(edits, 'group') && !Object.hasOwn(edits, 'position') && edits.group !== fields.group) out.position = null;
|
||||
return out;
|
||||
}
|
||||
// A new task's fields. A null position means after the last task in its group; the write settles it.
|
||||
export function storeNewTask({ title, request = null, requirement = null, op = null }) {
|
||||
return {
|
||||
title, description: '', due_date: null, priority: 0, percent_done: 0, state: 'todo', bucket: 'todo', done: false,
|
||||
labels: [], assignees: [], group: null, position: null, comments: 0, comment: null, verdict: null, request, requirement, op,
|
||||
};
|
||||
}
|
||||
// Every field a stored task has, no more and no fewer.
|
||||
export const STORE_FIELDS = Object.freeze(Object.keys(storeNewTask({ title: 'x' })));
|
||||
// The fields each role verb may change: the slice 1 field table (packages/tasks/fields.json) on the
|
||||
// store's fields. The table's labels and due date go with task.schedule, so the store's group and
|
||||
// position do too; a comment's count goes with the latest comment; a close records its verdict; and
|
||||
// a create sets what it cited. The broker refuses a role's write that changes any other field
|
||||
// (field-writer), whatever the module asked for.
|
||||
export const STORE_VERB_FIELDS = Object.freeze({
|
||||
'task.create': Object.freeze(['title', 'description', 'due_date', 'priority', 'labels', 'group', 'position', 'request', 'requirement', 'op']),
|
||||
'task.assign': Object.freeze(['assignees']),
|
||||
'task.reassign': Object.freeze(['assignees']),
|
||||
'task.schedule': Object.freeze(['due_date', 'labels', 'group', 'position']),
|
||||
'task.priority.change': Object.freeze(['priority']),
|
||||
'task.scope.change': Object.freeze(['title', 'description']),
|
||||
'task.update.assigned': Object.freeze(['percent_done', 'state', 'bucket', 'comments', 'comment']),
|
||||
'task.close': Object.freeze(['state', 'bucket', 'done', 'verdict']),
|
||||
});
|
||||
// Gaps between settled positions, so a person can place a task between two others.
|
||||
export const STORE_POSITION_STEP = 1024;
|
||||
// The most one task's fields take, as JSON.
|
||||
const STORE_TASK_BYTES = 262144;
|
||||
// Board order: ungrouped first, then group name, position and task number. Every read sorts this way.
|
||||
export function storeOrder(a, b) {
|
||||
const ga = a.group ?? '', gb = b.group ?? '';
|
||||
if (ga !== gb) return ga < gb ? -1 : 1;
|
||||
if ((a.position ?? 0) !== (b.position ?? 0)) return (a.position ?? 0) - (b.position ?? 0);
|
||||
return a.task - b.task;
|
||||
}
|
||||
// A git commit id, SHA-1 or SHA-256.
|
||||
const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x);
|
||||
// Row 123 (#1617): the one list of organization-file keys limits.set and organization.set accept.
|
||||
@@ -544,8 +644,9 @@ export class Broker {
|
||||
#accessRevoked = new Set();
|
||||
#clock = 0;
|
||||
#processing;
|
||||
#taskTracker;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
|
||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||
@@ -555,6 +656,7 @@ export class Broker {
|
||||
if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function');
|
||||
if (accessStamp !== null && typeof accessStamp !== 'function') throw new TypeError('accessStamp must be a function');
|
||||
if (compactions !== null && typeof compactions !== 'function') throw new TypeError('compactions must be a function');
|
||||
if (taskTracker !== null && typeof taskTracker !== 'function') throw new TypeError('taskTracker must be a function');
|
||||
this.#businesses = structuredClone(businesses);
|
||||
this.#store = store;
|
||||
// A Broker is a trusted host startup, including in-process hosts. Never repair an existing state as off.
|
||||
@@ -596,6 +698,11 @@ export class Broker {
|
||||
// Row 194 (#1692, D2.3): roleFile(name) loads role definition `name` from the roles directory as it
|
||||
// stands, for the hire checks. Without it a hire refuses not-configured.
|
||||
this.#roleFile = roleFile;
|
||||
// U4a (#1873): taskTracker(business) names the task-tracker module the bus runtime chose for the
|
||||
// business at start. The store's writes refuse tracker-not-store under any other module, so a
|
||||
// Vikunja organization never grows a second task list. Without it (the unit tests' brokers)
|
||||
// every business uses the store.
|
||||
this.#taskTracker = taskTracker ?? (() => 'store');
|
||||
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
|
||||
this.#requirements = loadRequirements();
|
||||
// These are host capabilities, like project/library; no filesystem module enters the client closure.
|
||||
@@ -1104,6 +1211,12 @@ export class Broker {
|
||||
this.#businesses[name] = next;
|
||||
this.#scheduleRequests();
|
||||
}
|
||||
// Host-only current role resolver shared by the broker and built-in task tracker.
|
||||
taskRoles(business) {
|
||||
const roles = this.#business(business).roles;
|
||||
if (!object(roles)) fail('invalid-business');
|
||||
return structuredClone(roles);
|
||||
}
|
||||
// Lifecycle Task 2 (#797): the chat project a bootstrapped product lists under. Host-only, so no verb
|
||||
// reaches it. State is 'free', 'associated' (this exact id, name and product) or 'conflict' (anything else
|
||||
// holding the id or the name), which lets a retry insert the row once.
|
||||
@@ -4632,11 +4745,16 @@ export class Broker {
|
||||
// the short form in task_ref order: no description, and the assignees that are a role's tracker
|
||||
// bot, by role. A tombstone isn't on the board, so a filtered read leaves it out. `total` counts
|
||||
// every task the filters match; `next` is the `after` for the next page, null on the last.
|
||||
// U4a (#1873, REQ-WS-8): the store's tasks come after the Vikunja rows, in board order, in the same
|
||||
// shape (task_ref, fields, digest, priority, requirement, at), with source 'store'. An archived store
|
||||
// task leaves the no-argument read and the filtered read unless `archived: true` asks for archived
|
||||
// tasks; `task_ref` alone still gives it, with archived true.
|
||||
#tasks(business, a) {
|
||||
const rows = this.#taskRows(business);
|
||||
if (!Object.keys(a).length) return rows;
|
||||
const rows = this.#taskRows(business), store = this.#storeRows(business);
|
||||
if (!Object.keys(a).length) return [...rows, ...store.filter((r) => !r.archived)];
|
||||
if ('task_ref' in a) {
|
||||
if (Object.keys(a).length !== 1 || !taskRef(a.task_ref)) fail('invalid-request');
|
||||
if (Object.keys(a).length !== 1 || !(taskRef(a.task_ref) || storeRef(a.task_ref))) fail('invalid-request');
|
||||
if (storeRef(a.task_ref)) return this.#storeCurrent(business, a.task_ref);
|
||||
return rows.find((r) => r.task_ref === a.task_ref) ?? fail('task-not-found');
|
||||
}
|
||||
const roles = this.#business(business).roles ?? {};
|
||||
@@ -4649,7 +4767,8 @@ export class Broker {
|
||||
('project_key' in a && !projectKey(a.project_key)) ||
|
||||
('requirement_snapshot' in a && !id(a.requirement_snapshot)) ||
|
||||
('limit' in a && !positive(a.limit, TASK_PAGE_MAX)) ||
|
||||
('after' in a && !taskRef(a.after))
|
||||
('archived' in a && typeof a.archived !== 'boolean') ||
|
||||
('after' in a && !(taskRef(a.after) || storeRef(a.after)))
|
||||
)
|
||||
fail('invalid-request');
|
||||
const bots = new Map(
|
||||
@@ -4680,10 +4799,209 @@ export class Broker {
|
||||
(!('project_key' in a) || t.project_key === a.project_key) &&
|
||||
(!('requirement_snapshot' in a) || t.requirement_snapshot === a.requirement_snapshot),
|
||||
);
|
||||
const rest = 'after' in a ? matched.filter((t) => t.task_ref > a.after) : matched;
|
||||
// The store's short form adds its group, position and archived flag; its assignees are role names.
|
||||
const archived = a.archived ?? false;
|
||||
const filed = store
|
||||
.filter((r) => r.archived === archived)
|
||||
.map(({ task_ref, fields: f, priority, requirement, at, archived: gone }) => ({
|
||||
task_ref,
|
||||
title: f.title,
|
||||
done: f.done,
|
||||
bucket: f.bucket,
|
||||
assignees: [...f.assignees],
|
||||
labels: [...f.labels],
|
||||
priority,
|
||||
requirement,
|
||||
changed_at: at,
|
||||
group: f.group,
|
||||
position: f.position,
|
||||
archived: gone,
|
||||
}))
|
||||
.filter(
|
||||
(t) =>
|
||||
(!('done' in a) || t.done === a.done) &&
|
||||
(!('assignee' in a) || t.assignees.includes(a.assignee)) &&
|
||||
(!('label' in a) || t.labels.includes(a.label)) &&
|
||||
(!('requirement' in a) || t.requirement === a.requirement) &&
|
||||
!('project_key' in a) &&
|
||||
!('requirement_snapshot' in a),
|
||||
);
|
||||
// Archived tasks are store tasks only. Pages run through the Vikunja tasks, then the store's: a
|
||||
// store `after` skips every Vikunja task and the store tasks up to it in board order. A cursor whose
|
||||
// task moved since continues from where it now stands; one whose task was deleted reads its last row.
|
||||
const all = [...(archived ? [] : matched), ...filed];
|
||||
let rest = all;
|
||||
if (taskRef(a.after ?? null)) rest = all.filter((t) => !taskRef(t.task_ref) || t.task_ref > a.after);
|
||||
else if ('after' in a) {
|
||||
const n = storeTaskNumber(a.after);
|
||||
const last = this.#store.get('SELECT fields FROM store_tasks WHERE business=? AND task=? ORDER BY seq DESC LIMIT 1', business, n);
|
||||
if (!last) fail('task-not-found');
|
||||
const f = JSON.parse(last.fields), at = { group: f.group, position: f.position, task: n };
|
||||
rest = filed.filter((t) => storeOrder({ group: t.group, position: t.position, task: storeTaskNumber(t.task_ref) }, at) > 0);
|
||||
}
|
||||
const limit = a.limit ?? TASK_PAGE;
|
||||
const tasks = rest.slice(0, limit);
|
||||
return { tasks, total: matched.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
|
||||
return { tasks, total: all.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
|
||||
}
|
||||
// U4a (#1873, REQ-TASK-1): the built-in task store. A task is its latest store_tasks row, and it is
|
||||
// archived when its latest archive or unarchive row is an archive. A deleted task's latest row is
|
||||
// its delete, so it leaves every read here. Every read builds from the rows; nothing is cached.
|
||||
#storeLatest(business, task = null) {
|
||||
return this.#store
|
||||
.all(
|
||||
`SELECT t.*, (SELECT a.op FROM store_tasks a WHERE a.business=t.business AND a.task=t.task AND a.op IN ('archive','unarchive') ORDER BY a.seq DESC LIMIT 1) AS archive
|
||||
FROM store_tasks t WHERE t.business=?${task === null ? '' : ' AND t.task=?'}
|
||||
AND t.seq=(SELECT max(seq) FROM store_tasks WHERE business=t.business AND task=t.task) AND t.op<>'delete' ORDER BY t.task`,
|
||||
...(task === null ? [business] : [business, task]),
|
||||
)
|
||||
.map((r) => {
|
||||
const fields = JSON.parse(r.fields);
|
||||
// The keys a Vikunja `tasks` row has that the Console and `mosaic tasks` read (task_ref,
|
||||
// fields, digest, priority, requirement, at, seq, source), with source 'store', and the
|
||||
// store's own: revision, the verb that wrote it, who (role and run, or the person), the event.
|
||||
return {
|
||||
seq: r.seq, at: r.at, business: r.business, task_ref: `task:${r.task}`, tracker: 'store', source: 'store',
|
||||
updated: r.at, revision: r.revision, verb: r.verb, role: r.by_role, run: r.by_run, human: r.by_human, event: r.event,
|
||||
digest: createHash('sha256').update(JSON.stringify([r.revision, r.fields])).digest('hex'), fields,
|
||||
priority: fields.priority ?? null, requirement: fields.requirement ?? null, archived: r.archive === 'archive',
|
||||
};
|
||||
});
|
||||
}
|
||||
// The live tasks, archived ones too, in board order.
|
||||
#storeRows(business) {
|
||||
const at = (r) => ({ group: r.fields.group, position: r.fields.position, task: storeTaskNumber(r.task_ref) });
|
||||
return this.#storeLatest(business).sort((a, b) => storeOrder(at(a), at(b)));
|
||||
}
|
||||
#storeOn(business) {
|
||||
this.#business(business);
|
||||
if (this.#taskTracker(business) !== 'store') fail('tracker-not-store');
|
||||
}
|
||||
#storeCurrent(business, ref) {
|
||||
const n = storeTaskNumber(ref);
|
||||
if (n === null) fail('invalid-request');
|
||||
return this.#storeLatest(business, n)[0] ?? fail('task-not-found');
|
||||
}
|
||||
// A role's create cites the person's instruction that asked for it, as a Vikunja create does.
|
||||
#storeRequest(business, request) {
|
||||
return (
|
||||
typeof request === 'string' &&
|
||||
!!this.#store.get(
|
||||
"SELECT 1 FROM events WHERE business=? AND id=? AND kind='human.input' AND actor_role IS NULL AND actor_run IS NULL AND json_extract(body,'$.kind')='instruction'",
|
||||
business,
|
||||
request,
|
||||
)
|
||||
);
|
||||
}
|
||||
// The whole task a write leaves: every field the store keeps and no other, each already in the form
|
||||
// its check gives (labels and assignees sorted, no repeats), and the bucket and done its state implies.
|
||||
#storeFields(business, f, previous) {
|
||||
if (!object(f) || Object.keys(f).length !== STORE_FIELDS.length || !STORE_FIELDS.every((k) => Object.hasOwn(f, k))) fail('invalid-request');
|
||||
const roles = { ...this.taskRoles(business), ...Object.fromEntries((previous?.assignees ?? []).map(role => [role, true])) };
|
||||
const check = storeFieldChecks(roles);
|
||||
for (const k of STORE_EDIT_FIELDS) {
|
||||
if (k === 'requirement' && f[k] === null) continue; // historical tasks
|
||||
if (JSON.stringify(check[k](f[k])) !== JSON.stringify(f[k])) fail('invalid-request');
|
||||
}
|
||||
const text = (x, max) => x === null || (typeof x === 'string' && x.length >= 1 && x.length <= max && !x.includes('\0'));
|
||||
if (
|
||||
f.bucket !== f.state ||
|
||||
f.done !== (f.state === 'done') ||
|
||||
!(Number.isSafeInteger(f.comments) && f.comments >= 0) ||
|
||||
!(f.comment === null || (object(f.comment) && Object.keys(f.comment).length === 2 && text(f.comment.text, 10000) && f.comment.text !== null && Object.hasOwn(f.comment, 'by') && text(f.comment.by, 128) && f.comment.by !== null)) ||
|
||||
!text(f.verdict, 256) ||
|
||||
!text(f.request, 64) ||
|
||||
!(f.requirement === null || (typeof f.requirement === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(f.requirement))) ||
|
||||
!text(f.op, 256)
|
||||
)
|
||||
fail('invalid-request');
|
||||
}
|
||||
// Appends one row and the event that records it, and answers the task as it now reads (or that it
|
||||
// was deleted). The caller owns the transaction and has checked who may write. A null position
|
||||
// settles after the last task in the task's group.
|
||||
#storeWrite(s, { current, op, verb, fields, by, kind, body }) {
|
||||
const task = current
|
||||
? storeTaskNumber(current.task_ref)
|
||||
: (this.#store.get('SELECT max(task) AS n FROM store_tasks WHERE business=?', s.business).n ?? 0) + 1;
|
||||
const ref = `task:${task}`;
|
||||
const peers = this.#storeRows(s.business).filter((r) => r.task_ref !== ref && (r.fields.group ?? null) === (fields.group ?? null));
|
||||
if (fields.position === null) {
|
||||
const last = peers.reduce((m, r) => Math.max(m, r.fields.position ?? 0), -STORE_POSITION_STEP);
|
||||
if (last + STORE_POSITION_STEP > STORE_POSITION_MAX) fail('task-position-full');
|
||||
fields = { ...fields, position: last + STORE_POSITION_STEP };
|
||||
} else if ((!current || fields.group !== current.fields.group || fields.position !== current.fields.position) && peers.some(r => r.fields.position === fields.position)) {
|
||||
fail('task-position-full');
|
||||
}
|
||||
this.#storeFields(s.business, fields, current?.fields);
|
||||
const json = JSON.stringify(fields);
|
||||
if (Buffer.byteLength(json) > STORE_TASK_BYTES) fail('invalid-request');
|
||||
this.#secretCheck(fields);
|
||||
const event = this.#event(s, kind, { ...body, task_ref: ref, tracker: 'store', op, verb }, ref);
|
||||
this.#store.run(
|
||||
'INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
this.#now(),
|
||||
s.business,
|
||||
task,
|
||||
current ? current.revision + 1 : 1,
|
||||
op,
|
||||
verb,
|
||||
by.role,
|
||||
by.run,
|
||||
by.human,
|
||||
event,
|
||||
json,
|
||||
);
|
||||
return op === 'delete' ? { task_ref: ref, deleted: true, event } : this.#storeCurrent(s.business, ref);
|
||||
}
|
||||
// U4a: the store module's one write path for a role, as authorize() and recordTask() are the
|
||||
// Vikunja adapter's. One transaction holds the holder check, the authority (a decision is spent here
|
||||
// as #consumeAuthority spends it), a close's verdict check, the event and the row. `change` is the
|
||||
// module's: it gets a copy of the task's fields (a new task's on a create) and of its row (null on a
|
||||
// create) and returns the whole new fields. The write may change only what STORE_VERB_FIELDS gives
|
||||
// its verb. An archived task takes no role write; a person unarchives it first. A create that names
|
||||
// an `op` the same role already created with answers that task again, with nothing written; the same
|
||||
// op with other fields, or from another role, refuses (task-create-conflict).
|
||||
storeTask(cap, { verb, task_ref = null, decision = null, expect = null, verdict = null, change } = {}) {
|
||||
const s = this.#session(cap);
|
||||
if (!TASK_VERBS.includes(verb) || typeof change !== 'function' || (verb === 'task.create') !== (task_ref === null)) fail('invalid-request');
|
||||
return this.#store.transaction(() => {
|
||||
this.#agent(s);
|
||||
this.#storeOn(s.business);
|
||||
const current = task_ref === null ? null : this.#storeCurrent(s.business, task_ref);
|
||||
if (current?.archived) fail('task-archived');
|
||||
if (expect !== null && current && expect !== current.digest) fail('task-conflict');
|
||||
const before = current?.fields ?? storeNewTask({ title: null }), allowed = STORE_VERB_FIELDS[verb];
|
||||
const fields = change(structuredClone(before), current && structuredClone(current));
|
||||
if (!object(fields)) fail('invalid-request');
|
||||
for (const k of new Set([...Object.keys(before), ...Object.keys(fields)]))
|
||||
if (JSON.stringify(before[k]) !== JSON.stringify(fields[k]) && !allowed.includes(k)) fail('field-writer');
|
||||
if (!current && !this.#storeRequest(s.business, fields.request)) fail('request-not-found');
|
||||
if (!current && typeof fields.op === 'string') {
|
||||
const old = this.#store.get(
|
||||
"SELECT task, by_role, fields FROM store_tasks WHERE business=? AND op='create' AND json_extract(fields,'$.op')=? ORDER BY seq LIMIT 1",
|
||||
s.business,
|
||||
fields.op,
|
||||
);
|
||||
if (old) {
|
||||
const was = JSON.parse(old.fields), same = (k) => JSON.stringify(was[k]) === JSON.stringify(fields[k]);
|
||||
if (old.by_role !== s.role || !STORE_FIELDS.every((k) => same(k) || (k === 'position' && fields.position === null))) fail('task-create-conflict');
|
||||
this.#checkAuthority(s, verb, { decision, target: null });
|
||||
const ref = `task:${old.task}`;
|
||||
return this.#storeLatest(s.business, old.task)[0] ?? { task_ref: ref, deleted: true };
|
||||
}
|
||||
}
|
||||
const authority = this.#checkAuthority(s, verb, { decision, target: task_ref });
|
||||
if (authority.decision && this.#spent(s.business, authority.decision)) fail('decision-consumed');
|
||||
if (verb === 'task.close') this.#closeVerdict(s, task_ref, verdict);
|
||||
return this.#storeWrite(s, {
|
||||
current,
|
||||
op: current ? 'change' : 'create',
|
||||
verb,
|
||||
fields,
|
||||
by: { role: s.role, run: s.run, human: null },
|
||||
kind: 'action.allowed',
|
||||
body: { action: verb, ...authority, target: task_ref, ...(verb === 'task.close' ? { verdict } : {}) },
|
||||
});
|
||||
});
|
||||
}
|
||||
#checkAuthority(s, action, context = {}) {
|
||||
this.#processingWork();
|
||||
@@ -5460,16 +5778,18 @@ export class Broker {
|
||||
}
|
||||
#refused(s, e, request, other = 'storage-refused') {
|
||||
const error = e instanceof BusError ? e : new BusError(other);
|
||||
// No request content or raw exception text in refusal evidence.
|
||||
// No request content or raw exception text in refusal evidence. U4a: a built-in store task's
|
||||
// ref files the refusal on that task's trail too.
|
||||
const subject = (x) => taskRef(x) || storeRef(x);
|
||||
try {
|
||||
this.#store.transaction(() =>
|
||||
this.#event(
|
||||
s,
|
||||
'action.refused',
|
||||
{ code: error.code },
|
||||
taskRef(request?.args?.task_ref)
|
||||
subject(request?.args?.task_ref)
|
||||
? request.args.task_ref
|
||||
: taskRef(request?.args?.target)
|
||||
: subject(request?.args?.target)
|
||||
? request.args.target
|
||||
: null,
|
||||
),
|
||||
@@ -7611,6 +7931,41 @@ export class Broker {
|
||||
// human gate is project.create's minus the tester carve-out: tester, user and agent sessions
|
||||
// are refused here. Each verb appends one project_archives row (the audit trail) and deletes
|
||||
// nothing; a repeat of the state the project already holds refuses.
|
||||
// U4a (#1873, REQ-TASK-1): the person's own writes to the built-in task store, from the terminal
|
||||
// (human-cli). Each writes a human.input event (body kind 'task') that its store_tasks row cites, so a
|
||||
// person's edit comes in as an event like any other input. The person writes any field, may reopen a
|
||||
// done task, and is the only one who archives, unarchives or deletes. `expect` is the digest the
|
||||
// person last read; a later write refuses task-conflict.
|
||||
case 'tasks.create':
|
||||
case 'tasks.edit':
|
||||
case 'tasks.archive':
|
||||
case 'tasks.unarchive':
|
||||
case 'tasks.delete': {
|
||||
this.#human(s);
|
||||
this.#storeOn(s.business);
|
||||
const creating = verb === 'tasks.create', editing = verb === 'tasks.edit';
|
||||
keys(a, creating ? STORE_EDIT_FIELDS : editing ? ['task_ref', 'expect', ...STORE_EDIT_FIELDS] : ['task_ref', 'expect'], creating ? ['title', 'requirement'] : ['task_ref']);
|
||||
if (Object.hasOwn(a, 'expect') && !(typeof a.expect === 'string' && /^[0-9a-f]{64}$/.test(a.expect))) fail('invalid-request');
|
||||
const current = creating ? null : this.#storeCurrent(s.business, a.task_ref);
|
||||
const roles = { ...this.taskRoles(s.business), ...Object.fromEntries((current?.fields.assignees ?? []).map(role => [role, true])) };
|
||||
const check = storeFieldChecks(roles), edits = {};
|
||||
for (const k of STORE_EDIT_FIELDS) if (Object.hasOwn(a, k)) edits[k] = check[k](a[k]);
|
||||
if (editing && !Object.keys(edits).length) fail('invalid-request');
|
||||
if (current && Object.hasOwn(a, 'expect') && a.expect !== current.digest) fail('task-conflict');
|
||||
if (editing && current.archived) fail('task-archived');
|
||||
if (verb === 'tasks.archive' && current.archived) fail('task-already-archived');
|
||||
if (verb === 'tasks.unarchive' && !current.archived) fail('task-not-archived');
|
||||
const fields = creating ? storeApply(storeNewTask({ title: edits.title }), edits) : editing ? storeApply(current.fields, edits) : current.fields;
|
||||
return this.#storeWrite(s, {
|
||||
current,
|
||||
op: creating ? 'create' : editing ? 'change' : verb.slice('tasks.'.length),
|
||||
verb,
|
||||
fields,
|
||||
by: { role: null, run: null, human: this.#owner(s) },
|
||||
kind: 'human.input',
|
||||
body: { kind: 'task' },
|
||||
});
|
||||
}
|
||||
case 'project.archive':
|
||||
case 'project.unarchive': {
|
||||
this.#human(s);
|
||||
@@ -8391,6 +8746,19 @@ export class Broker {
|
||||
);
|
||||
}
|
||||
}
|
||||
// U4a (#1873): a store task's rows (its whole history, a delete included) and the person's
|
||||
// instruction a role's create cited, with that instruction's message.
|
||||
if (storeRef(subject)) {
|
||||
const own = this.#store.all('SELECT * FROM store_tasks WHERE business=? AND task=? ORDER BY seq', s.business, storeTaskNumber(subject));
|
||||
add('store_tasks', own);
|
||||
const request = own.length ? JSON.parse(own[0].fields).request : null;
|
||||
const input = request && this.#store.get("SELECT * FROM events WHERE business=? AND kind='human.input' AND id=?", s.business, request);
|
||||
if (input) {
|
||||
add('events', [input]);
|
||||
const body = JSON.parse(input.body);
|
||||
if (body.message) add('messages', this.#store.all('SELECT * FROM messages WHERE business=? AND id=?', s.business, body.message));
|
||||
}
|
||||
}
|
||||
for (const m of [...rows.values()])
|
||||
if (m.table === 'messages')
|
||||
add('deliveries', this.#store.all('SELECT * FROM deliveries WHERE message=?', m.id));
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- U4a (#1873): the task store built into each organization, the default task-tracker module
|
||||
-- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row
|
||||
-- and the rows are its history: who wrote it (a role and run, or the person), when, under which
|
||||
-- verb, and the event that records the write. Task numbers count up per organization and are
|
||||
-- never reused. A delete is a last row: the task leaves every read and its history stays until the
|
||||
-- organization is removed, which removes this whole database with it. Only a new append-only
|
||||
-- table, its index and its guards; no existing row changes and nothing is deleted.
|
||||
CREATE TABLE store_tasks (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
task INTEGER NOT NULL CHECK (task >= 1),
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
op TEXT NOT NULL CHECK (op IN ('create','change','archive','unarchive','delete')),
|
||||
verb TEXT NOT NULL,
|
||||
by_role TEXT,
|
||||
by_run TEXT,
|
||||
by_human TEXT,
|
||||
event TEXT NOT NULL REFERENCES events(id),
|
||||
fields TEXT NOT NULL CHECK (json_valid(fields) AND json_type(fields) = 'object'),
|
||||
CHECK ((by_human IS NULL) = (by_role IS NOT NULL AND by_run IS NOT NULL)),
|
||||
UNIQUE (business, task, revision)
|
||||
) STRICT;
|
||||
CREATE INDEX store_tasks_task ON store_tasks (business, task, seq);
|
||||
CREATE TRIGGER store_tasks_no_update BEFORE UPDATE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
|
||||
CREATE TRIGGER store_tasks_no_delete BEFORE DELETE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
|
||||
CREATE TRIGGER store_tasks_no_replace BEFORE INSERT ON store_tasks WHEN EXISTS (SELECT 1 FROM store_tasks WHERE seq = NEW.seq OR (business = NEW.business AND task = NEW.task AND revision = NEW.revision)) BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
|
||||
CREATE TRIGGER store_tasks_revision BEFORE INSERT ON store_tasks
|
||||
WHEN NEW.revision IS NOT COALESCE((SELECT MAX(revision) FROM store_tasks WHERE business = NEW.business AND task = NEW.task), 0) + 1
|
||||
BEGIN SELECT RAISE(ABORT, 'a store task revision follows the last one'); END;
|
||||
CREATE TRIGGER store_tasks_create BEFORE INSERT ON store_tasks
|
||||
WHEN (NEW.op = 'create') IS NOT (NEW.revision = 1)
|
||||
OR (NEW.op = 'create' AND NEW.task IS NOT COALESCE((SELECT MAX(task) FROM store_tasks WHERE business = NEW.business), 0) + 1)
|
||||
BEGIN SELECT RAISE(ABORT, 'a store task is created once, with the next number'); END;
|
||||
CREATE TRIGGER store_tasks_deleted BEFORE INSERT ON store_tasks
|
||||
WHEN EXISTS (SELECT 1 FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op = 'delete')
|
||||
BEGIN SELECT RAISE(ABORT, 'a deleted store task takes no more rows'); END;
|
||||
CREATE TRIGGER store_tasks_archive BEFORE INSERT ON store_tasks
|
||||
WHEN NEW.op IN ('archive','unarchive')
|
||||
AND (COALESCE((SELECT op FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op IN ('archive','unarchive') ORDER BY seq DESC LIMIT 1), 'unarchive') = 'archive') = (NEW.op = 'archive')
|
||||
BEGIN SELECT RAISE(ABORT, 'a store task archive row changes state'); END;
|
||||
@@ -63,6 +63,8 @@ export const ACCESS_SWEEP_MS = 30_000;
|
||||
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
|
||||
// Human limits writes use the shared organization writer behind the verified transport.
|
||||
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
|
||||
// U4a (#1873): it serves the businesses whose task-tracker module is Vikunja; the others get theirs
|
||||
// through the plug-in loader, the built-in store by default (see trackerOf in startBroker).
|
||||
// REQ-HARN-2, REQ-PLUG-2 (decision 0b81b0c5): one web service per business. Its search source is
|
||||
// the "search-source" plug-in the business's organization file switches on: searxng-url (the
|
||||
// business var web.searxng) or searxng-bundled. With none, web.search refuses "not configured" and
|
||||
@@ -653,7 +655,21 @@ export async function startBroker({
|
||||
return no(declared ? 'project-pending' : 'project-unknown');
|
||||
}
|
||||
const requirementsHost = createRequirementsHost({ store, businesses, builtinBinding, journal: requestJournal });
|
||||
// U4a (#1873, REQ-TASK-1, REQ-PLUG-1): the task-tracker module each business uses, fixed for this
|
||||
// start. The organization file's "task-tracker" plug-in names it. With none, a business the boot
|
||||
// config gives a Vikunja tracker keeps Vikunja (so does every business when an in-process caller
|
||||
// hands `tasks` and no `trackers`), and any other business gets the built-in store. A business that
|
||||
// names another module while the boot config also gives it a Vikunja tracker refuses the boot:
|
||||
// two trackers can't both hold its tasks.
|
||||
const trackerOf = new Map();
|
||||
for (const [id, b] of Object.entries(businesses)) {
|
||||
const named = b?.plugins?.['task-tracker'];
|
||||
const tracked = trackers !== null && typeof trackers === 'object' && Object.hasOwn(trackers, id);
|
||||
if (named !== undefined && named !== 'vikunja' && tracked) throw new BusError('tracker-config');
|
||||
trackerOf.set(id, named ?? ((trackers ? tracked : tasks !== null) ? 'vikunja' : 'store'));
|
||||
}
|
||||
broker = new Broker({
|
||||
taskTracker: (id) => trackerOf.get(id),
|
||||
workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }),
|
||||
requirementsHost,
|
||||
requestLifecycle: services => {
|
||||
@@ -888,11 +904,42 @@ export async function startBroker({
|
||||
}
|
||||
const bound = launches.map(bindLaunch);
|
||||
const readCaps = readers.map((business) => ({ business, cap: broker.bindReader({ business }) }));
|
||||
if (tasks) {
|
||||
adapter = await tasks({ broker, credentials, businesses, processing });
|
||||
if (typeof adapter?.handle !== 'function' || !Number.isSafeInteger(adapter.timeout) || adapter.timeout < 1)
|
||||
// U4a: one adapter per task-tracker module in use, behind one handle that routes each call by the
|
||||
// caller's business. The host's `tasks` factory is the Vikunja module. Any other module, and Vikunja
|
||||
// when the host gave no factory but an organization file names it, loads through the plug-in loader
|
||||
// with the boot config's `trackers`. A module that refuses to load or has no factory refuses
|
||||
// startup with tracker-config. A module whose factory makes no
|
||||
// adapter refuses the boot (invalid-adapter), and every adapter made so far is closed. The host's
|
||||
// factory is always made when given, used or not, so its own checks of `trackers` still run.
|
||||
const made = new Map();
|
||||
adapter = { async close() { for (const a of made.values()) await a?.close?.(); } };
|
||||
const pluginLog = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`);
|
||||
for (const name of new Set([...(tasks ? ['vikunja'] : []), ...trackerOf.values()])) {
|
||||
let factory = null;
|
||||
if (name === 'vikunja' && tasks) factory = tasks;
|
||||
else if (name !== 'vikunja' || [...Object.values(businesses)].some((b) => b?.plugins?.['task-tracker'] === 'vikunja')) {
|
||||
const loader = createLoader({ log: pluginLog });
|
||||
const { refused } = await loader.load({ 'task-tracker': name });
|
||||
const [module] = loader.modules('task-tracker');
|
||||
if (!refused.length && module) factory = module.adapter({ trackers: trackers ?? {} });
|
||||
}
|
||||
// A host may omit its implicit Vikunja adapter, retaining the legacy unconfigured path.
|
||||
// An explicitly selected module must always supply a factory.
|
||||
if (!factory && name === 'vikunja' && !tasks && !Object.values(businesses).some(b => b?.plugins?.['task-tracker'] === 'vikunja')) continue;
|
||||
if (typeof factory !== 'function') throw new BusError('tracker-config');
|
||||
const one = await factory({ broker, credentials, businesses, processing });
|
||||
made.set(name, one);
|
||||
if (typeof one?.handle !== 'function' || !Number.isSafeInteger(one.timeout) || one.timeout < 1)
|
||||
throw new BusError('invalid-adapter');
|
||||
}
|
||||
if (made.size) {
|
||||
adapter.timeout = Math.max(...[...made.values()].map((a) => a.timeout));
|
||||
adapter.handle = async (cap, verb, args) => {
|
||||
const one = made.get(trackerOf.get(broker.identity(cap).business));
|
||||
if (!one) throw new BusError('tracker-unconfigured');
|
||||
return one.handle(cap, verb, args);
|
||||
};
|
||||
}
|
||||
const web = await webServices(businesses);
|
||||
// Row 210 (#1707, design D7 step 6): clear what a killed bus child left, before anyone can call.
|
||||
if (runRoot) sweepRunDir(runRoot);
|
||||
@@ -921,7 +968,7 @@ export async function startBroker({
|
||||
web: (business) => web.get(business) ?? null,
|
||||
host: host ? { busy: (...args) => host.busy(...args),
|
||||
run: (...args) => processing.work(() => host.run(...args)) } : null,
|
||||
tasks: adapter ? { handle: adapter.handle, timeout: adapter.timeout } : null,
|
||||
tasks: adapter?.handle ? { handle: adapter.handle, timeout: adapter.timeout } : null,
|
||||
broker,
|
||||
path,
|
||||
peerReader,
|
||||
|
||||
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||
export const SCHEMA_SHA256 = '6f6f0c0fd471b7895648a71aa9f320a769b145da792c4152ee8282006eff30c7';
|
||||
export const SCHEMA_VERSION = '3u';
|
||||
export const SCHEMA_SHA256 = 'e5868996851ee60a3c6d2798a17b9da446ba666c859d84d1b575ca5104ae61f8';
|
||||
export const SCHEMA_VERSION = '3v';
|
||||
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
|
||||
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
|
||||
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
|
||||
@@ -115,6 +115,11 @@ const migrations = [{
|
||||
}, {
|
||||
from: '3t', to: '3u',
|
||||
sql: readFileSync(new URL('./migrations/3t-3u.sql', import.meta.url), 'utf8'),
|
||||
}, {
|
||||
// U4a (#1873): the task store built into each organization, the default task-tracker module.
|
||||
// Only a new append-only table, its index and its guards; no existing row changes.
|
||||
from: '3u', to: '3v',
|
||||
sql: readFileSync(new URL('./migrations/3u-3v.sql', import.meta.url), 'utf8'),
|
||||
}];
|
||||
const timedTables = [
|
||||
'events',
|
||||
@@ -142,6 +147,7 @@ const timedTables = [
|
||||
'request_project_events',
|
||||
'project_archives',
|
||||
'project_system_types',
|
||||
'store_tasks',
|
||||
];
|
||||
const hex = (value) => createHash('sha256').update(value).digest('hex');
|
||||
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
|
||||
|
||||
@@ -19,7 +19,7 @@ const here = (p) => new URL(p, import.meta.url);
|
||||
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
|
||||
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
|
||||
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
function source3h(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -95,6 +95,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
|
||||
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
|
||||
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
|
||||
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
@@ -102,7 +103,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
|
||||
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
|
||||
@@ -152,7 +153,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
// Still a whole 3h store: the real step migrates it.
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
});
|
||||
|
||||
@@ -164,14 +165,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
|
||||
schema: ddl3h,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
|
||||
assert.throws(() => new Store3h(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
|
||||
});
|
||||
@@ -616,7 +616,7 @@ async function database3c(t) {
|
||||
let source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.notEqual(pinned, source);
|
||||
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
|
||||
@@ -640,7 +640,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
|
||||
const b = new Broker({ store, businesses: demo(), organization: null });
|
||||
|
||||
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
|
||||
const original = readFileSync(source);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
||||
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
|
||||
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
|
||||
const sha = createHash('sha256').update(futureDDL).digest('hex');
|
||||
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
|
||||
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3v';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3u', to: '3v', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3w';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3v', to: '3w', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql'])
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql', '3u-3v.sql'])
|
||||
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
|
||||
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
||||
const future = new FutureStore(root, { migrateSchema: true });
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w');
|
||||
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
|
||||
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
|
||||
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
|
||||
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.match(migrated.stdout, /bus schema 3u ready/);
|
||||
assert.match(migrated.stdout, /bus schema 3v ready/);
|
||||
new Store(root).close();
|
||||
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 2);
|
||||
|
||||
@@ -36,11 +36,11 @@ function source3j(t) {
|
||||
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
|
||||
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
|
||||
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
function rows(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types')").all()
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").all()
|
||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
|
||||
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
|
||||
} finally { db.close(); }
|
||||
@@ -85,6 +85,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
|
||||
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
|
||||
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
|
||||
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -106,7 +107,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
|
||||
const { root, path } = source3j(t), before = rows(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(migrated3l(rows(path)), before);
|
||||
const now = master(path);
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
||||
@@ -132,7 +133,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
});
|
||||
|
||||
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
@@ -143,14 +144,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
|
||||
schema: ddl3j,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
|
||||
assert.throws(() => new Store3j(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1',
|
||||
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
|
||||
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
|
||||
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
function source3i(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -42,7 +42,7 @@ function source3i(t) {
|
||||
function rows(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types')").all()
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").all()
|
||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
|
||||
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
|
||||
} finally { db.close(); }
|
||||
@@ -88,6 +88,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
|
||||
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
|
||||
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
|
||||
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -111,7 +112,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
|
||||
const { root, path } = source3i(t), before = rows(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(migrated3l(rows(path)), before);
|
||||
const now = master(path).filter(r => !LATER.includes(r.name));
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
||||
@@ -136,7 +137,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
});
|
||||
|
||||
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
@@ -147,14 +148,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
|
||||
schema: ddl3i,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
|
||||
assert.throws(() => new Store3i(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
|
||||
|
||||
@@ -199,6 +199,8 @@ test('3p through 3s migration preserves existing request rows and the append-onl
|
||||
// 3s-3t (#797) adds project_archives; the 3p fixture predates it and the replayed chain recreates it.
|
||||
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
|
||||
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
|
||||
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(",'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced'", '');
|
||||
const db = new DatabaseSync(path); db.exec(ddl);
|
||||
const at = '2026-11-03T00:00:00.000Z';
|
||||
@@ -214,7 +216,7 @@ test('3p through 3s migration preserves existing request rows and the append-onl
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true }); t.after(() => store.close());
|
||||
assert.deepEqual(store.all('SELECT * FROM request_events'), before);
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')", at);
|
||||
assert.throws(() => store.run('DELETE FROM request_events'), /append-only/);
|
||||
assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/);
|
||||
|
||||
@@ -143,7 +143,8 @@ const here = (p) => new URL(p, import.meta.url);
|
||||
const migrationSQL = readFileSync(here('../src/migrations/3s-3t.sql'), 'utf8');
|
||||
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
|
||||
assert.equal(currentDDL.split(migrationSQL).length, 2, 'schema.sql holds the 3s→3t step exactly once');
|
||||
const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), '');
|
||||
// 3u-3v (#1873, U4a) adds store_tasks after them; a 3s store has neither.
|
||||
const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), '').replace(readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8'), '');
|
||||
|
||||
function source3s(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'project-archive-3s-'));
|
||||
@@ -178,7 +179,7 @@ test('a 3s store migrates to 3t: project_archives is new and empty, every row st
|
||||
assert.deepEqual(master(path), was, 'the refused open changed nothing');
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.equal(store.get("SELECT id FROM events").id, 'preserved');
|
||||
assert.equal(store.get("SELECT name FROM projects").name, 'kept');
|
||||
assert.deepEqual(store.all('SELECT seq,at,business,project,op,by FROM project_archives'), []);
|
||||
@@ -211,7 +212,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3s).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3s';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3s';")
|
||||
.replace(/\}, \{\n {2}\/\/ #797 MVP3[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3s';") && edited.includes('3r-3s') && !edited.includes('3s-3t'), 'the 3s engine has no 3s→3t step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -221,7 +222,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
|
||||
const Store3s = (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
assert.throws(() => new Store3s(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3s(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(master(path), was);
|
||||
// The same engine opens a fresh 3s store, so the refusal is the version's and not a broken copy.
|
||||
const { root: old } = source3s(t);
|
||||
|
||||
@@ -302,7 +302,7 @@ test('a cancel is refused when the owner-to-PM edge closed, with no withdrawn de
|
||||
});
|
||||
|
||||
test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => {
|
||||
assert.equal(SCHEMA_VERSION, '3u');
|
||||
assert.equal(SCHEMA_VERSION, '3v');
|
||||
const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
||||
const path = join(root, 'bus/bus.sqlite'), AT = '2026-11-03T00:00:00.000Z';
|
||||
@@ -310,8 +310,11 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
|
||||
// 3s-3t (#797) adds project_archives, outside request_events; the 3q fixture predates it and
|
||||
// the replayed chain recreates it, so the step's text is removed here like the kinds are.
|
||||
const step3t = readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8');
|
||||
// 3t-3u (#1873, SYS1) adds project_system_types the same way, and 3u-3v (U4a) adds store_tasks.
|
||||
const step3u = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
|
||||
const step3v = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8');
|
||||
// 3p-3q shipped the PRD kinds and mode.changed; 3q-3r adds the three Task 3 kinds after them.
|
||||
const ddl3q = current.replace(step3t, '').replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
|
||||
const ddl3q = current.replace(step3t, '').replace(step3u, '').replace(step3v, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
|
||||
assert.notEqual(ddl3q, current, 'the fixture is the narrower schema');
|
||||
const migration = readFileSync(new URL('../src/migrations/3q-3r.sql', import.meta.url), 'utf8');
|
||||
assert.ok(migration.includes("'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'"), 'the step carries every 3q kind plus its own');
|
||||
@@ -328,7 +331,7 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n');
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n');
|
||||
const store = new Store(root);
|
||||
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes, including the 3q kinds');
|
||||
const fresh = new DatabaseSync(':memory:'); fresh.exec(current);
|
||||
|
||||
@@ -231,6 +231,8 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
|
||||
db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
|
||||
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
|
||||
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
|
||||
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(",'advanced'", ''));
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3r');
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
|
||||
@@ -251,7 +253,7 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
|
||||
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before);
|
||||
assert.deepEqual(store.all("SELECT name,sql FROM sqlite_master WHERE type='trigger' AND tbl_name IN ('request_events','request_effects') ORDER BY name"), guards);
|
||||
assert.equal(store.get("SELECT name FROM sqlite_master WHERE type='index' AND name='request_events_request'").name, 'request_events_request');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',16,'advanced',?,'{}')", at);
|
||||
assert.equal(store.get("SELECT max(seq) n FROM request_events").n, 42);
|
||||
assert.throws(() => store.run("UPDATE request_events SET kind='opened'"), /append-only/);
|
||||
|
||||
@@ -279,6 +279,8 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
|
||||
// predate it and the replayed chain recreates it, so the step's text comes out here too.
|
||||
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
|
||||
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
|
||||
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
|
||||
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
|
||||
const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8');
|
||||
assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length);
|
||||
@@ -291,7 +293,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
|
||||
db.close(); chmodSync(path, 0o600); const before = readFileSync(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before);
|
||||
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n'); assert.equal(SCHEMA_VERSION, '3u');
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n'); assert.equal(SCHEMA_VERSION, '3v');
|
||||
const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved');
|
||||
for (const table of ['request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0);
|
||||
store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0);
|
||||
@@ -315,6 +317,8 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
|
||||
const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
|
||||
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
|
||||
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
|
||||
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
|
||||
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
|
||||
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
|
||||
const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z';
|
||||
const db = new DatabaseSync(path); db.exec(ddl3o);
|
||||
@@ -331,7 +335,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n');
|
||||
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n');
|
||||
const store = new Store(root);
|
||||
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes');
|
||||
assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects);
|
||||
|
||||
@@ -527,8 +527,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
|
||||
// #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards.
|
||||
// Task 4 (3p to 3q), Task 3 (3q to 3r) and Task 6b (3r to 3s) widen request_events.kind in place.
|
||||
// #797 MVP3 (3s to 3t) adds project_archives and its five guards; project-archive.test.mjs tests them.
|
||||
// 3t to 3u adds project_system_types and four guards.
|
||||
assert.equal(count('table') - 1, 32);
|
||||
assert.equal(count('trigger'), 123);
|
||||
// 3t to 3u adds project_system_types and four guards; U4a #1873 (3u to 3v) adds store_tasks and its seven guards.
|
||||
assert.equal(count('table') - 1, 33);
|
||||
assert.equal(count('trigger'), 130);
|
||||
assert.equal(count('view'), 5);
|
||||
});
|
||||
@@ -530,9 +530,9 @@ test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are
|
||||
const before = tables(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
const after = tables(path);
|
||||
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types']) {
|
||||
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) {
|
||||
assert.deepEqual(after[name], [], name);
|
||||
delete after[name];
|
||||
}
|
||||
@@ -571,7 +571,7 @@ async function engine3m(root) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3m';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3m';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -588,7 +588,7 @@ test('T4 schema: the 3m engine refuses a 3n store, migrating or not, and leaves
|
||||
const Store3m = await engine3m(root);
|
||||
assert.throws(() => new Store3m(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(tables(path), before);
|
||||
// The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
|
||||
|
||||
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
|
||||
const { root, path } = source3k(t);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
// The migrated schema is the fresh one, object for object.
|
||||
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
|
||||
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
||||
@@ -430,7 +430,7 @@ async function engine3k(root) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
|
||||
const Store3k = await engine3k(root);
|
||||
assert.throws(() => new Store3k(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
|
||||
|
||||
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
|
||||
@@ -67,7 +67,9 @@ test('3t store migrates offline without altering existing project rows', async t
|
||||
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||
const step = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
|
||||
assert.equal(ddl.split(step).length, 2);
|
||||
db.exec(ddl.replace(step, ''));
|
||||
// 3u-3v (U4a) adds store_tasks after this step; a 3t store has neither.
|
||||
const later = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8');
|
||||
db.exec(ddl.replace(step, '').replace(later, ''));
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3t');
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
|
||||
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', '2026-01-01T00:00:00.000Z');
|
||||
@@ -77,7 +79,7 @@ test('3t store migrates offline without altering existing project rows', async t
|
||||
const migrated = new Store(root, { migrateSchema: true }); t.after(() => migrated.close());
|
||||
assert.equal(migrated.get('SELECT name FROM projects WHERE id=?', 'old').name, 'old');
|
||||
assert.equal(migrated.get('SELECT count(*) AS n FROM project_system_types').n, 0);
|
||||
assert.equal(migrated.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(migrated.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
});
|
||||
|
||||
test('runtime reads the current organization file for project selections and console discovery', async t => {
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { Store, schemaDigest } from '../src/store.mjs';
|
||||
import { Broker, STORE_FIELDS, STORE_POSITION_STEP, STORE_VERB_FIELDS, storeNewTask } from '../src/broker.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { storeAdapter } from '../../tasks/src/store.mjs';
|
||||
import { loadFields } from '../../tasks/src/fields.mjs';
|
||||
|
||||
// U4a (#1873, REQ-TASK-1, REQ-PLUG-1, REQ-WS-8): the task store built into each organization, the
|
||||
// default task-tracker module. Roles write through the eight task verbs (packages/tasks/src/store.mjs
|
||||
// over broker.storeTask), the person through tasks.create/edit/archive/unarchive/delete, and every
|
||||
// read is the bus's `tasks` verb, the same shape whichever module holds the tasks.
|
||||
|
||||
const AUTHORITY = {
|
||||
pm: {
|
||||
withinRole: ['task.create', 'task.assign', 'task.reassign', 'task.schedule', 'task.update.assigned', 'task.close', 'role.launch', 'message.send'],
|
||||
crossRole: ['task.priority.change', 'task.scope.change'],
|
||||
},
|
||||
coder: { withinRole: ['task.update.assigned', 'git.push.working', 'review.request', 'message.send'], crossRole: ['task.scope.change'] },
|
||||
reviewer: { withinRole: ['review.verdict', 'message.send', 'task.update.assigned'], crossRole: [] },
|
||||
cto: { withinRole: ['review.request', 'task.update.assigned', 'message.send', 'decision.resolve.technical'], crossRole: ['task.scope.change'] },
|
||||
};
|
||||
const roles = () => Object.fromEntries(Object.entries(AUTHORITY).map(([r, authority]) => [r, { definition: r, authority: structuredClone(authority) }]));
|
||||
const business = (id, extra = {}) => ({ id, human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: roles(), ...extra });
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
// One bus (one data root) holding the given organizations, with the store module over it.
|
||||
async function org(t, ids = ['acme'], { taskTracker = null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'ts-'));
|
||||
const businesses = Object.fromEntries(ids.map((id) => [id, business(id)]));
|
||||
const store = new Store(root);
|
||||
const broker = new Broker({ store, businesses, ...(taskTracker ? { taskTracker } : {}) });
|
||||
const adapter = await storeAdapter()({ broker, businesses });
|
||||
let removed = false;
|
||||
t.after(() => {
|
||||
adapter.close();
|
||||
if (!removed) {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
const caps = new Map(), humans = new Map();
|
||||
const agent = (role, id = ids[0]) => {
|
||||
const key = id + '/' + role;
|
||||
if (!caps.has(key)) {
|
||||
const cap = broker.bindLaunch({ business: id, role, run: role + '-run', harness: 'pi' });
|
||||
broker.request(cap, { verb: 'role.claim' });
|
||||
caps.set(key, cap);
|
||||
}
|
||||
return caps.get(key);
|
||||
};
|
||||
const human = (id = ids[0]) => {
|
||||
if (!humans.has(id)) humans.set(id, broker.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true }));
|
||||
return humans.get(id);
|
||||
};
|
||||
const instruction = (id = ids[0]) => broker.request(human(id), { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
|
||||
const call = (role, verb, args, id = ids[0]) => broker.requestTask(agent(role, id), { verb, args }, adapter.handle);
|
||||
const person = (verb, args, id = ids[0]) => broker.request(human(id), { verb, args });
|
||||
const read = (args = {}, id = ids[0]) => broker.request(broker.bindReader({ business: id }), { verb: 'tasks', args });
|
||||
const create = (title, extra = {}, id = ids[0]) => call('pm', 'task.create', { title, request: instruction(id), requirement: 'REQ-TASK-1', ...extra }, id);
|
||||
// A cross-role verb's approval: the role raises it in the technical domain and the cto resolves it.
|
||||
const approve = (role, action, target, id = ids[0]) => {
|
||||
const d = broker.request(agent(role, id), {
|
||||
verb: 'decision.raise',
|
||||
args: { action, domain: 'technical', target, question: 'Allow?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false },
|
||||
});
|
||||
broker.request(agent('cto', id), { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
|
||||
return d.id;
|
||||
};
|
||||
const remove = () => {
|
||||
removed = true;
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
};
|
||||
return { root, store, broker, agent, human, instruction, call, person, read, create, approve, remove };
|
||||
}
|
||||
const codeOf = async (p) => {
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
return e.code;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const syncCode = (fn) => {
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
return e.code;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
test('REQ-TASK-1 the store keeps the slice 1 field table: one field list per role verb, all of them store fields', () => {
|
||||
assert.deepEqual(Object.keys(STORE_VERB_FIELDS).sort(), Object.keys(loadFields().verbs).sort());
|
||||
for (const [verb, list] of Object.entries(STORE_VERB_FIELDS))
|
||||
for (const k of list) assert.ok(STORE_FIELDS.includes(k), `${verb} names ${k}`);
|
||||
});
|
||||
|
||||
test('REQ-TASK-1 every role verb writes the store through the module', async (t) => {
|
||||
const f = await org(t);
|
||||
const request = f.instruction();
|
||||
const made = await f.call('pm', 'task.create', { title: 'Write the store', description: 'd', priority: 2, labels: [3, 1], group: 'build', request, requirement: 'REQ-TASK-1' });
|
||||
assert.equal(made.task_ref, 'task:1');
|
||||
assert.match(made.digest, DIGEST);
|
||||
let row = f.read({ task_ref: 'task:1' });
|
||||
assert.equal(row.source, 'store');
|
||||
assert.equal(row.tracker, 'store');
|
||||
assert.equal(row.role, 'pm');
|
||||
assert.equal(row.verb, 'task.create');
|
||||
assert.equal(row.revision, 1);
|
||||
assert.equal(row.digest, made.digest);
|
||||
assert.equal(row.archived, false);
|
||||
assert.deepEqual(row.fields, { ...storeNewTask({ title: 'Write the store', request, requirement: 'REQ-TASK-1' }), description: 'd', priority: 2, labels: [1, 3], group: 'build', position: 0 });
|
||||
// The row's event is the action.allowed the write recorded, on the task's subject.
|
||||
const event = f.store.get('SELECT kind, subject, body FROM events WHERE id=?', row.event);
|
||||
assert.equal(event.kind, 'action.allowed');
|
||||
assert.equal(event.subject, 'task:1');
|
||||
assert.equal(JSON.parse(event.body).action, 'task.create');
|
||||
|
||||
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
|
||||
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'reviewer' })), 'task-assigned');
|
||||
await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'reviewer' });
|
||||
assert.deepEqual(f.read({ task_ref: 'task:1' }).fields.assignees, ['reviewer']);
|
||||
await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'coder' });
|
||||
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'nobody' })), 'unknown-role');
|
||||
|
||||
await f.call('pm', 'task.schedule', { task_ref: 'task:1', due_date: '2026-12-01T00:00:00.000Z', labels: { add: [5], remove: [1] } });
|
||||
row = f.read({ task_ref: 'task:1' });
|
||||
assert.equal(row.fields.due_date, '2026-12-01T00:00:00.000Z');
|
||||
assert.deepEqual(row.fields.labels, [3, 5]);
|
||||
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1' })), 'invalid-request');
|
||||
|
||||
await f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.5, state: 'in-progress', comment: 'started' });
|
||||
row = f.read({ task_ref: 'task:1' });
|
||||
assert.equal(row.role, 'coder');
|
||||
assert.equal(row.fields.state, 'in-progress');
|
||||
assert.equal(row.fields.bucket, 'in-progress');
|
||||
assert.equal(row.fields.percent_done, 0.5);
|
||||
assert.equal(row.fields.comments, 1);
|
||||
assert.deepEqual(row.fields.comment, { text: 'started', by: 'coder' });
|
||||
// done is task.close's, never the assignee's.
|
||||
assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', state: 'done' })), 'invalid-state');
|
||||
|
||||
// Cross-role verbs need a resolved decision, as they do on Vikunja, and spend it once.
|
||||
assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4 })), 'decision-required');
|
||||
const d = f.approve('pm', 'task.priority.change', 'task:1');
|
||||
await f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4, decision: d });
|
||||
assert.equal(f.read({ task_ref: 'task:1' }).priority, 4);
|
||||
assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 5, decision: d })), 'decision-consumed');
|
||||
const scope = f.approve('coder', 'task.scope.change', 'task:1');
|
||||
await f.call('coder', 'task.scope.change', { task_ref: 'task:1', title: 'Write the store well', decision: scope });
|
||||
assert.equal(f.read({ task_ref: 'task:1' }).fields.title, 'Write the store well');
|
||||
|
||||
const closed = await f.call('pm', 'task.close', { task_ref: 'task:1', verdict: 'review-1' });
|
||||
row = f.read({ task_ref: 'task:1' });
|
||||
assert.equal(row.digest, closed.digest);
|
||||
assert.equal(row.fields.done, true);
|
||||
assert.equal(row.fields.state, 'done');
|
||||
assert.equal(row.fields.verdict, 'review-1');
|
||||
// A done task takes no role write; reopening it is the person's.
|
||||
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', labels: { add: [9] } })), 'task-done');
|
||||
assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 1 })), 'task-done');
|
||||
// Nine writes; no refusal wrote a row.
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 9);
|
||||
assert.deepEqual(f.store.all('SELECT revision FROM store_tasks ORDER BY seq').map((r) => r.revision), [1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
});
|
||||
|
||||
test('REQ-TASK-1 field writers: a role cannot write a field it does not own', async (t) => {
|
||||
const f = await org(t);
|
||||
await f.create('Owned fields');
|
||||
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
|
||||
// The module holds the pm verbs to the pm definition.
|
||||
assert.equal(await codeOf(f.call('coder', 'task.create', { title: 'x', request: f.instruction(), requirement: 'REQ-TASK-1' })), 'field-writer');
|
||||
assert.equal(await codeOf(f.call('coder', 'task.assign', { task_ref: 'task:1', role: 'coder' })), 'field-writer');
|
||||
assert.equal(await codeOf(f.call('coder', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'field-writer');
|
||||
assert.equal(await codeOf(f.call('reviewer', 'task.close', { task_ref: 'task:1', verdict: 'v' })), 'field-writer');
|
||||
// task.update.assigned needs the role among the assignees.
|
||||
assert.equal(await codeOf(f.call('reviewer', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.1 })), 'not-assigned');
|
||||
// The broker holds every write to its verb's fields, whatever the module asks for.
|
||||
const pm = f.agent('pm'), coder = f.agent('coder');
|
||||
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.schedule', task_ref: 'task:1', change: (x) => ({ ...x, title: 'sneaky' }) })), 'field-writer');
|
||||
assert.equal(syncCode(() => f.broker.storeTask(coder, { verb: 'task.update.assigned', task_ref: 'task:1', change: (x) => ({ ...x, priority: 5 }) })), 'field-writer');
|
||||
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.close', task_ref: 'task:1', verdict: 'v', change: (x) => ({ ...x, done: true, state: 'done', bucket: 'done', assignees: [] }) })), 'field-writer');
|
||||
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.assign', task_ref: 'task:1', change: (x) => ({ ...x, extra: 1 }) })), 'field-writer');
|
||||
// A person's session is not a role here.
|
||||
assert.equal(syncCode(() => f.broker.storeTask(f.human(), { verb: 'task.schedule', task_ref: 'task:1', change: (x) => x })), 'agent-required');
|
||||
// Vikunja-only arguments refuse; nothing was written by any refusal.
|
||||
for (const extra of [{ project: 1 }, { relations: [] }, { project_key: 'k' }, { requirement_snapshot: 's' }])
|
||||
assert.equal(await codeOf(f.create('vikunja args', extra)), 'invalid-request', JSON.stringify(extra));
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 2);
|
||||
// A refusal on a store task is filed on its trail.
|
||||
const trail = f.person('trail', { subject: 'task:1' });
|
||||
assert.ok(trail.some((r) => r.table === 'events' && r.kind === 'action.refused' && r.body.code === 'not-assigned'));
|
||||
});
|
||||
|
||||
test('REQ-TASK-1 a person creates, edits, archives, unarchives and deletes; each write is a human.input event', async (t) => {
|
||||
const f = await org(t);
|
||||
const made = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Plan', group: 'todo', labels: [2] });
|
||||
assert.equal(made.task_ref, 'task:1');
|
||||
assert.equal(made.human, 'jason');
|
||||
assert.equal(made.role, null);
|
||||
assert.equal(made.fields.position, 0);
|
||||
assert.equal(made.fields.request, null);
|
||||
const event = f.store.get('SELECT kind, subject, actor_role, body FROM events WHERE id=?', made.event);
|
||||
assert.equal(event.kind, 'human.input');
|
||||
assert.equal(event.subject, 'task:1');
|
||||
assert.equal(event.actor_role, null);
|
||||
assert.deepEqual(JSON.parse(event.body), { kind: 'task', task_ref: 'task:1', tracker: 'store', op: 'create', verb: 'tasks.create' });
|
||||
assert.equal(syncCode(() => f.person('tasks.create', {})), 'invalid-request');
|
||||
assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x', request: 'r' })), 'invalid-request');
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1' })), 'invalid-request');
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', state: 'shipped' })), 'invalid-state');
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', assignees: ['nobody'] })), 'unknown-role');
|
||||
// expect is the digest the person last read; a later write refuses.
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', expect: 'a'.repeat(64), title: 'x' })), 'task-conflict');
|
||||
const done = f.person('tasks.edit', { task_ref: 'task:1', expect: made.digest, state: 'done', assignees: ['coder'] });
|
||||
assert.equal(done.fields.done, true);
|
||||
assert.equal(done.fields.bucket, 'done');
|
||||
// The person may reopen a done task.
|
||||
assert.equal(f.person('tasks.edit', { task_ref: 'task:1', state: 'todo' }).fields.done, false);
|
||||
// Agents and the reader don't reach the person's verbs.
|
||||
assert.equal(syncCode(() => f.broker.request(f.agent('pm'), { verb: 'tasks.create', args: { title: 'x' } })), 'human-required');
|
||||
assert.notEqual(syncCode(() => f.broker.request(f.broker.bindReader({ business: 'acme' }), { verb: 'tasks.create', args: { title: 'x' } })), null);
|
||||
|
||||
const other = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Kept' });
|
||||
f.person('tasks.archive', { task_ref: 'task:1' });
|
||||
assert.equal(syncCode(() => f.person('tasks.archive', { task_ref: 'task:1' })), 'task-already-archived');
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'x' })), 'task-archived');
|
||||
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'task-archived');
|
||||
// An archived task leaves the board read and the filtered read, unless archived: true asks.
|
||||
assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]);
|
||||
assert.deepEqual(f.read({ limit: 10 }).tasks.map((r) => r.task_ref), [other.task_ref]);
|
||||
const archived = f.read({ archived: true });
|
||||
assert.deepEqual(archived.tasks.map((r) => [r.task_ref, r.archived]), [['task:1', true]]);
|
||||
assert.equal(archived.total, 1);
|
||||
assert.equal(f.read({ task_ref: 'task:1' }).archived, true);
|
||||
f.person('tasks.unarchive', { task_ref: 'task:1' });
|
||||
assert.equal(syncCode(() => f.person('tasks.unarchive', { task_ref: 'task:1' })), 'task-not-archived');
|
||||
assert.equal(f.read({ task_ref: 'task:1' }).archived, false);
|
||||
|
||||
const gone = f.person('tasks.delete', { task_ref: 'task:1' });
|
||||
assert.equal(gone.task_ref, 'task:1');
|
||||
assert.equal(gone.deleted, true);
|
||||
assert.equal(syncCode(() => f.read({ task_ref: 'task:1' })), 'task-not-found');
|
||||
assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]);
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'back' })), 'task-not-found');
|
||||
assert.equal(syncCode(() => f.person('tasks.delete', { task_ref: 'task:1' })), 'task-not-found');
|
||||
// The history stays in the organization's bus until the organization is removed.
|
||||
const trail = f.person('trail', { subject: 'task:1' }).filter((r) => r.table === 'store_tasks');
|
||||
assert.deepEqual(trail.map((r) => r.op), ['create', 'change', 'change', 'archive', 'unarchive', 'delete']);
|
||||
assert.equal(trail[0].fields.title, 'Plan');
|
||||
// Numbers are never reused.
|
||||
assert.equal(f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Next' }).task_ref, 'task:3');
|
||||
});
|
||||
|
||||
test('REQ-TASK-1 a role create cites the instruction; the trail carries it, and an op answers the same task again', async (t) => {
|
||||
const f = await org(t);
|
||||
const request = f.instruction();
|
||||
const args = { title: 'Once', request, requirement: 'REQ-TASK-1', op: 'op-1' };
|
||||
const first = await f.call('pm', 'task.create', args);
|
||||
const again = await f.call('pm', 'task.create', args);
|
||||
assert.deepEqual(again, first);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 1);
|
||||
assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, title: 'Other' })), 'task-create-conflict');
|
||||
assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, op: undefined, request: 'not-an-instruction' })), 'request-not-found');
|
||||
assert.equal(await codeOf(f.call('pm', 'task.create', { title: 'x', request, requirement: 'nope' })), 'invalid-request');
|
||||
const trail = f.person('trail', { subject: 'task:1' });
|
||||
assert.ok(trail.some((r) => r.table === 'events' && r.id === request && r.kind === 'human.input'));
|
||||
assert.ok(trail.some((r) => r.table === 'messages'));
|
||||
// A stale expect refuses a role write too.
|
||||
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: 'a'.repeat(64) })), 'task-conflict');
|
||||
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: first.digest });
|
||||
});
|
||||
|
||||
test('REQ-TASK-1 ordering and grouping: positions step by 1024 inside a group, moves go to the end, reads in board order', async (t) => {
|
||||
const f = await org(t);
|
||||
for (const title of ['A', 'B', 'C']) await f.create(title);
|
||||
await f.create('D', { group: 'review' });
|
||||
await f.create('E', { group: 'build' });
|
||||
const at = () => f.read().map((r) => [r.fields.title, r.fields.group, r.fields.position]);
|
||||
assert.deepEqual(at(), [['A', null, 0], ['B', null, STORE_POSITION_STEP], ['C', null, 2 * STORE_POSITION_STEP], ['E', 'build', 0], ['D', 'review', 0]]);
|
||||
// A move to another group with no position goes to its end; a position alone reorders in place.
|
||||
await f.call('pm', 'task.schedule', { task_ref: 'task:2', group: 'review' });
|
||||
await f.call('pm', 'task.schedule', { task_ref: 'task:3', position: 10 });
|
||||
f.person('tasks.edit', { task_ref: 'task:1', position: 5000 });
|
||||
assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', 'build', 0], ['D', 'review', 0], ['B', 'review', STORE_POSITION_STEP]]);
|
||||
// A person's move to a group with no position also goes to its end (after E, still in build then); ungrouping too.
|
||||
f.person('tasks.edit', { task_ref: 'task:4', group: 'build' });
|
||||
f.person('tasks.edit', { task_ref: 'task:5', group: null });
|
||||
assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', null, 6024], ['D', 'build', STORE_POSITION_STEP], ['B', 'review', STORE_POSITION_STEP]]);
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', group: ' ' })), 'invalid-request');
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', position: -1 })), 'invalid-request');
|
||||
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', position: 1.5 })), 'invalid-request');
|
||||
// The filtered short form pages in board order with the store's group, position and archived.
|
||||
const one = f.read({ limit: 2 });
|
||||
assert.deepEqual(one.tasks.map((r) => r.task_ref), ['task:3', 'task:1']);
|
||||
assert.equal(one.total, 5);
|
||||
assert.equal(one.next, 'task:1');
|
||||
assert.deepEqual(Object.keys(one.tasks[0]).sort(), ['archived', 'assignees', 'bucket', 'changed_at', 'done', 'group', 'labels', 'position', 'priority', 'requirement', 'task_ref', 'title']);
|
||||
const two = f.read({ limit: 2, after: one.next });
|
||||
assert.deepEqual(two.tasks.map((r) => r.task_ref), ['task:5', 'task:4']);
|
||||
const three = f.read({ limit: 2, after: two.next });
|
||||
assert.deepEqual(three.tasks.map((r) => r.task_ref), ['task:2']);
|
||||
assert.equal(three.next, null);
|
||||
assert.equal(syncCode(() => f.read({ after: 'task:99' })), 'task-not-found');
|
||||
// The other filters apply to store tasks as to Vikunja's.
|
||||
await f.call('pm', 'task.assign', { task_ref: 'task:4', role: 'coder' });
|
||||
assert.deepEqual(f.read({ assignee: 'coder' }).tasks.map((r) => r.task_ref), ['task:4']);
|
||||
assert.deepEqual(f.read({ requirement: 'REQ-TASK-1', done: false }).total, 5);
|
||||
assert.deepEqual(f.read({ project_key: 'k'.repeat(8) }).tasks, []);
|
||||
});
|
||||
|
||||
test('REQ-TASK-1 two organizations keep their own tasks, and removing one removes only its tasks', async (t) => {
|
||||
// Jason 11-07: each organization has its own bus and data root.
|
||||
const a = await org(t, ['acme']), b = await org(t, ['beta']);
|
||||
await a.create('acme task');
|
||||
await b.create('beta task');
|
||||
b.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'beta by hand' });
|
||||
assert.deepEqual(a.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'acme task']]);
|
||||
assert.deepEqual(b.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'beta task'], ['task:2', 'beta by hand']]);
|
||||
// Removing an organization removes its data root, and its tasks with it.
|
||||
a.remove();
|
||||
assert.equal(existsSync(a.root), false);
|
||||
assert.deepEqual(b.read().map((r) => r.fields.title), ['beta task', 'beta by hand']);
|
||||
await b.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
|
||||
// Within one bus, too, a business reads and writes only its own rows.
|
||||
const both = await org(t, ['one', 'two']);
|
||||
await both.create('one task', {}, 'one');
|
||||
assert.deepEqual(both.read({}, 'two'), []);
|
||||
assert.equal(syncCode(() => both.read({ task_ref: 'task:1' }, 'two')), 'task-not-found');
|
||||
assert.equal(await codeOf(both.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' }, 'two')), 'task-not-found');
|
||||
assert.equal(syncCode(() => both.person('tasks.archive', { task_ref: 'task:1' }, 'two')), 'task-not-found');
|
||||
assert.equal((await both.create('two task', {}, 'two')).task_ref, 'task:1');
|
||||
assert.deepEqual(both.read({}, 'one').map((r) => r.fields.title), ['one task']);
|
||||
});
|
||||
|
||||
test('REQ-PLUG-1 a business on another tracker module refuses the store verbs', async (t) => {
|
||||
const f = await org(t, ['acme'], { taskTracker: () => 'vikunja' });
|
||||
assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x' })), 'tracker-not-store');
|
||||
assert.equal(await codeOf(f.create('x')), 'tracker-not-store');
|
||||
assert.deepEqual(f.read(), []);
|
||||
assert.throws(() => new Broker({ store: f.store, businesses: { acme: business('acme') }, taskTracker: 'store' }), TypeError);
|
||||
});
|
||||
|
||||
test('REQ-TASK-1 store_tasks guards: append-only, revisions in order, one create, nothing after a delete, archive changes state', async (t) => {
|
||||
const f = await org(t);
|
||||
await f.create('Guarded');
|
||||
const event = f.store.get('SELECT event FROM store_tasks').event;
|
||||
const fields = f.store.get('SELECT fields FROM store_tasks').fields;
|
||||
const insert = (task, revision, op, by = ['pm', 'pm-run', null]) =>
|
||||
f.store.run('INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
'2026-11-09T00:00:00.000Z', 'acme', task, revision, op, 'x', ...by, event, fields);
|
||||
for (const [sql, ...args] of [["UPDATE store_tasks SET verb='y'"], ['DELETE FROM store_tasks'],
|
||||
["INSERT OR REPLACE INTO store_tasks(seq,at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(1,'t','acme',1,2,'change','x','pm','pm-run',NULL,?,?)", event, fields]])
|
||||
assert.throws(() => f.store.run(sql, ...args), /store_tasks is append-only/, sql);
|
||||
assert.throws(() => insert(1, 3, 'change'), /a store task revision follows the last one/);
|
||||
assert.throws(() => insert(1, 2, 'create'), /a store task is created once/);
|
||||
assert.throws(() => insert(3, 1, 'create'), /with the next number/);
|
||||
assert.throws(() => insert(1, 2, 'unarchive'), /a store task archive row changes state/);
|
||||
assert.throws(() => insert(1, 2, 'change', ['pm', null, null]), /CHECK/);
|
||||
assert.throws(() => insert(1, 2, 'change', ['pm', 'pm-run', 'jason']), /CHECK/);
|
||||
assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,?,'[]')", event), /CHECK/);
|
||||
assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,'no-such-event',?)", fields), /FOREIGN KEY/);
|
||||
insert(1, 2, 'archive', [null, null, 'jason']);
|
||||
assert.throws(() => insert(1, 3, 'archive', [null, null, 'jason']), /a store task archive row changes state/);
|
||||
insert(1, 3, 'delete', [null, null, 'jason']);
|
||||
assert.throws(() => insert(1, 4, 'change'), /a deleted store task takes no more rows/);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 3);
|
||||
});
|
||||
|
||||
// --- The 3u→3v migration: store_tasks is new and empty, and everything else stays. ---
|
||||
const here = (p) => new URL(p, import.meta.url);
|
||||
const stepSQL = readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8');
|
||||
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
|
||||
const master = (path) => {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r }));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
const version = (path) => {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
|
||||
test('REQ-TASK-1 a 3u store migrates to 3v: store_tasks is new and empty, every row stays, and the schema is the fresh one', (t) => {
|
||||
assert.equal(currentDDL.split(stepSQL).length, 2, 'schema.sql holds the 3u→3v step exactly once');
|
||||
assert.ok(currentDDL.endsWith(stepSQL), 'the 3u→3v step is the last in schema.sql');
|
||||
const root = mkdtempSync(join(tmpdir(), 'ts-3u-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
||||
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
|
||||
db.exec(currentDDL.replace(stepSQL, ''));
|
||||
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3u');
|
||||
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
|
||||
db.prepare('INSERT INTO meta VALUES (?,?)').run('comment_cursor_epoch', '2026-11-09T00:00:00.000Z');
|
||||
db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('preserved','2026-11-09T00:00:00.000Z','acme','action.allowed','{}')").run();
|
||||
db.close();
|
||||
chmodSync(path, 0o600);
|
||||
const was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
assert.deepEqual(master(path), was, 'the refused open changed nothing');
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(version(path), '3v');
|
||||
assert.equal(store.get('SELECT id FROM events').id, 'preserved');
|
||||
assert.deepEqual(store.all('SELECT * FROM store_tasks'), []);
|
||||
const freshRoot = mkdtempSync(join(tmpdir(), 'ts-fresh-'));
|
||||
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
||||
new Store(freshRoot).close();
|
||||
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
|
||||
});
|
||||
|
||||
// --- The bus runtime: the store is the default module; Vikunja stays the host's. ---
|
||||
test('REQ-PLUG-1 the runtime gives a business with no tracker the store and routes a Vikunja business to the host adapter', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'ts-rt-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const businesses = {
|
||||
acme: business('acme', { plugins: { harness: ['pi'] } }),
|
||||
beta: business('beta', { plugins: { harness: ['pi'], 'task-tracker': 'vikunja' } }),
|
||||
};
|
||||
const seen = [];
|
||||
let closed = 0;
|
||||
const launch = (id) => ({ business: id, role: 'pm', run: id + '-pm', harness: 'pi', pid: process.pid, startTime: '1' });
|
||||
const runtime = await startBroker({
|
||||
dataRoot: root,
|
||||
businesses,
|
||||
launches: [launch('acme'), launch('beta')],
|
||||
trackers: { beta: { baseUrl: 'http://127.0.0.1:9', project: 1 } },
|
||||
tasks: async () => ({ handle: async (cap, verb) => (seen.push(verb), { routed: 'vikunja' }), timeout: 1000, close: async () => closed++ }),
|
||||
});
|
||||
try {
|
||||
const [acme, beta] = runtime.launches.map((l) => new Client({ path: runtime.path, cap: l.cap }));
|
||||
await acme.call('role.claim');
|
||||
await beta.call('role.claim');
|
||||
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const request = runtime.broker.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
|
||||
const made = await acme.call('task.create', { title: 'From the socket', request, requirement: 'REQ-TASK-1' });
|
||||
assert.equal(made.task_ref, 'task:1');
|
||||
assert.deepEqual(await beta.call('task.create', { title: 'x', request: 'r', requirement: 'REQ-TASK-1' }), { routed: 'vikunja' });
|
||||
assert.deepEqual(seen, ['task.create']);
|
||||
// The Console's read: the `tasks` verb on the organization's reader cap, the same rows either way.
|
||||
const rows = runtime.broker.request(runtime.broker.bindReader({ business: 'acme' }), { verb: 'tasks' });
|
||||
assert.deepEqual(rows.map((r) => [r.task_ref, r.source, r.fields.title]), [['task:1', 'store', 'From the socket']]);
|
||||
assert.equal(syncCode(() => runtime.broker.request(runtime.broker.bindHuman({ business: 'beta', human: 'jason', via: 'cli', outsideAgent: true }), { verb: 'tasks.create', args: { title: 'x' } })), 'tracker-not-store');
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
assert.equal(closed, 1);
|
||||
// A business that names the store while the boot config also gives it a Vikunja tracker refuses the boot.
|
||||
await assert.rejects(
|
||||
startBroker({ dataRoot: root, businesses: { acme: business('acme', { plugins: { 'task-tracker': 'store' } }) }, trackers: { acme: { baseUrl: 'http://127.0.0.1:9', project: 1 } } }),
|
||||
(e) => e.code === 'tracker-config',
|
||||
);
|
||||
assert.equal(existsSync(join(root, 'bus/writer.lock')), false);
|
||||
});
|
||||
|
||||
test('review: an unknown task-tracker module must refuse boot', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'unknown-'));
|
||||
t.after(() => rmSync(root, {recursive:true, force:true}));
|
||||
let runtime;
|
||||
try {
|
||||
await assert.rejects(async () => {
|
||||
runtime = await startBroker({dataRoot:root, businesses:{acme:business('acme', {plugins:{harness:['pi'], 'task-tracker':'no-such-tracker'}})}});
|
||||
}, (e) => e.code === 'tracker-config');
|
||||
} finally { await runtime?.close(); }
|
||||
// Startup cleanup releases the same store so a corrected configuration can start.
|
||||
const corrected = await startBroker({dataRoot:root, businesses:{acme:business('acme')}});
|
||||
await corrected.close();
|
||||
});
|
||||
|
||||
test('review: a stale expect must reject a delete after an intervening archive', async (t) => {
|
||||
const f=await org(t);
|
||||
const old=f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Read before archiving'});
|
||||
const archived=f.person('tasks.archive', {task_ref:old.task_ref});
|
||||
assert.equal(archived.revision, old.revision+1);
|
||||
assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:old.digest})), 'task-conflict');
|
||||
const unarchived = f.person('tasks.unarchive', {task_ref:old.task_ref});
|
||||
assert.equal(syncCode(()=>f.person('tasks.edit', {task_ref:old.task_ref, title:'stale', expect:old.digest})), 'task-conflict');
|
||||
assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:archived.digest})), 'task-conflict');
|
||||
assert.equal(f.person('tasks.edit', {task_ref:old.task_ref, title:'current', expect:unarchived.digest}).fields.title, 'current');
|
||||
});
|
||||
|
||||
test('review: human tasks can cite the PRD-required requirement id', async (t) => {
|
||||
const f=await org(t);
|
||||
const made=f.person('tasks.create', { title:'Traceable',requirement:'REQ-TASK-1'});
|
||||
assert.equal(made.fields.requirement,'REQ-TASK-1');
|
||||
assert.equal(syncCode(() => f.person('tasks.create', {title:'Missing'})), 'invalid-request');
|
||||
for (const requirement of [null, '', 'REQ-foo-1', 'REQ-TASK-0']) {
|
||||
assert.equal(syncCode(() => f.person('tasks.create', {title:'Invalid', requirement})), 'invalid-request');
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:made.task_ref, requirement})), 'invalid-request');
|
||||
}
|
||||
const edited = f.person('tasks.edit', {task_ref:made.task_ref, requirement:'REQ-TASK-2'});
|
||||
assert.equal(edited.fields.requirement, 'REQ-TASK-2');
|
||||
assert.equal(f.store.get('SELECT kind FROM events WHERE id=?', edited.event).kind, 'human.input');
|
||||
});
|
||||
|
||||
test('review: a newly hired assignee can use the store after the business reloads', async (t) => {
|
||||
const f=await org(t);
|
||||
const made=await f.create('For a new coder');
|
||||
const next=business('acme');
|
||||
next.roles.newcoder=structuredClone(next.roles.coder);
|
||||
f.broker.reloadBusiness('acme',next);
|
||||
f.person('tasks.edit',{task_ref:made.task_ref,assignees:['newcoder']});
|
||||
await f.call('newcoder','task.update.assigned',{task_ref:made.task_ref,state:'in-progress'});
|
||||
assert.equal(f.read({task_ref:made.task_ref}).fields.state,'in-progress');
|
||||
});
|
||||
|
||||
test('review: deleting an assignee does not prevent archiving the existing task', async (t) => {
|
||||
const f=await org(t);
|
||||
const made=f.person('tasks.create',{requirement:'REQ-TASK-1',title:'Assigned history',assignees:['coder']});
|
||||
const next=business('acme'); delete next.roles.coder; next.retired=['coder'];
|
||||
f.broker.reloadBusiness('acme',next);
|
||||
const edited=f.person('tasks.edit',{task_ref:made.task_ref,title:'Edited',assignees:['coder']});
|
||||
assert.deepEqual(edited.fields.assignees,['coder']);
|
||||
assert.equal(syncCode(() => f.person('tasks.edit',{task_ref:made.task_ref,assignees:['coder','missing']})), 'unknown-role');
|
||||
const archived=f.person('tasks.archive',{task_ref:made.task_ref});
|
||||
assert.equal(archived.archived,true);
|
||||
assert.deepEqual(archived.fields.assignees,['coder']);
|
||||
f.person('tasks.delete',{task_ref:made.task_ref});
|
||||
const last=JSON.parse(f.store.get('SELECT fields FROM store_tasks ORDER BY seq DESC LIMIT 1').fields);
|
||||
assert.deepEqual(last.assignees,['coder']);
|
||||
});
|
||||
|
||||
test('review: saturated and adjacent positions refuse without a write', async (t) => {
|
||||
const f = await org(t);
|
||||
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Moved', group:'other'});
|
||||
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Tail', group:'target', position:1_000_000_000});
|
||||
const before = f.store.get('SELECT count(*) n FROM store_tasks').n;
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'target'})), 'task-position-full');
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before);
|
||||
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'A', group:'adjacent', position:10});
|
||||
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'B', group:'adjacent', position:11});
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'adjacent', position:10})), 'task-position-full');
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before + 2);
|
||||
});
|
||||
@@ -301,12 +301,15 @@ test('the process loads the S3 adapter from plain-data trackers', async (t) => {
|
||||
const bad = await boot(t, { dataRoot: root, businesses, launches, trackers: { nope: { baseUrl: 'http://127.0.0.1:9', project: 1 } } });
|
||||
assert.deepEqual(bad.reply, { ok: false, error: 'tracker-config' });
|
||||
await once(bad.child, 'exit');
|
||||
// An empty tracker map loads the adapter; a business without an entry refuses task verbs.
|
||||
// An empty tracker map loads the adapter. U4a (#1873): a business without an entry gets the
|
||||
// built-in store, not Vikunja. These fixture roles carry no definition, so the store refuses the
|
||||
// caller (tracker-role) where the Vikunja adapter refused tracker-unconfigured; task-store.test.mjs
|
||||
// runs the store's verbs.
|
||||
const { child, reply } = await boot(t, { dataRoot: root, businesses, launches, trackers: {} });
|
||||
assert.equal(reply.ok, true);
|
||||
const pm = new Client({ path: reply.path, cap: reply.launches[0].cap });
|
||||
await pm.call('role.claim');
|
||||
await assert.rejects(pm.call('task.create', { title: 'x' }), /tracker-unconfigured/);
|
||||
await assert.rejects(pm.call('task.create', { title: 'x' }), /tracker-role/);
|
||||
const exit = once(child, 'exit');
|
||||
child.send({ op: 'close' });
|
||||
assert.equal((await exit)[0], 0);
|
||||
|
||||
@@ -49,7 +49,7 @@ async function database3d(t) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
|
||||
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
|
||||
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
|
||||
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
|
||||
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3u ready', ''].join('\n'));
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3v ready', ''].join('\n'));
|
||||
});
|
||||
@@ -403,12 +403,12 @@ test('a 3l store migrates to 3m: thread_shares is new and empty, and every other
|
||||
const before = tables(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
const after = tables(path);
|
||||
assert.deepEqual(after.thread_shares, []);
|
||||
delete after.thread_shares;
|
||||
// Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too.
|
||||
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types']) {
|
||||
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) {
|
||||
assert.deepEqual(after[name], [], name);
|
||||
delete after[name];
|
||||
}
|
||||
@@ -439,7 +439,7 @@ async function engine3l(root) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3l';")
|
||||
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3l';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -456,7 +456,7 @@ test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it wa
|
||||
const Store3l = await engine3l(root);
|
||||
assert.throws(() => new Store3l(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3u');
|
||||
assert.equal(version(path), '3v');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(tables(path), before);
|
||||
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
|
||||
|
||||
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
|
||||
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
|
||||
|
||||
@@ -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";
|
||||
@@ -74,7 +74,8 @@ export function readAccounting({ dataRoot,business,start,end }) {
|
||||
const db = new DatabaseSync(join(dataRoot,'bus/bus.sqlite'),{readOnly:true});
|
||||
try {
|
||||
db.exec('BEGIN');
|
||||
if (!['3s','3t','3u'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
|
||||
// U4a (#1873) 3u-3v adds only store_tasks, which accounting never reads, so 3v joins the closed list.
|
||||
if (!['3s','3t','3u','3v'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
|
||||
const store = { all:(sql,...args) => db.prepare(sql).all(...args) };
|
||||
const records = turnRecords(store,business);
|
||||
const raw = store.all("SELECT * FROM usage_records WHERE business=? AND source<>'@proxy/cliproxy-queue' ORDER BY seq",business);
|
||||
|
||||
@@ -648,7 +648,12 @@ async function bus(rest, io, deps) {
|
||||
// A dev fixture serves only the organizations that use it; their launches carry no provider credential.
|
||||
const orgDoc = instance ? JSON.parse(readFileSync(file, "utf8")) : undefined;
|
||||
const launchEnv = instance ? fixtureLaunchEnv(orgDoc, io.env) : io.env;
|
||||
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker: Boolean(boot.trackers?.[businessId]), launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
|
||||
// U4a (#1873): agents get the task tools when the bus serves task verbs for the organization: a
|
||||
// Vikunja tracker in the boot config, or the built-in store, which an organization with no
|
||||
// tracker gets (the bus runtime makes the same choice, runtime.mjs trackerOf).
|
||||
const taskModule = boot.businesses?.[businessId]?.plugins?.["task-tracker"];
|
||||
const tracker = Boolean(boot.trackers?.[businessId]) || taskModule === undefined || taskModule === "store";
|
||||
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
|
||||
await launcher.listen();
|
||||
if (instance) io.stdout.write(`DEV LOCAL OWNER MODE: console owner local-dev accepted (named instance ${instance})\n`);
|
||||
if (instance && parsed.flags.has("--pm")) io.stdout.write('DEV LOCAL OWNER MODE: launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only\n');
|
||||
|
||||
@@ -83,6 +83,9 @@ export function trailSummary(r) {
|
||||
return `${r.op} ${r.role} by ${r.holder_run}`;
|
||||
case "task_snapshots":
|
||||
return `snapshot ${r.task_ref ?? ""}`.trim();
|
||||
// U4a (#1873): a built-in store task's write: what it did, under which verb, by which role or person.
|
||||
case "store_tasks":
|
||||
return [r.op, r.verb, `r${r.revision}`, `by ${r.by_role ?? r.by_human}`, r.fields?.title ? one(r.fields.title, 100) : null].filter(Boolean).join(" ");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -168,6 +168,21 @@ test("agents and tasks print through the broker", async (t) => {
|
||||
assert.equal(tasks.io.out.text, "tasks: none\n");
|
||||
});
|
||||
|
||||
test("U4a: tasks and trail print the organization's built-in store tasks", async (t) => {
|
||||
const s = setup(t);
|
||||
s.b.request(s.human, { verb: "tasks.create", args: { title: "Plan the store", requirement: "REQ-TASK-1" } });
|
||||
s.b.request(s.human, { verb: "tasks.edit", args: { task_ref: "task:1", state: "in-progress" } });
|
||||
const tasks = await s.run(["tasks", "--business", "demo"]);
|
||||
assert.equal(tasks.code, 0, tasks.io.err.text);
|
||||
assert.equal(tasks.io.out.text, "task:1 in-progress Plan the store\n");
|
||||
const trail = await s.run(["trail", "task:1", "--business", "demo"]);
|
||||
assert.equal(trail.code, 0, trail.io.err.text);
|
||||
const lines = trail.io.out.text.trimEnd().split("\n");
|
||||
assert.equal(lines[0], `trail task:1: ${s.read("trail", { subject: "task:1" }).length} row(s)`);
|
||||
assert.ok(lines.some((l) => / {2}store_tasks#1 {2}create {2}tasks\.create {2}r1 {2}by jason {2}Plan the store$/.test(l)), trail.io.out.text);
|
||||
assert.ok(lines.some((l) => / {2}store_tasks#2 {2}change {2}tasks\.edit {2}r2 {2}by jason {2}Plan the store$/.test(l)), trail.io.out.text);
|
||||
});
|
||||
|
||||
test("notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes", (t) => {
|
||||
const dataRoot = tmp(t);
|
||||
assert.throws(() => readNotifyConfig(dataRoot, "acme"), (e) => e.exitCode === 3 && /no notifier config/.test(e.message));
|
||||
|
||||
@@ -37,3 +37,14 @@ test("the trail keeps the broker's order and names a decision's task without its
|
||||
test("tasks print the tracker fields the snapshot carries", () => {
|
||||
assert.equal(formatTasks([{ task_ref: "vikunja:1/7", fields: { title: "Build S4", done: false } }]), "vikunja:1/7 open Build S4\n");
|
||||
});
|
||||
|
||||
test("U4a: store tasks print like tracker tasks, and their trail rows name the write", () => {
|
||||
assert.equal(formatTasks([{ task_ref: "task:3", source: "store", fields: { title: "Plan", state: "in-progress", done: false } }]), "task:3 in-progress Plan\n");
|
||||
const rows = [
|
||||
{ at: "2026-11-09T10:00:00Z", table: "store_tasks", seq: 1, op: "create", verb: "task.create", revision: 1, by_role: "pm", by_human: null, fields: { title: "Plan" } },
|
||||
{ at: "2026-11-09T11:00:00Z", table: "store_tasks", seq: 2, op: "archive", verb: "tasks.archive", revision: 2, by_role: null, by_human: "jason", fields: { title: "Plan" } },
|
||||
];
|
||||
const lines = formatTrail("task:3", rows).trimEnd().split("\n");
|
||||
assert.equal(lines[1], "2026-11-09T10:00:00Z store_tasks#1 create task.create r1 by pm Plan");
|
||||
assert.equal(lines[2], "2026-11-09T11:00:00Z store_tasks#2 archive tasks.archive r2 by jason Plan");
|
||||
});
|
||||
@@ -306,7 +306,7 @@ test('#811 migration readers follow the engine order and refuse malformed pairs'
|
||||
assert.notEqual(engineSource,source);
|
||||
const engine=await import(`data:text/javascript;base64,${Buffer.from(engineSource).toString('base64')}`);
|
||||
const pairs=engine.migrations.map(({from,to})=>[from,to]);
|
||||
assert.equal(pairs.length,19);assert.deepEqual(pairs.at(-1),['3t','3u']);
|
||||
assert.equal(pairs.length,20);assert.deepEqual(pairs.at(-1),['3u','3v']);
|
||||
const release=readFileSync(join(ROOT,'scripts/stack-release.sh'),'utf8');
|
||||
const fn=(name)=>{const m=release.match(new RegExp(`^${name}\\(\\) \{[\\s\\S]*?^\}`, 'm'));assert.ok(m,name);return m[0]};
|
||||
const functions=['copy_schema','target_schema','plan_steps'].map(fn).join('\n');
|
||||
@@ -943,7 +943,10 @@ test('T46 row 181 item 4: every table in the bus schema is in exactly one of fou
|
||||
const db=new DatabaseSync(':memory:');db.exec(readFileSync(join(ROOT,'packages','bus','schema.sql'),'utf8'));
|
||||
const tables=db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all().map((r)=>r.name);
|
||||
const hasAt=(n)=>db.prepare(`SELECT count(*) AS n FROM pragma_table_info(?) WHERE name='at'`).get(n).n===1;
|
||||
const always={decisions:'decision.raise writes action.allowed in the same transaction'};
|
||||
const always={
|
||||
decisions:'decision.raise writes action.allowed in the same transaction',
|
||||
store_tasks:'U4a #1873: each row goes in one transaction with the action.allowed or human.input events row it cites',
|
||||
};
|
||||
const untimed={
|
||||
gitea_grants:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
|
||||
gitea_users:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -93,6 +93,15 @@ export const KINDS = Object.freeze({
|
||||
theme: Object.freeze({
|
||||
kind: "theme", interfaceVersion: 1, exports: Object.freeze(["theme"]), switch: "list",
|
||||
}),
|
||||
// Where an organization keeps its tasks (U4a, REQ-TASK-1). The organization file names one module;
|
||||
// with none, the bus uses `store`, the task store built into each organization's own bus. `vikunja`
|
||||
// is the optional Vikunja tracker, set up from the boot config's `trackers` as before. Core calls
|
||||
// adapter({ trackers }) -> async ({ broker, credentials, businesses, processing }) -> { handle, timeout, close }
|
||||
// once per bus, where handle(cap, verb, args) answers the eight task verbs for a role's cap and
|
||||
// timeout is in milliseconds. Every module answers the same `tasks` read through the broker.
|
||||
"task-tracker": Object.freeze({
|
||||
kind: "task-tracker", interfaceVersion: 1, exports: Object.freeze(["adapter"]), switch: "one",
|
||||
}),
|
||||
});
|
||||
|
||||
export class PluginError extends Error {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// U4a (#1873, REQ-TASK-1): the default task-tracker module, the task store built into each
|
||||
// organization's own bus. It needs no setting and no credential; packages/tasks/src/store.mjs has it.
|
||||
import { storeAdapter } from '../../../tasks/src/store.mjs';
|
||||
|
||||
export const interfaceVersion = 1;
|
||||
|
||||
export function adapter() {
|
||||
return storeAdapter();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// U4a (#1873): the optional Vikunja task-tracker module. It is the Vikunja adapter that was the only
|
||||
// tracker before, unchanged, set up from the boot config's `trackers` (packages/tasks/src/adapter.mjs).
|
||||
import { tasksAdapter } from '../../../tasks/src/adapter.mjs';
|
||||
|
||||
export const interfaceVersion = 1;
|
||||
|
||||
export function adapter({ trackers = {} } = {}) {
|
||||
return tasksAdapter({ trackers });
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs";
|
||||
const code = expected => e => e instanceof PluginError && e.code === expected;
|
||||
|
||||
test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1, limit-check at 2 (row 125), each a list", () => {
|
||||
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "system-type", "theme"]);
|
||||
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "system-type", "theme", "task-tracker"]);
|
||||
assert.deepEqual({ ...KINDS["task-tracker"], exports: [...KINDS["task-tracker"].exports] }, { kind: "task-tracker", interfaceVersion: 1, exports: ["adapter"], switch: "one" });
|
||||
assert.ok(Object.isFrozen(KINDS["task-tracker"]) && Object.isFrozen(KINDS["task-tracker"].exports));
|
||||
// Stream S4 (PLAN C5): a theme is data. interfaceVersion 1, one export, a list switch.
|
||||
assert.deepEqual({ ...KINDS.theme, exports: [...KINDS.theme.exports] }, { kind: "theme", interfaceVersion: 1, exports: ["theme"], switch: "list" });
|
||||
assert.ok(Object.isFrozen(KINDS.theme) && Object.isFrozen(KINDS.theme.exports));
|
||||
|
||||
@@ -12,7 +12,7 @@ import { validateBusinessDocument } from "../../organization/src/organization-co
|
||||
|
||||
const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] };
|
||||
// What loaded() reports for GOOD: search-source is a kind too, switched off here.
|
||||
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] };
|
||||
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] };
|
||||
|
||||
// A tree with one good module of each kind, already loaded.
|
||||
async function loadedTree(t, options) {
|
||||
@@ -56,7 +56,7 @@ test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization
|
||||
const loader = await s.loader();
|
||||
const result = await loader.load(business.plugins);
|
||||
assert.deepEqual(result.refused, []);
|
||||
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
|
||||
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
|
||||
assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage");
|
||||
assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit");
|
||||
assert.equal(loader.modules("limit-check")[0].mustStop(), false);
|
||||
@@ -170,7 +170,7 @@ test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses a
|
||||
assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/);
|
||||
assert.deepEqual(result.refused[0].kept, ["meter"]);
|
||||
assert.deepEqual(s.loader.logged.slice(before), result.refused);
|
||||
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
|
||||
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
|
||||
} finally {
|
||||
if (lock) chmodSync(lock, 0o755);
|
||||
}
|
||||
@@ -232,7 +232,7 @@ test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and t
|
||||
const loader = await s.loader();
|
||||
// meter loads, absent refuses: the whole kind stays off, not half on.
|
||||
const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
|
||||
assert.deepEqual(loader.loaded(), result.loaded);
|
||||
assert.deepEqual(loader.modules("usage-source"), []);
|
||||
assert.equal(loader.logged.length, 1);
|
||||
@@ -260,7 +260,7 @@ test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole se
|
||||
await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/);
|
||||
// An absent key switches the kind off.
|
||||
result = await s.loader.load({ "limit-check": ["cap"] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] });
|
||||
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
|
||||
});
|
||||
|
||||
test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { BusError, TASK_VERBS, ASSIGNED_STATES, STORE_POSITION_MAX } from '../../bus/src/broker.mjs';
|
||||
import { loadFields } from './fields.mjs';
|
||||
// U4a (#1873, REQ-TASK-1): the task store built into each organization, the default task-tracker
|
||||
// module. It answers the eight task verbs as verbs.mjs does for Vikunja, with the same arguments
|
||||
// where the store has the thing they name, and writes through broker.storeTask, which keeps the rows
|
||||
// in the organization's own bus.sqlite. Nothing here talks to a network or holds a credential.
|
||||
//
|
||||
// What the module checks before the broker sees the call, in this order:
|
||||
// - the caller is one of the organization's roles (tracker-role);
|
||||
// - the field table's writer for the verb (packages/tasks/fields.json): a pm verb needs the pm
|
||||
// definition (field-writer), task.update.assigned needs the role among the task's assignees
|
||||
// (not-assigned), task.scope.change any role;
|
||||
// - the arguments, as verbs.mjs takes them. The store has no Vikunja projects, relations or scoped
|
||||
// requirement snapshots, so `project`, `relations`, `project_key` and `requirement_snapshot`
|
||||
// refuse (invalid-request). task.schedule also takes `group` and `position`, task.create too.
|
||||
// The broker then holds the write to the fields STORE_VERB_FIELDS gives the verb, whatever this
|
||||
// module asked for, and checks the holder, the authority and a close's verdict as it does for Vikunja.
|
||||
export const STORE_TIMEOUT = 60000;
|
||||
const refuse = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
function keys(x, allowed, required = []) {
|
||||
if (!object(x) || Object.keys(x).some((k) => !allowed.includes(k)) || required.some((k) => x[k] === undefined))
|
||||
refuse('invalid-request');
|
||||
}
|
||||
const text = (x, max, min = 0) => {
|
||||
if (typeof x !== 'string' || x.length < min || x.length > max) refuse('invalid-request');
|
||||
return x;
|
||||
};
|
||||
const canonical = (x) =>
|
||||
typeof x === 'string' &&
|
||||
/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) &&
|
||||
Number.isFinite(Date.parse(x)) &&
|
||||
new Date(x).toISOString() === x;
|
||||
const due = (x) => (x === null || canonical(x) ? x : refuse('invalid-request'));
|
||||
const priority = (x) => (Number.isSafeInteger(x) && x >= 0 && x <= 5 ? x : refuse('invalid-request'));
|
||||
const percent = (x) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1 ? x : refuse('invalid-request'));
|
||||
const ids = (list) => {
|
||||
if (!Array.isArray(list) || list.length > 50 || list.some((x) => !Number.isSafeInteger(x) || x < 1)) refuse('invalid-request');
|
||||
return [...new Set(list)];
|
||||
};
|
||||
const group = (x) => (x === null || (typeof x === 'string' && x.length <= 64 && x.trim() && !/[\u0000-\u001f\u007f]/.test(x)) ? x : refuse('invalid-request'));
|
||||
const position = (x) => (Number.isSafeInteger(x) && x >= 0 && x <= STORE_POSITION_MAX ? x : refuse('invalid-request'));
|
||||
const decision = (x) => (x === undefined ? null : text(x, 128, 1));
|
||||
// Arguments a Vikunja verb takes that name something the store doesn't have.
|
||||
const VIKUNJA_ONLY = ['project', 'relations', 'project_key', 'requirement_snapshot'];
|
||||
const COMMON = ['task_ref', 'decision', 'expect'];
|
||||
|
||||
export function storeAdapter({ fields = loadFields() } = {}) {
|
||||
const writers = fields.verbs;
|
||||
return async function factory({ broker }) {
|
||||
let closed = false;
|
||||
function prepare(verb, args, roles, role) {
|
||||
if (object(args) && VIKUNJA_ONLY.some((k) => Object.hasOwn(args, k))) refuse('invalid-request');
|
||||
if (args?.expect !== undefined && !(typeof args.expect === 'string' && /^[0-9a-f]{64}$/.test(args.expect))) refuse('invalid-request');
|
||||
switch (verb) {
|
||||
case 'task.create': {
|
||||
keys(args, ['title', 'description', 'due_date', 'priority', 'labels', 'request', 'requirement', 'decision', 'op', 'group', 'position'], ['title', 'request', 'requirement']);
|
||||
const set = { title: text(args.title, 250, 1) };
|
||||
if (!set.title.trim()) refuse('invalid-request');
|
||||
if (args.description !== undefined) set.description = text(args.description, 65536);
|
||||
if (args.due_date !== undefined) set.due_date = due(args.due_date);
|
||||
if (args.priority !== undefined) set.priority = priority(args.priority);
|
||||
if (args.labels !== undefined) set.labels = ids(args.labels).sort((a, b) => a - b);
|
||||
if (args.group !== undefined) set.group = group(args.group);
|
||||
if (args.position !== undefined) set.position = position(args.position);
|
||||
set.request = text(args.request, 64, 1);
|
||||
set.requirement = text(args.requirement, 64);
|
||||
if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(set.requirement)) refuse('invalid-request');
|
||||
if (args.op !== undefined) {
|
||||
set.op = text(args.op, 256, 1);
|
||||
if (/[;\r\n]/.test(set.op)) refuse('invalid-request');
|
||||
}
|
||||
return (f) => ({ ...f, ...set });
|
||||
}
|
||||
case 'task.assign':
|
||||
case 'task.reassign': {
|
||||
keys(args, [...COMMON, 'role'], ['task_ref', 'role']);
|
||||
const to = text(args.role, 64, 1);
|
||||
if (!Object.hasOwn(roles, to)) refuse('unknown-role');
|
||||
return (f) => {
|
||||
const held = f.assignees;
|
||||
if (verb === 'task.assign' && held.length) refuse('task-assigned');
|
||||
if (verb === 'task.reassign' && !held.length) refuse('task-unassigned');
|
||||
if (verb === 'task.reassign' && held.length === 1 && held[0] === to) refuse('task-assigned');
|
||||
return { ...f, assignees: [to] };
|
||||
};
|
||||
}
|
||||
case 'task.schedule': {
|
||||
keys(args, [...COMMON, 'due_date', 'labels', 'group', 'position'], ['task_ref']);
|
||||
if (['due_date', 'labels', 'group', 'position'].every((k) => args[k] === undefined)) refuse('invalid-request');
|
||||
const dueDate = args.due_date === undefined ? undefined : due(args.due_date);
|
||||
const labels = { add: [], remove: [] };
|
||||
if (args.labels !== undefined) {
|
||||
keys(args.labels, ['add', 'remove']);
|
||||
labels.add = ids(args.labels.add ?? []);
|
||||
labels.remove = ids(args.labels.remove ?? []);
|
||||
}
|
||||
const g = args.group === undefined ? undefined : group(args.group);
|
||||
const p = args.position === undefined ? undefined : position(args.position);
|
||||
return (f) => {
|
||||
const out = { ...f };
|
||||
if (dueDate !== undefined) out.due_date = dueDate;
|
||||
out.labels = [...new Set([...f.labels.filter((l) => !labels.remove.includes(l)), ...labels.add])].sort((a, b) => a - b);
|
||||
// A move to another group with no position goes to the end of it (the broker settles a null).
|
||||
if (g !== undefined && g !== f.group) Object.assign(out, { group: g, position: p ?? null });
|
||||
else if (p !== undefined) out.position = p;
|
||||
return out;
|
||||
};
|
||||
}
|
||||
case 'task.priority.change': {
|
||||
keys(args, [...COMMON, 'priority'], ['task_ref', 'priority']);
|
||||
const p = priority(args.priority);
|
||||
return (f) => ({ ...f, priority: p });
|
||||
}
|
||||
case 'task.scope.change': {
|
||||
keys(args, [...COMMON, 'title', 'description'], ['task_ref']);
|
||||
const set = {};
|
||||
if (args.title !== undefined) set.title = text(args.title, 250, 1);
|
||||
if (set.title !== undefined && !set.title.trim()) refuse('invalid-request');
|
||||
if (args.description !== undefined) set.description = text(args.description, 65536);
|
||||
if (!Object.keys(set).length) refuse('invalid-request');
|
||||
return (f) => ({ ...f, ...set });
|
||||
}
|
||||
case 'task.update.assigned': {
|
||||
keys(args, [...COMMON, 'percent_done', 'state', 'comment'], ['task_ref']);
|
||||
const pct = args.percent_done === undefined ? undefined : percent(args.percent_done);
|
||||
// done is not a target here: closing is the pm's task.close after a review verdict.
|
||||
if (args.state !== undefined && !ASSIGNED_STATES.includes(args.state)) refuse('invalid-state');
|
||||
const note = args.comment === undefined ? undefined : text(args.comment, 10000, 1);
|
||||
if (pct === undefined && args.state === undefined && note === undefined) refuse('invalid-request');
|
||||
return (f) => {
|
||||
if (!f.assignees.includes(role)) refuse('not-assigned');
|
||||
const out = { ...f };
|
||||
if (pct !== undefined) out.percent_done = pct;
|
||||
if (args.state !== undefined) Object.assign(out, { state: args.state, bucket: args.state });
|
||||
// The store keeps the latest comment and the count; the event records only that there was one.
|
||||
if (note !== undefined) Object.assign(out, { comments: f.comments + 1, comment: { text: note, by: role } });
|
||||
return out;
|
||||
};
|
||||
}
|
||||
case 'task.close': {
|
||||
keys(args, [...COMMON, 'verdict'], ['task_ref', 'verdict']);
|
||||
const verdict = text(args.verdict, 256, 1);
|
||||
return (f) => ({ ...f, state: 'done', bucket: 'done', done: true, verdict });
|
||||
}
|
||||
default:
|
||||
return refuse('unknown-verb');
|
||||
}
|
||||
}
|
||||
return {
|
||||
timeout: STORE_TIMEOUT,
|
||||
async handle(cap, verb, args) {
|
||||
if (closed) refuse('tracker-closed');
|
||||
const me = broker.identity(cap);
|
||||
const roles = broker.taskRoles(me.business);
|
||||
const mine = roles[me.role];
|
||||
if (!mine || typeof mine.definition !== 'string') refuse('tracker-role');
|
||||
if (!TASK_VERBS.includes(verb)) refuse('unknown-verb');
|
||||
if (writers[verb] === 'pm' && mine.definition !== 'pm') refuse('field-writer');
|
||||
const apply = prepare(verb, args, roles, me.role);
|
||||
const creating = verb === 'task.create';
|
||||
const row = broker.storeTask(cap, {
|
||||
verb,
|
||||
task_ref: creating ? null : args.task_ref,
|
||||
decision: decision(args.decision),
|
||||
expect: args.expect ?? null,
|
||||
verdict: verb === 'task.close' ? args.verdict : null,
|
||||
// Reopening a done task is a person's act; no role verb writes to one.
|
||||
change: (f) => {
|
||||
if (f?.done) refuse('task-done');
|
||||
return apply(f);
|
||||
},
|
||||
});
|
||||
return row.deleted ? { task_ref: row.task_ref, deleted: true } : { task_ref: row.task_ref, digest: row.digest, updated: row.updated };
|
||||
},
|
||||
close() {
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -264,9 +264,32 @@
|
||||
|
||||
// ---------------------------------------------------------------- views
|
||||
let inboxCount = null, messageCount = null;
|
||||
// Organization mode (sample 01): Dashboard, Inbox, Tasks, Projects, Side by side, Agents and
|
||||
// Assistant, with Settings pinned to the foot of the sidebar. Queue opens from the organization
|
||||
// dashboard and Docs from its Docs row, so neither is a sidebar item here.
|
||||
function orgSections(route) {
|
||||
const nav = $('console-nav');
|
||||
let foot = $('sections-foot');
|
||||
if (!foot) { foot = document.createElement('div'); foot.id = 'sections-foot'; foot.className = 'ws-foot'; nav.append(foot); }
|
||||
const active = document.activeElement;
|
||||
const focused = $('sections').contains(active) || foot.contains(active) ? active.getAttribute('href') : null;
|
||||
const icon = id => window.MosaicWorkspace?.icon?.(id) ?? `<svg class="i" aria-hidden="true"><use href="/icons.svg#i-${id}"/></svg>`;
|
||||
const n = (count, what) => count > 0 ? `<span class="ws-n hot" aria-label="${count} ${what}">${count}</span>` : '';
|
||||
const items = [['/', 'Dashboard', '', 'board'], ['/inbox', 'Inbox', n(inboxCount?.open, 'open for you'), 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/projects', 'Projects', '', 'folder'], ['/side-by-side', 'Side by side', '', 'columns'], ['/agents', 'Agents', '', 'agents'], ['/messages', 'Assistant', n(messageCount, 'unread messages'), 'talk']];
|
||||
const link = ([h, l, extra, id]) => {
|
||||
const cur = h === '/' ? (route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
|
||||
return `<a href="#${h}" class="s1-section ws-nav"${cur ? ' aria-current="page"' : ''}>${icon(id)}<span class="t">${l}</span>${extra}</a>`;
|
||||
};
|
||||
// No project tree under Projects here: the sidebar's Projects group lists every organization's
|
||||
// projects and opens the same project page, which lists its conversations.
|
||||
$('sections').innerHTML = items.map(item => `<li>${link(item)}</li>`).join('');
|
||||
foot.innerHTML = link(['/settings', 'Settings', '', 'settings']);
|
||||
if (focused) [...$('sections').querySelectorAll('a[href]'), ...foot.querySelectorAll('a[href]')].find(a => a.getAttribute('href') === focused)?.focus({ preventScroll: true });
|
||||
}
|
||||
function sections(route) {
|
||||
if (window.MosaicOrg) return orgSections(route);
|
||||
const focused = $('sections').contains(document.activeElement) ? document.activeElement.getAttribute('href') : null;
|
||||
const items = [['/', 'Dashboard', '', 'board'], ['/projects', 'Projects', '', 'inbox'], ['/inbox', 'Inbox', inboxCount?.open > 0 ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount > 0 ? `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>` : '', 'talk'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
|
||||
const items = [['/', 'Dashboard', '', 'board'], ['/projects', 'Projects', '', 'inbox'], ['/inbox', 'Inbox', inboxCount?.open > 0 ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Assistant', messageCount > 0 ? `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>` : '', 'talk'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
|
||||
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
|
||||
const cur = h === '/' ? (route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
|
||||
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true">${h === '/projects' ? '<path d="M2 5h6l2 2h8v11H2z"/>' : `<use href="/icons.svg${spriteVersion ? `?retry=${spriteVersion}` : ''}#i-${icon}"/>`}</svg>${l}${extra ? ` ${extra}` : ''}</a>${h === '/projects' ? '<div id="project-tree" aria-label="Project conversations"></div>' : ''}</li>`;
|
||||
@@ -668,7 +691,12 @@
|
||||
}
|
||||
}
|
||||
async function agentsView(get) {
|
||||
const claims = await get('agents');
|
||||
let claims;
|
||||
try { claims = await get('agents'); } catch (err) {
|
||||
if (!window.MosaicOrg || err.code === 'signed-out') throw err;
|
||||
if (refusal(err)) forget();
|
||||
return `${await window.MosaicWorkspace.agentGrid()}<div class="banner err" role="alert">The selected organization's held roles could not be read. ${why(err)}</div>`;
|
||||
}
|
||||
let instances = null, threads = [];
|
||||
if (!isTester) {
|
||||
const reads = await Promise.allSettled([get('instances'), get.api('chat/threads')]);
|
||||
@@ -683,7 +711,9 @@
|
||||
for (const claim of claims) if (claim.holder_run && claim.role && !holders.has(claim.holder_run)) holders.set(claim.holder_run, claim.role);
|
||||
const threadSection = await threadClaimsSection(get, holders, threads, !!instances, running);
|
||||
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${txt(holders.get(c.holder_run) || c.role)}<span class="source">run ${txt(c.holder_run)}${!instances ? ', running status unavailable' : running.has(c.holder_run) ? '' : ', not running'}</span></td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
|
||||
return `${head('Agents', readNote(get))}
|
||||
// Organization mode (sample 08): the levels grid carries the page heading; held roles follow.
|
||||
const top = window.MosaicOrg ? await window.MosaicWorkspace.agentGrid(readNote(get)) : head('Agents', readNote(get));
|
||||
return `${top}
|
||||
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.</p>
|
||||
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section>
|
||||
${threadSection}
|
||||
@@ -753,7 +783,7 @@
|
||||
const b = (await get.api('business')).business || {};
|
||||
const orgs = window.MosaicOrg ? await window.MosaicOrg.list : null;
|
||||
if (orgs) await configRead; // orgSetup decides the "New organization" link
|
||||
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}${org.invalid ? ' disabled' : ''}>${txt(org.label || org.id)}${org.invalid ? ' · Invalid' : org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select>${orgSetup ? `<p><a href="${NEW_ORG}">New organization</a></p>` : ''}</section>` : '';
|
||||
const picker = '';
|
||||
const instances = Array.isArray(b.instances) ? b.instances : [], creds = Array.isArray(b.credentials) ? b.credentials : [], actions = Array.isArray(b.actions) ? b.actions : [];
|
||||
const launch = b.launch || {};
|
||||
const authKnown = instances.some(i => i.authority);
|
||||
@@ -1172,7 +1202,7 @@
|
||||
const label=pages.find(([id])=>id===page)?.[1] || 'Appearance';
|
||||
const isDirty=id=>(id==='routing'&&routesDraft)||(id==='limits'&&settingsInputs)||(id==='assistant'&&(Object.keys(assistantDrafts).length||Object.keys(nameDrafts).length))||(id==='agents'&&(instanceDrafts.add||instanceDrafts.change));
|
||||
const dot='<span class="settings-dot"><span class="sr-only">unsaved changes</span></span>';
|
||||
const links=pages.map(([id,name],i)=>`${i===0?'<div class="settings-group">Your browser</div>':i===1?'<div class="settings-group">This instance</div>':''}<a href="#/settings/${id}"${id===page?' aria-current="page"':''}>${name}${isDirty(id)?dot:''}</a>`).join('');
|
||||
const links=pages.map(([id,name],i)=>`${i===0?'<div class="settings-group">Your browser</div>':i===1?'<div class="settings-group">This instance</div>':''}<a href="#/settings/${id}"${id===page?' aria-current="page"':''}>${name}${isDirty(id)?dot:''}</a>`).join('') + (!isTester ? '<div class="settings-group">Members and invites <span class="muted">not built</span></div>' : '');
|
||||
return `${head('Settings','Your browser and this instance.')}<div class="settings-layout"><div><button type="button" id="settings-disclosure" aria-expanded="false" aria-controls="settings-nav"><small>Section</small><strong>${label}${isDirty(page)?dot:''}</strong><span aria-hidden="true">⌄</span></button><nav id="settings-nav" aria-label="Settings sections">${links}</nav></div><div class="settings-content settings-${page}${readonlyPage(page)?' readonly':''}"><h2 id="settings-page-title" tabindex="-1">${label}</h2><p class="settings-helper">${helper}</p>${readonlyPage(page)?'<p class="settings-readonly">Read only. Saving requires signed-in send mode.</p>':''}${body}</div></div>`;
|
||||
}
|
||||
async function settingsView(get, page='appearance') {
|
||||
@@ -1412,10 +1442,20 @@
|
||||
// ---------------------------------------------------------------- router
|
||||
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
|
||||
const routeOf = () => window.MosaicOrg?.route() ?? (location.hash.startsWith('#/') ? location.hash.slice(1) : '/');
|
||||
// A one-line scope note right under a view's page heading.
|
||||
function afterHead(html, note) {
|
||||
const start = html.indexOf('<div class="page-head">'), end = start < 0 ? -1 : html.indexOf('</div></div>', start);
|
||||
const line = `<p class="small muted ws-scope" data-not-built>${esc(note)}</p>`;
|
||||
return end < 0 ? `${line}${html}` : `${html.slice(0, end + 12)}${line}${html.slice(end + 12)}`;
|
||||
}
|
||||
function view(get, path, q, opts, current) {
|
||||
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
|
||||
if (parts.includes(null)) return notFound();
|
||||
const [a, b, c] = parts, n = parts.length;
|
||||
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
|
||||
if (a === 'side-by-side' && n === 1 && window.MosaicOrg) return `${head('Side by side', 'Not built yet.')}${empty('Side by side is not built yet.', 'It arrives with a later unified workspace update. Projects and Agents work today.')}`;
|
||||
if (a === 'inbox' && n === 1 && window.MosaicOrg) return inboxView(get).then(html => afterHead(html, 'The inbox across all organizations is not built yet. This is the selected organization\'s inbox.'));
|
||||
if (a === 'tasks' && n === 1 && window.MosaicOrg) return tasksView(get).then(html => afterHead(html, 'Tasks across all organizations are not built yet. These are the selected organization\'s tasks.'));
|
||||
if (a === 'inbox' && n === 1) return inboxView(get);
|
||||
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
|
||||
if (a === 'messages' && n === 1) return messagesView(get, q);
|
||||
@@ -1441,6 +1481,7 @@
|
||||
function keep() {
|
||||
const a = document.activeElement, view = $('bus-view');
|
||||
return {
|
||||
focusKey: view.contains(a) ? a.dataset?.focusKey || (a.hasAttribute?.('data-bus-restart') ? 'bus-restart' : null) : null,
|
||||
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'), mirror: view.contains(a) ? a.dataset?.mirror : null, h1: view.contains(a) && a.tagName === 'H1',
|
||||
settingsChoice: view.contains(a) && a.type==='radio' ? [a.name,a.value] : null,
|
||||
settingsField: view.contains(a) && a.closest('.settings-content') && (a.dataset?.instanceField || a.dataset?.assistantField || a.dataset?.route || a.dataset?.setting) ? {form:a.closest('form')?.id || null, instance:a.closest('[data-instance]')?.dataset.instance || null, key:a.dataset.instanceField?'instanceField':a.dataset.assistantField?'assistantField':a.dataset.route?'route':'setting', value:a.dataset.instanceField || a.dataset.assistantField || a.dataset.route || a.dataset.setting} : null,
|
||||
@@ -1459,7 +1500,10 @@
|
||||
const dec = k.decide ? $('bus-view').querySelector(k.decide === 'status' ? '#decide-status' : `[data-decide="${k.decide}"]`) : null;
|
||||
const choice=k.settingsChoice && [...$('bus-view').querySelectorAll('input[type=radio]')].find(x=>x.name===k.settingsChoice[0]&&x.value===k.settingsChoice[1]);
|
||||
const setting=k.settingsField && [...$('bus-view').querySelectorAll('[data-instance-field],[data-assistant-field],[data-route],[data-setting]')].find(e=>e.dataset[k.settingsField.key]===k.settingsField.value && (!k.settingsField.form || e.closest('form')?.id===k.settingsField.form) && (!k.settingsField.instance || e.closest('[data-instance]')?.dataset.instance===k.settingsField.instance));
|
||||
if (setting) { setting.focus({preventScroll:true}); if(k.caret && typeof setting.setSelectionRange==='function'){setting.setSelectionRange(k.caret[0],k.caret[1]);setting.scrollTop=k.caret[2];} }
|
||||
// The workspace screens mark their controls with data-focus-key: several Open links share an href.
|
||||
const keyed = k.focusKey ? $('bus-view').querySelector(k.focusKey === 'bus-restart' ? '[data-bus-restart]' : `[data-focus-key="${CSS.escape(k.focusKey)}"]`) : null;
|
||||
if (keyed) keyed.focus({ preventScroll: true });
|
||||
else if (setting) { setting.focus({preventScroll:true}); if(k.caret && typeof setting.setSelectionRange==='function'){setting.setSelectionRange(k.caret[0],k.caret[1]);setting.scrollTop=k.caret[2];} }
|
||||
else if (choice) choice.focus({preventScroll:true});
|
||||
else if (dec) dec.focus({ preventScroll: true });
|
||||
else if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
|
||||
@@ -1557,7 +1601,7 @@
|
||||
let html, state = 'fresh';
|
||||
const live = reader(true);
|
||||
try {
|
||||
if (path !== '/messages' && !['/settings','/settings/appearance','/settings/model-proxy','/settings/organizations'].includes(path)) {
|
||||
if (path !== '/organization' && !(path === '/agents' && window.MosaicOrg) && path !== '/messages' && !['/settings','/settings/appearance','/settings/model-proxy','/settings/organizations'].includes(path)) {
|
||||
try { await live('messages'); }
|
||||
catch (err) { messageCount = null; if (err.status === 403 || err.code === 'signed-out') throw err; }
|
||||
}
|
||||
@@ -1578,6 +1622,7 @@
|
||||
$('bus-view').innerHTML = html; shown = route;
|
||||
syncAppearance(true);
|
||||
if (path.startsWith('/docs/')) paintDoc();
|
||||
if (path === '/organization') window.MosaicConsole?.attachBus(document.querySelector('[data-dashboard-bus]'));
|
||||
if (path === '/settings/model-proxy') proxyCleanup=window.MosaicProxy.mount();
|
||||
if (path === '/settings/organizations') orgCleanup=window.MosaicOrgCreate.mount();
|
||||
sections(path);
|
||||
@@ -1639,7 +1684,7 @@
|
||||
// Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and
|
||||
// tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus
|
||||
// back where it was. It only navigates.
|
||||
const VIEWS = [['#/', 'Dashboard'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/settings/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']];
|
||||
const VIEWS = [['#/', 'Dashboard'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ['#/messages', 'Assistant'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/settings/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']];
|
||||
let pkAll = [], pkShown = [], pkAt = 0, pkBack = null;
|
||||
function pkItems() {
|
||||
const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
|
||||
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script>
|
||||
<script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<body>
|
||||
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
@@ -19,7 +19,7 @@
|
||||
<div id="processing-mobile"></div>
|
||||
<div class="frame">
|
||||
<div id="nav-scrim" hidden></div><nav class="tree" id="console-nav" aria-label="Console"><div class="drawer-head"><strong>Navigation</strong><button type="button" class="btn" id="nav-close">Close</button></div><p id="nav-overflow" class="small muted" hidden>Scroll for more destinations</p><div id="project-context" aria-label="Current project and conversation"></div>
|
||||
<ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Board project filter</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
||||
<ul class="s1-sections" id="sections"></ul><div id="workspace-nav" aria-label="Organizations and projects"></div><div id="board-tree"><h2 class="tree-title">Board project filter</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
||||
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div id="theme-warning" class="banner ref" role="status" hidden><p></p><div class="actions"><button id="theme-clear" type="button" class="btn" hidden>Use the default theme</button></div></div><p id="org-cue" class="org-cue" hidden></p><nav class="breadcrumbs" id="breadcrumbs" aria-label="Breadcrumb"><span aria-current="page">Dashboard</span></nav><div class="page-head" id="board-head"><div><h1>Dashboard</h1><p id="dashboard-business" class="muted">Business overview</p><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
|
||||
<section id="dashboard-summary" aria-label="Dashboard metrics"><div id="dashboard-kpis" class="kpi-grid"></div><p id="dashboard-updated" class="kpi-footer">Not read yet</p></section>
|
||||
<section id="held-section" aria-labelledby="held-title"><h2 id="held-title">Held roles <span id="held-count" class="count">–</span></h2><p class="small muted">The stack's seats: each role a run holds on the bus, from the bus <code>agents</code> read. Machine sessions are under Show machine sessions.</p><div id="held"><div class="skel-rows" aria-busy="true"><span class="sr-only">Reading the held roles.</span><span class="skel"></span><span class="skel"></span></div></div></section>
|
||||
|
||||
@@ -351,3 +351,212 @@ body:not(:has(.project-chat)) .content { padding-bottom:calc(22px + var(--foot-h
|
||||
.settings-content:has(.route-actions){padding-bottom:100px}
|
||||
}
|
||||
.assistant-form textarea[data-assistant-field="vars"] { field-sizing:content; min-height:6em; max-height:16em; overflow-y:auto; }
|
||||
/* Unified workspace, organization mode (PRD 1.20, U2). The sample 01, 03 and 08 layouts, ported
|
||||
from docs/design/samples/2026-11-09-unified-workspace/workspace.css with ws- names, so nothing
|
||||
collides with the Console's own .btn, .chip, .panel, .split, .label or .cfg. */
|
||||
html[data-organizations]{--glyph:"DejaVu Sans","Segoe UI Symbol","Apple Symbols","Noto Sans Symbols 2",var(--font)}
|
||||
@media (min-width:960px){html[data-organizations]{--sidebar-width:240px}}
|
||||
/* An organization's colour for the page's mode: workspace.js sets the three pairs inline. */
|
||||
html[data-organizations] .ws-o{--org:var(--org-l);--org-on:var(--org-on-l)}
|
||||
@media (prefers-color-scheme:dark){html[data-organizations]:not([data-mode="light"]):not([data-mode="dim"]) .ws-o{--org:var(--org-d);--org-on:var(--org-on-d)}}
|
||||
html[data-organizations][data-mode="dark"] .ws-o{--org:var(--org-d);--org-on:var(--org-on-d)}
|
||||
html[data-organizations][data-mode="dim"] .ws-o{--org:var(--org-m);--org-on:var(--org-on-m)}
|
||||
html[data-organizations] .ws-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
||||
html[data-organizations] .ws-label{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
|
||||
html[data-organizations] .ws-line{margin:0;padding:3px 8px 3px 10px;font-size:12px;color:var(--muted);list-style:none}
|
||||
|
||||
/* Sidebar (sample 01). */
|
||||
html[data-organizations] .tree{padding:10px 8px 14px;gap:2px}
|
||||
html[data-organizations] #project-tree{border-bottom:0;padding-bottom:0}
|
||||
html[data-organizations] .drawer-head{background:var(--nav-surface,var(--surface))}
|
||||
html[data-organizations] #workspace-nav{display:flex;flex-direction:column;gap:2px;min-width:0;border:0;padding:0}
|
||||
html[data-organizations] .s1-sections{display:grid;gap:2px;flex-basis:auto;overflow:visible;padding:0}
|
||||
html[data-organizations] .s1-sections>li{width:100%;flex:none}
|
||||
html[data-organizations] .ws-nav,html[data-organizations] .s1-section.ws-nav{display:flex;align-items:center;gap:9px;padding:5px 8px;min-height:0;min-width:0;border:0;border-radius:var(--r);box-shadow:none;background:transparent;color:var(--text);text-decoration:none;font:600 13.5px/1.5 var(--font);flex-wrap:nowrap;white-space:nowrap}
|
||||
html[data-organizations] .ws-nav:hover{background:var(--raised)}
|
||||
html[data-organizations] .ws-nav[aria-current=page],html[data-organizations] .s1-section.ws-nav[aria-current=page]{background:color-mix(in srgb,var(--action) 13%,var(--surface));box-shadow:none;color:var(--strong)}
|
||||
html[data-organizations] .ws-nav svg.i{width:18px;height:18px;stroke-width:1.75;color:inherit}
|
||||
html[data-organizations] .ws-nav[aria-current=page] svg.i{color:var(--action)}
|
||||
html[data-organizations] .ws-nav .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
html[data-organizations] .ws-nav.sel{color:var(--strong)}
|
||||
html[data-organizations] .ws-n{margin-left:auto;font:600 11px var(--mono);color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
html[data-organizations] .ws-n.hot{color:var(--surface);background:var(--danger);border-radius:999px;padding:0 6px}
|
||||
html[data-organizations] .ws-hr{border:0;border-top:1px solid var(--line);margin:6px 4px;width:auto}
|
||||
html[data-organizations] .ws-foot{margin-top:auto;padding-top:8px;border-top:1px solid var(--line)}
|
||||
html[data-organizations] .ws-grp{display:flex;flex-direction:column;gap:1px}
|
||||
html[data-organizations] .ws-grp-h{display:flex;align-items:center;gap:6px;padding:10px 8px 4px;color:var(--muted)}
|
||||
html[data-organizations] .ws-grp-t{display:flex;align-items:center;gap:6px;flex:1;min-width:0;padding:0;margin:0;border:0;background:none;color:inherit;font:inherit;cursor:pointer;text-align:left}
|
||||
html[data-organizations] .ws-grp-t .ws-label{flex:1}
|
||||
html[data-organizations] .ws-grp-h>.ws-label{flex:1}
|
||||
html[data-organizations] .ws-grp-h svg.i{width:14px;height:14px;transition:transform .15s}
|
||||
html[data-organizations] .ws-grp-t[aria-expanded=false] svg.chev{transform:rotate(-90deg)}
|
||||
html[data-organizations] .ws-grp-h .ws-n{margin-left:0}
|
||||
html[data-organizations] .ws-add{display:inline-grid;place-items:center;flex:none;width:22px;height:22px;border-radius:var(--r);color:var(--muted);border:1px solid var(--line)}
|
||||
html[data-organizations] .ws-add:hover{background:var(--raised);color:var(--strong)}
|
||||
html[data-organizations] .ws-marks{display:flex;gap:4px;padding:0 8px 6px 28px}
|
||||
html[data-organizations] .ws-orgs{display:flex;flex-direction:column;gap:1px;list-style:none;margin:0;padding:0}
|
||||
html[data-organizations] .ws-orgs[hidden]{display:none}
|
||||
html[data-organizations] .ws-project-list[hidden]{display:none}
|
||||
html[data-organizations] .ws-seg{display:inline-flex;margin-left:auto;border:1px solid var(--line);border-radius:var(--r);overflow:hidden;background:var(--surface)}
|
||||
html[data-organizations] .ws-seg>button{border:0;background:none;padding:2px 8px;color:var(--muted);font:600 12px/1.5 var(--font);display:inline-flex;align-items:center;white-space:nowrap;cursor:pointer;min-height:0}
|
||||
html[data-organizations] .ws-seg.xs>button{padding:0 6px;font-size:11px}
|
||||
html[data-organizations] .ws-seg [aria-pressed=true]{background:var(--action);color:var(--onAction)}
|
||||
html[data-organizations] .ws-pgroup{display:flex;flex-direction:column;gap:1px;margin-bottom:4px}
|
||||
html[data-organizations] .ws-pgroup-h{display:flex;align-items:center;gap:7px;padding:4px 8px 2px;font-size:12px;font-weight:700;color:var(--strong)}
|
||||
html[data-organizations] .ws-pgroup-h .t{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
html[data-organizations] .ws-projs{margin-left:17px;border-left:2px solid var(--org);display:flex;flex-direction:column;gap:1px}
|
||||
html[data-organizations] .ws-proj{padding:3px 8px 3px 10px;font-weight:500;font-size:13px}
|
||||
html[data-organizations] .ws-proj.flat{padding-left:8px}
|
||||
html[data-organizations] .ws-flat{display:flex;flex-direction:column;gap:1px}
|
||||
|
||||
/* Avatars, status marks, chips, buttons. */
|
||||
html[data-organizations] .ws-av{display:inline-grid;place-items:center;flex:none;width:20px;height:20px;border-radius:5px;background:var(--org,var(--muted));color:var(--org-on,var(--surface));font:700 9.5px/1 var(--font);letter-spacing:.02em}
|
||||
html[data-organizations] .ws-av.xs{width:16px;height:16px;border-radius:4px;font-size:8.5px;letter-spacing:0}
|
||||
html[data-organizations] .ws-av.md{width:28px;height:28px;border-radius:7px;font-size:12px}
|
||||
html[data-organizations] .ws-av.lg{width:44px;height:44px;border-radius:10px;font-size:17px}
|
||||
html[data-organizations] .ws-av.all{background:var(--raised);color:var(--strong)}
|
||||
html[data-organizations] .ws-av.all .mosaic-logo{width:18px;height:18px;fill:currentColor}
|
||||
html[data-organizations] .ws-dot{display:inline-flex;align-items:center;gap:6px;flex:none}
|
||||
html[data-organizations] .ws-dot b{font:400 13px/1 var(--glyph);width:14px;text-align:center}
|
||||
html[data-organizations] .ws-dot.s-working{color:var(--success)}
|
||||
html[data-organizations] .ws-dot.s-waiting{color:var(--warning)}
|
||||
html[data-organizations] .ws-dot.s-error{color:var(--danger)}
|
||||
html[data-organizations] .ws-dot.s-offline,html[data-organizations] .ws-dot.s-idle,html[data-organizations] .ws-dot.s-unknown{color:var(--muted)}
|
||||
html[data-organizations] .ws-nav .ws-dot{margin-left:auto}
|
||||
html[data-organizations] .ws-org-row .ws-dot{font-size:12.5px;font-weight:500}
|
||||
html[data-organizations] .ws-org-row .ws-dot.s-offline,html[data-organizations] .ws-org-row .ws-dot.s-error{color:var(--text)}
|
||||
html[data-organizations] .ws-org-row .ws-dot.s-offline b{color:var(--muted)}
|
||||
html[data-organizations] .ws-org-row .ws-dot.s-error b{color:var(--danger)}
|
||||
html[data-organizations] .ws-btn,html[data-organizations] .ws-busctl [data-bus-restart]{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font:600 13px/1.4 var(--font);color:var(--strong);text-decoration:none;white-space:nowrap;cursor:pointer;box-shadow:none}
|
||||
html[data-organizations] .ws-btn:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-restart]:hover:not(:disabled){background:var(--raised)}
|
||||
html[data-organizations] .ws-btn:disabled,html[data-organizations] .ws-busctl [data-bus-restart]:disabled{color:var(--muted);cursor:not-allowed;opacity:1}
|
||||
html[data-organizations] .ws-btn svg.i{width:15px;height:15px}
|
||||
/* The live bus restart is console-restart.js's own button; its icon sits beside it, outside the
|
||||
part that file repaints. */
|
||||
html[data-organizations] .ws-busctl{position:relative;display:inline-flex;flex-direction:column;align-items:flex-end}
|
||||
html[data-organizations] .ws-busctl .bus-restart{display:inline-flex;flex-direction:column;align-items:flex-end;gap:4px}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart]{padding-left:31px}
|
||||
html[data-organizations] .ws-busctl .ws-busicon{position:absolute;left:11px;top:7px;width:15px;height:15px;pointer-events:none;color:var(--strong);z-index:1}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart-receipt]{font-size:12px;color:var(--muted);max-width:28em;text-align:right}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart-receipt]:empty{display:none}
|
||||
html[data-organizations] .ws-chips{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
|
||||
|
||||
/* Page frame (sample main column). */
|
||||
html[data-organizations] .content:has(.ws){padding-top:18px;padding-inline:24px}
|
||||
body:has([data-org-dashboard]) #breadcrumbs,body:has([data-org-agents]) #breadcrumbs{display:none}
|
||||
html[data-organizations] .ws{font-size:14px;line-height:1.5;color:var(--text);min-width:0}
|
||||
html[data-organizations] .ws h1{margin:0;font-size:28px;font-weight:800;letter-spacing:-.02em;line-height:1.15;color:var(--strong)}
|
||||
html[data-organizations] .ws h1:focus,html[data-organizations] .ws h1:focus-visible{outline:none}
|
||||
html[data-organizations] .ws h2{margin:0;font-size:15px;font-weight:700;color:var(--strong)}
|
||||
html[data-organizations] .ws code,html[data-organizations] .ws .mono{font-family:var(--mono);font-size:12.5px}
|
||||
html[data-organizations] .ws .muted,html[data-organizations] .ws .note{color:var(--muted)}
|
||||
html[data-organizations] .ws .note{font-size:13px}
|
||||
html[data-organizations] .ws-crumb{display:flex;gap:6px;align-items:center;font-size:12.5px;color:var(--muted);margin-bottom:4px}
|
||||
html[data-organizations] .ws-crumb a{color:var(--muted);text-decoration:none}
|
||||
html[data-organizations] .ws-crumb a:hover{color:var(--action);text-decoration:underline}
|
||||
html[data-organizations] .ws-vh{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-bottom:14px}
|
||||
html[data-organizations] .ws-vh .sub{flex-basis:100%;margin:0;font-size:13.5px;color:var(--muted)}
|
||||
html[data-organizations] .ws-right{margin-left:auto;display:flex;flex-wrap:wrap;align-items:flex-start;gap:8px}
|
||||
html[data-organizations] .ws-sec-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;margin:22px 0 8px}
|
||||
html[data-organizations] .ws-sec-h .note{flex:1}
|
||||
html[data-organizations] .ws-sec-h a{margin-left:auto;font-size:13px;font-weight:600;color:var(--action);text-decoration:none}
|
||||
html[data-organizations] .ws-sec-h a:hover{text-decoration:underline}
|
||||
html[data-organizations] .ws-panel{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:16px;margin:0}
|
||||
html[data-organizations] .ws-panel.tight{padding:12px 14px}
|
||||
html[data-organizations] .ws-split{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(0,1fr);gap:16px;align-items:start}
|
||||
html[data-organizations] .ws-split{margin-top:22px}
|
||||
html[data-organizations] .ws-split>section>.ws-sec-h:first-child{margin-top:0}
|
||||
|
||||
/* Organization dashboard (sample 03). */
|
||||
html[data-organizations] .ws-orghead{display:flex;flex-wrap:wrap;align-items:center;gap:14px;margin-bottom:14px}
|
||||
html[data-organizations] .ws-orghead h1{display:flex;align-items:center;gap:10px}
|
||||
html[data-organizations] .ws-orghead h1 svg.i{width:20px;height:20px;color:var(--muted)}
|
||||
html[data-organizations] .ws-meta{flex-basis:100%;display:flex;flex-wrap:wrap;align-items:center;gap:4px 14px;margin:-6px 0 0;padding-left:58px;font-size:13px;color:var(--muted)}
|
||||
html[data-organizations] .ws-meta code{color:var(--text)}
|
||||
html[data-organizations] .ws-meta .ws-dot{color:var(--muted)}
|
||||
html[data-organizations] .ws-meta .ws-dot.s-working b{color:var(--success)}
|
||||
html[data-organizations] .ws-meta .ws-dot.s-offline b,html[data-organizations] .ws-meta .ws-dot.s-error b{color:var(--danger)}
|
||||
html[data-organizations] .ws-typechips,html[data-organizations] .ws-docrow{margin-top:8px}
|
||||
html[data-organizations] .ws-typechips .ws-label,html[data-organizations] .ws-docrow .ws-label{min-width:104px}
|
||||
html[data-organizations] .ws-doc{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:2px 8px;font-size:12.5px;font-weight:600;color:var(--strong);text-decoration:none}
|
||||
html[data-organizations] .ws-doc:hover{background:var(--raised)}
|
||||
html[data-organizations] .ws-doc svg.i{width:14px;height:14px;color:var(--muted)}
|
||||
html[data-organizations] .ws-doc small{font-weight:500;color:var(--muted);font-size:11px}
|
||||
html[data-organizations] .ws-more{font-size:13px;font-weight:600;margin-left:4px;color:var(--action);text-decoration:none}
|
||||
html[data-organizations] .ws-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
|
||||
html[data-organizations] .ws-tile{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:grid;gap:6px;align-content:start}
|
||||
html[data-organizations] .ws-tile.is-empty{grid-column:1/-1;border-style:dashed;background:transparent}
|
||||
html[data-organizations] .ws-empty-t{font-size:13.5px;font-weight:600;color:var(--strong)}
|
||||
html[data-organizations] .ws-tbl{overflow-x:auto;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
|
||||
html[data-organizations] .ws-tbl table{width:100%;border-collapse:collapse;font-size:13px;margin:0}
|
||||
html[data-organizations] .ws-tbl th{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);background:none;white-space:nowrap}
|
||||
html[data-organizations] .ws-tbl td{padding:7px 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);vertical-align:middle}
|
||||
html[data-organizations] .ws-tbl tr:last-child td{border-bottom:0}
|
||||
html[data-organizations] .ws-tbl .t1{color:var(--strong)}
|
||||
html[data-organizations] .ws-tbl .t2{display:block;font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .ws-tbl td.mono{font-family:var(--mono);font-size:12.5px;white-space:nowrap}
|
||||
html[data-organizations] .ws-cfg{display:grid;list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)}
|
||||
html[data-organizations] .ws-cfg li{display:grid;align-items:center;grid-template-columns:18px 132px minmax(0,1fr) auto auto;gap:0 10px;padding:7px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
|
||||
html[data-organizations] .ws-cfg li:last-child{border-bottom:0}
|
||||
html[data-organizations] .ws-cfg svg.i{width:16px;height:16px;color:var(--muted)}
|
||||
html[data-organizations] .ws-cfg .k{font-size:13px;font-weight:600;color:var(--strong)}
|
||||
html[data-organizations] .ws-cfg .v{font-size:12px;color:var(--muted);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
html[data-organizations] .ws-cfg .src-tag{font:500 11px var(--mono);color:var(--accent);white-space:nowrap}
|
||||
html[data-organizations] .ws-cfg .go{font-size:12.5px;font-weight:600;color:var(--action);text-decoration:none;min-width:32px;text-align:right}
|
||||
html[data-organizations] .ws-cfg a.go:hover{text-decoration:underline}
|
||||
html[data-organizations] .ws-cfg li.danger .k,html[data-organizations] .ws-cfg li.danger svg.i,html[data-organizations] .ws-cfg li.danger .go,html[data-organizations] .ws-cfg li.danger .src-tag{color:var(--danger)}
|
||||
|
||||
/* Agents (sample 08). */
|
||||
html[data-organizations] .ws-lvl{display:inline-block;font:700 10.5px/1.5 var(--font);letter-spacing:.05em;text-transform:uppercase;padding:1px 6px;border-radius:3px;margin-right:6px;vertical-align:1px}
|
||||
html[data-organizations] .ws-lvl.root{background:var(--strong);color:var(--surface)}
|
||||
html[data-organizations] .ws-lvl.org{background:color-mix(in srgb,var(--org,var(--action)) 18%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--org,var(--action)) 50%,transparent);color:var(--strong)}
|
||||
html[data-organizations] .ws-lvl.proj{box-shadow:inset 0 0 0 1px var(--line);color:var(--text)}
|
||||
html[data-organizations] .ws-span-band{display:flex;flex-wrap:wrap;align-items:center;gap:8px 10px;padding:8px 12px;margin-bottom:14px;border-radius:8px;border:1px solid var(--action);background:color-mix(in srgb,var(--action) 8%,var(--surface));font-size:13px;color:var(--strong)}
|
||||
html[data-organizations] .ws-span-band .orgs{display:inline-flex;gap:4px}
|
||||
html[data-organizations] .ws-band-note{flex-basis:100%;font-size:12.5px}
|
||||
html[data-organizations] .ws-levels{display:grid;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--surface)}
|
||||
html[data-organizations] .ws-levels>div{padding:10px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);border-right:1px solid color-mix(in srgb,var(--line) 60%,transparent);min-width:0}
|
||||
html[data-organizations] .ws-levels>.endc{border-right:0}
|
||||
html[data-organizations] .ws-levels>.last{border-bottom:0}
|
||||
html[data-organizations] .ws-levels>.lh{background:color-mix(in srgb,var(--raised) 45%,transparent);display:grid;gap:4px;align-content:start}
|
||||
html[data-organizations] .ws-levels>.lh .ws-lvl{justify-self:start}
|
||||
html[data-organizations] .ws-levels>.lh small{font-size:11.5px;color:var(--muted);line-height:1.4}
|
||||
html[data-organizations] .ws-levels>.oh{display:flex;align-items:center;gap:8px;font-weight:700;color:var(--strong);background:color-mix(in srgb,var(--raised) 45%,transparent)}
|
||||
html[data-organizations] .ws-levels>.span{grid-column:2/-1;display:flex;align-items:center}
|
||||
html[data-organizations] .ws-levels .mlabel{display:none;align-items:center;gap:6px;font-size:12px;font-weight:700;color:var(--strong);margin-bottom:4px}
|
||||
html[data-organizations] .ws-agent{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:2px 10px 2px 8px;font-size:12.5px;font-weight:600;background:var(--canvas);margin:2px 4px 2px 0;color:var(--strong)}
|
||||
html[data-organizations] .ws-agent small{color:var(--muted);font-weight:500;font-size:11px}
|
||||
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}
|
||||
|
||||
@media (pointer:coarse){html[data-organizations] .ws-nav,html[data-organizations] .s1-section.ws-nav{min-height:40px}}
|
||||
@media (max-width:759px){
|
||||
html[data-organizations] .content:has(.ws){padding:14px 16px 24px}
|
||||
html[data-organizations] .tree{padding:10px 12px 20px}
|
||||
html[data-organizations] .ws h1{font-size:24px}
|
||||
html[data-organizations] .ws-split,html[data-organizations] .ws-tiles{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-tile.is-empty{grid-column:auto}
|
||||
html[data-organizations] .ws-sec-h .note{flex-basis:100%;order:3}
|
||||
html[data-organizations] .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto}
|
||||
html[data-organizations] .ws-cfg .v{grid-column:2/-1;grid-row:2}
|
||||
html[data-organizations] .ws-meta{padding-left:0;margin-top:0}
|
||||
html[data-organizations] .ws-orghead .ws-right{margin-left:0}
|
||||
html[data-organizations] .ws-busctl,html[data-organizations] .ws-busctl .bus-restart{align-items:flex-start}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart-receipt]{text-align:left}
|
||||
html[data-organizations] .ws-typechips .ws-label,html[data-organizations] .ws-docrow .ws-label{min-width:0}
|
||||
html[data-organizations] .ws-levels{grid-template-columns:minmax(0,1fr)!important}
|
||||
html[data-organizations] .ws-levels>div{border-right:0}
|
||||
html[data-organizations] .ws-levels>.hide-m,html[data-organizations] .ws .hide-m{display:none}
|
||||
html[data-organizations] .ws-levels>.span{grid-column:auto}
|
||||
html[data-organizations] .ws-levels>.last.lh{border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
|
||||
html[data-organizations] .ws-levels .mlabel{display:flex}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Every read the unified workspace screens make: the organization list, the theme table and the
|
||||
// per-organization routes under /api/orgs/<id>/. Nothing here writes. A read that fails throws, so
|
||||
// each caller says "unavailable" for that one part instead of guessing.
|
||||
(() => {
|
||||
const read = async path => {
|
||||
const response = await fetch(path, { cache: 'no-store', redirect: 'manual' });
|
||||
if (!response.ok || response.type === 'opaqueredirect') throw new Error('Read unavailable');
|
||||
return response.json();
|
||||
};
|
||||
const orgPath = (id, path) => `/api/orgs/${encodeURIComponent(id)}/${path}`;
|
||||
const organizations = () => read('/api/orgs');
|
||||
const themes = () => read('/api/themes');
|
||||
const projects = id => read(orgPath(id, 'chat/projects'));
|
||||
const threads = id => read(orgPath(id, 'chat/threads'));
|
||||
const instances = id => read(orgPath(id, 'bus/instances'));
|
||||
// Held roles with the state the bus derives (working, waiting, idle), and which run holds which thread.
|
||||
const agents = id => read(orgPath(id, 'bus/agents'));
|
||||
const claims = id => read(orgPath(id, 'bus/thread.claims'));
|
||||
const queue = id => read(orgPath(id, 'queue'));
|
||||
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)}`));
|
||||
// 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 });
|
||||
})();
|
||||
@@ -0,0 +1,394 @@
|
||||
// The unified workspace screens in organization mode: the sidebar's Organizations and Projects
|
||||
// groups (sample 01), the organization dashboard (sample 03) and the Agents levels (sample 08).
|
||||
// Every read goes through MosaicWorkspaceData (workspace-data.js). Nothing here writes; the bus
|
||||
// restart button is console-restart.js's own control, with its gating and trail unchanged.
|
||||
(() => {
|
||||
'use strict';
|
||||
if (!document.documentElement.hasAttribute('data-organizations')) return;
|
||||
const data = window.MosaicWorkspaceData;
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const enc = encodeURIComponent;
|
||||
const GROUPING = 'mosaic-workspace-project-grouping-v1', ORGS_OPEN = 'mosaic-workspace-orgs-open-v1', PROJECTS_OPEN = 'mosaic-workspace-projects-open-v1';
|
||||
const load = key => { try { return localStorage.getItem(key); } catch { return null; } };
|
||||
const save = (key, value) => { try { localStorage.setItem(key, value); } catch {} };
|
||||
let grouping = load(GROUPING) === 'flat' ? 'flat' : 'org';
|
||||
let expanded = load(ORGS_OPEN) !== 'closed';
|
||||
let projectsOpen = load(PROJECTS_OPEN) !== 'closed';
|
||||
let orgs = null, orgError = false, themes = null, projects = new Map();
|
||||
|
||||
// Icons: the Console sprite where it has one, inline paths from the U0 sample where it does not.
|
||||
const PATHS = {
|
||||
folder: '<path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/>',
|
||||
columns: '<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/>',
|
||||
chevron: '<path d="m6 9 6 6 6-6"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
restart: '<path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/>',
|
||||
doc: '<path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/>',
|
||||
people: '<circle cx="9" cy="8.5" r="3"/><path d="M3.5 19.5a5.5 5.5 0 0 1 11 0M15.5 5.8a3 3 0 0 1 0 5.4M17.5 19.5a5.5 5.5 0 0 0-2.5-4.6"/>',
|
||||
cube: '<path d="M12 3.5 20 8v8l-8 4.5L4 16V8z"/><path d="m4 8 8 4.5L20 8M12 12.5v8"/>',
|
||||
};
|
||||
const icon = (id, cls = '') => `<svg class="i${cls ? ` ${cls}` : ''}"${PATHS[id] ? ' viewBox="0 0 24 24"' : ''} aria-hidden="true" focusable="false">${PATHS[id] || `<use href="/icons.svg#i-${id}"/>`}</svg>`;
|
||||
const LOGO = '<svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg>';
|
||||
|
||||
// An organization's colour is its theme's action colour, per mode, with that mode's onAction for
|
||||
// the initials. The CSS picks the pair for the page's mode (.ws-o in shell.css).
|
||||
const HEX = /^#[0-9a-f]{6}$/i;
|
||||
const themeOf = org => (themes || []).find(t => t.id === (org.theme || 'harbor')) || (themes || []).find(t => t.id === 'harbor');
|
||||
function colours(org) {
|
||||
const tokens = themeOf(org)?.tokens || {};
|
||||
const pick = (mode, name, fallback) => (HEX.test(tokens[mode]?.[name] || '') ? tokens[mode][name] : fallback);
|
||||
const l = [pick('light', '--action', '#2266bf'), pick('light', '--onAction', '#ffffff')];
|
||||
const d = [pick('dark', '--action', '#74a6e7'), pick('dark', '--onAction', '#10141a')];
|
||||
const m = [pick('dim', '--action', d[0]), pick('dim', '--onAction', d[1])];
|
||||
return `--org-l:${l[0]};--org-on-l:${l[1]};--org-d:${d[0]};--org-on-d:${d[1]};--org-m:${m[0]};--org-on-m:${m[1]}`;
|
||||
}
|
||||
// Two words give their first letters (Distinctly Digital, DD); one word gives its first letter and
|
||||
// its last consonant (Forge, FG; Home, HM), as the U0 sample marks them.
|
||||
function initials(org) {
|
||||
const words = String(org.label || org.id || '?').trim().split(/[\s_.-]+/).filter(Boolean);
|
||||
if (words.length > 1) return (words[0][0] + words[1][0]).toUpperCase();
|
||||
const word = words[0] || '?', rest = word.slice(1).replace(/[^a-z0-9]/gi, ''), consonants = rest.match(/[bcdfghjklmnpqrstvwxz]/gi);
|
||||
return (word[0] + (consonants ? consonants[consonants.length - 1] : rest[0] || '')).toUpperCase();
|
||||
}
|
||||
const av = (org, size = '') => `<span class="ws-av ws-o${size ? ` ${size}` : ''}" style="${colours(org)}" aria-hidden="true">${esc(initials(org))}</span>`;
|
||||
const label = org => org.label || org.id;
|
||||
const state = org => (org.invalid ? ['s-error', '▲', 'invalid'] : org.busHealthy ? ['s-working', '●', 'bus running'] : ['s-offline', '○', 'bus unavailable']);
|
||||
// A status mark: a glyph plus its word, shown or read aloud only.
|
||||
const dot = (cls, glyph, word, shown = false) => `<span class="ws-dot ${cls}" title="${esc(word)}"><b aria-hidden="true">${glyph}</b>${shown ? esc(word) : `<span class="ws-sr">${esc(word)}</span>`}</span>`;
|
||||
const orgHref = id => `#/orgs/${enc(id)}`;
|
||||
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
|
||||
const ensureThemes = async () => { if (!themes) themes = await data.themes().then(b => (Array.isArray(b?.themes) ? b.themes : [])).catch(() => []); return themes; };
|
||||
const instancesOf = value => { const v = value?.rows?.[0]?.instances ?? value?.instances; return v && typeof v === 'object' && !Array.isArray(v) ? v : null; };
|
||||
const running = item => (Array.isArray(item?.running) ? item.running.length : 0);
|
||||
const rowsOf = v => (Array.isArray(v) ? v : Array.isArray(v?.rows) ? v.rows : null);
|
||||
// An agent's activity is the state the bus derives for the run holding its role. A live process
|
||||
// alone says only running, never working or idle.
|
||||
const ACTIVITY = { working: ['s-working', '●', 'working'], waiting: ['s-waiting', '◐', 'waiting on you'], idle: ['s-idle', '–', 'idle'] };
|
||||
const activity = (name, item, agents) => {
|
||||
const on = running(item) > 0;
|
||||
const held = on && agents ? agents.find(a => a?.role === name && item.running.includes(a.holder_run)) : null;
|
||||
return ACTIVITY[held?.state] || (on ? ['s-working', '●', 'running'] : ['s-idle', '–', 'not running']);
|
||||
};
|
||||
// The inline model control: the change itself happens on Settings, Routing, with its own checks.
|
||||
const modelCell = (agent, model) => (model ? `${esc(model)} <a class="ws-change" href="#/settings/routing" data-focus-key="${esc(`model:${agent}`)}" aria-label="${esc(`Change the model for ${agent}`)}">Change</a>` : '<span class="muted">not reported</span>');
|
||||
|
||||
// Focus survives a repaint: the focused control's data-focus-key is found again afterwards.
|
||||
const focusKey = root => { const a = document.activeElement; return root && a && root.contains(a) ? a.dataset?.focusKey || null : null; };
|
||||
const refocus = (root, key) => { if (key) root.querySelector(`[data-focus-key="${CSS.escape(key)}"]`)?.focus({ preventScroll: true }); };
|
||||
|
||||
// ---------- sidebar (sample 01) ----------
|
||||
function projectLinks(org, flat) {
|
||||
const rows = projects.get(org.id);
|
||||
if (rows === undefined) return flat ? '' : '<p class="ws-line">Reading projects…</p>';
|
||||
if (rows === null) return `<p class="ws-line">${flat ? `${esc(label(org))}: projects unavailable` : 'Projects unavailable'}</p>`;
|
||||
const live = rows.filter(p => p && !p.archived);
|
||||
if (!live.length) return flat ? '' : '<p class="ws-line">No projects yet</p>';
|
||||
const hash = location.hash;
|
||||
return live.map(p => {
|
||||
const href = `${orgHref(org.id)}/projects/${enc(p.id)}`, current = hash === href || hash.startsWith(`${href}/`);
|
||||
return `<a class="ws-nav ws-proj${flat ? ' flat' : ''}" href="${esc(href)}" data-focus-key="${esc(`proj:${org.id}:${p.id}`)}"${current ? ' aria-current="page"' : ''}>${flat ? av(org, 'xs') : ''}<span class="t">${esc(p.name || p.id)}</span></a>`;
|
||||
}).join('');
|
||||
}
|
||||
function projectGroups(list) {
|
||||
if (grouping === 'flat') {
|
||||
const body = list.map(org => projectLinks(org, true)).join('');
|
||||
if (body) return `<div class="ws-flat">${body}</div>`;
|
||||
return list.some(org => projects.get(org.id) === undefined) ? '<p class="ws-line">Reading projects…</p>' : '<p class="ws-line">No projects yet</p>';
|
||||
}
|
||||
return list.map(org => `<div class="ws-pgroup ws-o" style="${colours(org)}" data-org="${esc(org.id)}"><div class="ws-pgroup-h">${av(org, 'xs')}<span class="t">${esc(label(org))}</span></div><div class="ws-projs">${projectLinks(org, false)}</div></div>`).join('') || '<p class="ws-line">No projects yet</p>';
|
||||
}
|
||||
function paint() {
|
||||
const root = document.getElementById('workspace-nav'); if (!root) return;
|
||||
const key = focusKey(root);
|
||||
if (orgError || !orgs) { root.innerHTML = `<hr class="ws-hr"><p class="ws-line">${orgError ? 'Organizations unavailable' : 'Reading organizations…'}</p>`; return; }
|
||||
const here = route(), selected = window.MosaicOrg?.id;
|
||||
const rows = orgs.map(org => {
|
||||
const [cls, glyph, word] = state(org), mine = org.id === selected, current = mine && here === '/organization';
|
||||
return `<li data-workspace-org="${esc(org.id)}"><a class="ws-nav ws-org-row${mine && !current ? ' sel' : ''}" href="${esc(orgHref(org.id))}/organization" data-focus-key="${esc(`org:${org.id}`)}"${current ? ' aria-current="page"' : ''}>${av(org)}<span class="t">${esc(label(org))}</span>${dot(cls, glyph, word, cls !== 's-working')}</a></li>`;
|
||||
}).join('');
|
||||
const marks = expanded || !orgs.length ? '' : `<div class="ws-marks" aria-hidden="true">${orgs.map(org => av(org, 'xs')).join('')}</div>`;
|
||||
root.innerHTML = `<hr class="ws-hr"><div class="ws-grp"><div class="ws-grp-h"><button type="button" class="ws-grp-t" data-org-toggle data-focus-key="org-toggle" aria-expanded="${expanded}" aria-controls="ws-org-list">${icon('chevron', 'chev')}<span class="ws-label">Organizations</span><span class="ws-n">${orgs.length}</span></button><a class="ws-add" href="#/settings/organizations?new=1" data-focus-key="org-new" aria-label="New organization" title="New organization">${icon('plus')}</a></div>${marks}<ul class="ws-orgs" id="ws-org-list" data-org-list${expanded ? '' : ' hidden'}>${rows || '<li class="ws-line">No organizations yet</li>'}</ul></div><hr class="ws-hr"><div class="ws-grp"><div class="ws-grp-h"><button type="button" class="ws-grp-t" data-projects-toggle data-focus-key="projects-toggle" aria-expanded="${projectsOpen}" aria-controls="ws-project-list">${icon('chevron', 'chev')}<span class="ws-label">Projects</span></button><span class="ws-seg xs" role="group" aria-label="Project layout"><button type="button" data-group="org" data-focus-key="group-org" aria-pressed="${grouping === 'org'}">By org</button><button type="button" data-group="flat" data-focus-key="group-flat" aria-pressed="${grouping === 'flat'}">Flat</button></span></div><div class="ws-project-list" id="ws-project-list"${projectsOpen ? '' : ' hidden'}>${projectGroups(orgs.filter(org => !org.invalid))}</div></div>`;
|
||||
refocus(root, key);
|
||||
}
|
||||
let reading = 0;
|
||||
async function refresh(rows) {
|
||||
const turn = ++reading;
|
||||
try {
|
||||
const [list] = await Promise.all([Array.isArray(rows) ? rows : data.organizations(), ensureThemes()]);
|
||||
if (turn !== reading) return;
|
||||
orgs = Array.isArray(list) ? list : []; orgError = false;
|
||||
} catch { if (turn === reading) { orgError = true; paint(); } return; }
|
||||
paint();
|
||||
const results = await Promise.all(orgs.filter(org => !org.invalid).map(org => data.projects(org.id).then(b => [org.id, Array.isArray(b?.rows) ? b.rows : null]).catch(() => [org.id, null])));
|
||||
if (turn !== reading) return;
|
||||
projects = new Map(results); paint();
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const toggle = e.target.closest?.('[data-org-toggle]');
|
||||
if (toggle) { expanded = !expanded; save(ORGS_OPEN, expanded ? 'open' : 'closed'); paint(); document.querySelector('[data-org-toggle]')?.focus(); return; }
|
||||
if (e.target.closest?.('[data-projects-toggle]')) { projectsOpen = !projectsOpen; save(PROJECTS_OPEN, projectsOpen ? 'open' : 'closed'); paint(); document.querySelector('[data-projects-toggle]')?.focus(); return; }
|
||||
const group = e.target.closest?.('#workspace-nav [data-group]');
|
||||
if (group) { grouping = group.dataset.group === 'flat' ? 'flat' : 'org'; save(GROUPING, grouping); paint(); document.querySelector(`#workspace-nav [data-group="${grouping}"]`)?.focus(); }
|
||||
});
|
||||
window.addEventListener('hashchange', () => { if (orgs) paint(); });
|
||||
document.addEventListener('mosaic-org-list', e => refresh(e.detail?.rows));
|
||||
|
||||
// ---------- organization dashboard (sample 03) ----------
|
||||
const SAFE = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]{0,159}$/;
|
||||
const DOC_PATH = /^(spec\/north-star\.md|docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md)$/;
|
||||
const docCache = new Map();
|
||||
// Docs are read at most once a minute per organization: the dashboard repaints every 10 s.
|
||||
async function docs(id, names) {
|
||||
const hit = docCache.get(id);
|
||||
if (hit && Date.now() - hit.at < 60000) return hit.value;
|
||||
const list = (Array.isArray(names) ? names : []).filter(p => typeof p === 'string' && SAFE.test(p)).slice(0, 6);
|
||||
const read = await Promise.allSettled(list.map(p => data.projectFiles(id, p).then(body => (Array.isArray(body?.files) ? body.files : []).filter(f => typeof f?.path === 'string' && DOC_PATH.test(f.path)).map(f => ({ project: p, path: f.path })))));
|
||||
const value = { rows: read.flatMap(r => (r.status === 'fulfilled' ? r.value : [])), failed: read.length > 0 && read.every(r => r.status === 'rejected') };
|
||||
docCache.set(id, { at: Date.now(), value });
|
||||
return value;
|
||||
}
|
||||
const docName = path => (path === 'spec/north-star.md' ? 'North star' : path.replace(/^.*\//, '').replace(/\.md$/, '').replace(/[-_]+/g, ' ').replace(/^./, c => c.toUpperCase()));
|
||||
const titleCase = s => String(s || '').replace(/(^|[-_\s])(\w)/g, (_, sep, c) => `${sep ? ' ' : ''}${c.toUpperCase()}`);
|
||||
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
||||
const secH = (title, note = '', link = '') => `<div class="ws-sec-h"><h2>${title}</h2>${note ? `<span class="note">${note}</span>` : ''}${link}</div>`;
|
||||
|
||||
function settingsList(org, ctx) {
|
||||
const { business, settings, instances, orgCount } = ctx;
|
||||
const b = business?.business, s = settings;
|
||||
const unread = 'Could not be read';
|
||||
const names = instances ? Object.keys(instances) : null;
|
||||
const models = b && Array.isArray(b.instances) ? [...new Set(b.instances.map(i => i?.vars?.model).filter(m => typeof m === 'string' && m))] : null;
|
||||
const arbiters = b?.arbiters && typeof b.arbiters === 'object' ? Object.entries(b.arbiters).filter(([, v]) => typeof v === 'string').map(([k, v]) => `${v} (${k})`).join(' · ') : null;
|
||||
const total = s?.launch && !s.launch.refused ? s.launch.limits?.total : undefined;
|
||||
const notifier = s?.notifier;
|
||||
const theme = b?.appearance?.theme || org.theme || 'harbor';
|
||||
const rows = [
|
||||
['business', 'Identity', b ? `${label(org)} · ${org.id} · kind not recorded` : unread, 'org file', 'business'],
|
||||
['flag', 'Appearance', `${titleCase(theme)} theme · ${initials(org)} avatar`, b?.appearance?.theme || org.theme ? 'org file' : 'system', 'appearance'],
|
||||
['cube', 'System types', 'Not built yet', 'not built', null],
|
||||
['folder', 'Projects', b ? (Array.isArray(b.projects) && b.projects.length ? plural(b.projects.length, 'project') : 'No projects') : unread, 'org file', '#/projects'],
|
||||
['people', 'Agents and roles', names ? (names.length ? `${plural(names.length, 'agent')} · ${Object.values(instances).filter(running).length} running` : 'No agents') : unread, 'org file', 'agents'],
|
||||
['term', 'Models and routing', models ? (models.length ? models.join(' · ') : 'No models set') : unread, 'org file', 'routing'],
|
||||
['queue', 'Arbiters', b ? arbiters || 'None set' : unread, 'org file', 'business'],
|
||||
['tasks', 'Task tracker', b ? (b.vars?.['tracker.kind'] ? titleCase(b.vars['tracker.kind']) : 'Not set') : unread, 'org file', 'business'],
|
||||
['talk', 'Channels', !s ? unread : typeof notifier?.binding === 'string' ? `Notifier ${notifier.binding}` : notifier?.refused ? unread : 'No notifier set', 'system', 'about'],
|
||||
['ledger', 'Launch limits', !s ? unread : s.launch?.refused ? unread : Number.isFinite(total) ? `${plural(total, 'session')} at once` : 'No session limit', 'org file', 'limits'],
|
||||
['talk', 'Assistant', 'The assistant agent and its seat names', 'org file', 'assistant'],
|
||||
['ledger', 'Token use', 'Usage and limits per provider and model', 'system', 'tokens'],
|
||||
['term', 'Model proxy', 'Accounts, priority and client keys', 'system', 'model-proxy'],
|
||||
['restart', 'Console', 'Console restart', 'system', 'console'],
|
||||
['business', 'Organizations', orgCount === null ? unread : plural(orgCount, 'organization'), 'system', 'organizations'],
|
||||
['flag', 'About', typeof s?.release === 'string' ? `Release ${s.release}` : 'Address, release and notifier', 'system', 'about'],
|
||||
['people', 'Members and invites', 'Not built yet', 'not built', null],
|
||||
['restart', 'Bus', `${org.busHealthy ? 'Running' : 'Unavailable'} · restart from this page's header`, 'system', null],
|
||||
['close', 'Archive or delete', "Archive or delete this organization's file", 'danger', 'organizations'],
|
||||
];
|
||||
return `<ul class="ws-cfg one" data-org-settings>${rows.map(([ic, name, value, tag, page]) => {
|
||||
const href = page === null ? null : page.startsWith('#') ? page : `#/settings/${page}`;
|
||||
const go = href ? `<a class="go" href="${esc(href)}" data-focus-key="${esc(`set:${name}`)}" aria-label="Open ${esc(name)}">Open</a>` : '<span class="go" aria-hidden="true"></span>';
|
||||
return `<li${tag === 'danger' ? ' class="danger"' : ''} data-org-setting="${esc(name)}">${icon(ic)}<span class="k">${esc(name)}</span><span class="v" title="${esc(value)}">${esc(value)}</span><span class="src-tag">${esc(tag)}</span>${go}</li>`;
|
||||
}).join('')}</ul>`;
|
||||
}
|
||||
|
||||
async function dashboard() {
|
||||
const id = window.MosaicOrg.id;
|
||||
// Only an organization the membership list names is read; anything else reads nothing more.
|
||||
const [listRead] = await Promise.all([data.organizations().catch(() => null), ensureThemes()]);
|
||||
const list = Array.isArray(listRead) ? listRead : null;
|
||||
const org = list?.find(o => o.id === id && !o.invalid);
|
||||
if (!org) return `<article class="ws ws-dash" data-org-dashboard data-org-unavailable><div class="ws-vh"><h1 tabindex="-1">Organization unavailable</h1><p class="sub">${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}</p></div></article>`;
|
||||
const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id)]);
|
||||
const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null);
|
||||
const business = value(0), settings = value(1), instances = instancesOf(value(2));
|
||||
const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5));
|
||||
const agents = rowsOf(value(6)), claims = rowsOf(value(7));
|
||||
const docRead = business?.business ? await docs(id, business.business.projects).catch(() => ({ rows: [], failed: true })) : null;
|
||||
await window.MosaicConsole?.ready;
|
||||
const name = esc(label(org));
|
||||
const tracker = business?.business?.vars?.['tracker.kind'];
|
||||
|
||||
// Header: the bus restart is console-restart.js's button where the Console allows it, otherwise
|
||||
// the same control, present and disabled, with the reason.
|
||||
const restart = window.MosaicConsole?.busButton?.('Restart bus') || '';
|
||||
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${icon('restart', 'ws-busicon')}${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="Restarting the bus is the owner's trailed action, on a Console started with send and the action trail">${icon('restart')}Restart bus</button></span>`;
|
||||
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="ws-reload-why" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
|
||||
const [bcls, bglyph] = state(org);
|
||||
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="ws-reload-why">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
|
||||
|
||||
const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid;
|
||||
const typechips = '<div class="ws-typechips ws-chips"><span class="ws-label">System types</span><span class="note">No system types yet. They arrive with the system types update.</span></div>';
|
||||
const docChips = !docRead ? '<span class="note">Docs unavailable</span>' : docRead.failed ? '<span class="note">Docs unavailable</span>' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `<a class="ws-doc" href="#/docs/${enc(d.project)}/${enc(d.path)}" data-focus-key="${esc(`doc:${d.project}:${d.path}`)}" title="${esc(`${d.project}/${d.path}`)}">${icon('doc')}${esc(docName(d.path))}<small>${esc(projectName(d.project))}</small></a>`).join('') : '<span class="note">No docs yet</span>';
|
||||
const docrow = `<div class="ws-docrow ws-chips"><span class="ws-label">Docs</span>${docChips}<a class="ws-more" href="#/docs" data-focus-key="docs-all">All docs</a></div>`;
|
||||
|
||||
const tiles = (title, note, empty) => `${secH(title, note)}<div class="ws-tiles"><div class="ws-tile is-empty"><span class="ws-empty-t">${empty}</span></div></div>`;
|
||||
const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model;
|
||||
const live = instances ? Object.entries(instances).filter(([, item]) => running(item)) : null;
|
||||
// A session's project and task is the thread its live run holds a claim on (thread.claims), not
|
||||
// any older conversation of the same agent. A failed read says unavailable, never "none".
|
||||
const sessionRows = (live || []).map(([agent, item]) => {
|
||||
const held = claims ? claims.filter(c => item.running.includes(c?.holder_run)) : null;
|
||||
const first = held?.[0], thread = first && threads ? threads.find(t => t?.id === first.thread) : null;
|
||||
const model = modelOf(agent), count = running(item), [acls, aglyph, aword] = activity(agent, item, agents);
|
||||
const more = held && held.length > 1 ? ` · ${held.length - 1} more` : '';
|
||||
const where = held === null ? '<span class="muted">Conversation unavailable</span>'
|
||||
: !first ? '<span class="muted">No conversation held right now</span>'
|
||||
: thread ? `${esc(projectName(thread.project))}<span class="t2">${esc(thread.name || thread.id)}${more}</span>`
|
||||
: threads ? `<span class="muted">Conversation</span><span class="t2">${esc(first.thread)}${more}</span>` : '<span class="muted">Conversation unavailable</span>';
|
||||
return `<tr><td>${esc(agent)}</td><td>${where}</td><td class="hide-m mono">${modelCell(agent, model)}</td><td>${dot(acls, aglyph, aword, true)}${count > 1 ? ` <span class="muted">${count} sessions</span>` : ''}</td></tr>`;
|
||||
}).join('');
|
||||
const sessions = live === null ? '<p class="ws-panel tight note">Sessions unavailable. The bus did not answer the instance read.</p>' : live.length ? `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Project and task</th><th scope="col" class="hide-m">Model</th><th scope="col">Status</th></tr></thead><tbody>${sessionRows}</tbody></table></div>` : '<p class="ws-panel tight note">No active agent sessions</p>';
|
||||
const open = queue ? queue.filter(r => r?.state !== 'done').length : null;
|
||||
const queueNote = queue === null ? 'unavailable' : queue.length ? `${plural(queue.length, 'row')}, ${open} not done` : 'no rows';
|
||||
|
||||
return `<article class="ws ws-dash" data-org-dashboard>
|
||||
<nav class="ws-crumb" aria-label="Breadcrumb"><a href="#/settings/organizations">Organizations</a><span aria-hidden="true">›</span><span>${name}</span></nav>
|
||||
<div class="ws-orghead ws-o" style="${colours(org)}">${av(org, 'lg')}<h1 tabindex="-1">${name}</h1><div class="ws-right">${bus}${reload}</div>${meta}</div>
|
||||
${typechips}${docrow}
|
||||
<div class="ws-split">
|
||||
<section aria-label="Measures and sessions">
|
||||
${tiles('KPIs', 'defined in the organization file', 'No KPIs defined in the organization file')}
|
||||
${tiles('Metrics', 'measured by the bus', 'No metrics recorded yet')}
|
||||
${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', '<a href="#/agents" data-focus-key="agents-all">All agents</a>')}
|
||||
${sessions}
|
||||
${secH('Queue', queueNote, '<a href="#/queue" data-org-queue data-focus-key="queue-open">Open queue</a>')}
|
||||
</section>
|
||||
<section aria-label="Settings">
|
||||
${secH('Settings', 'every setting, with where its value comes from')}
|
||||
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })}
|
||||
</section>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
// ---------- 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, 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. 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),
|
||||
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 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. 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>${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 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';
|
||||
|
||||
@@ -58,7 +59,7 @@ const files = new Map([
|
||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
|
||||
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
|
||||
@@ -131,7 +131,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/);
|
||||
|
||||
@@ -137,14 +137,15 @@ for (const [status, body] of [[201, { outcome: 'done', step: 'delete' }], [503,
|
||||
});
|
||||
}
|
||||
|
||||
test('review: deleting a non-current org refreshes the organization switcher', async t => {
|
||||
// U2 (#1873): the sidebar's Organizations group replaced the organization switcher.
|
||||
test('review: deleting a non-current org refreshes the sidebar organization list', async t => {
|
||||
const f = await reviewFixture(t);
|
||||
await f.confirm('beta');
|
||||
assert.deepEqual((await registry.listOrgs(f.ctx)).map(e=>e.id), ['alpha']);
|
||||
await f.b.evaluate(`location.hash='#/orgs/alpha/settings/business'`);
|
||||
await f.wait(`!!document.querySelector('#org-picker')`);
|
||||
assert.deepEqual(await f.b.evaluate(`[...document.querySelector('#org-picker').options].map(e=>e.value)`), ['alpha']);
|
||||
assert.equal(await f.b.evaluate(`document.querySelector('#org-picker').value`), 'alpha');
|
||||
await f.b.evaluate(`location.hash='#/orgs/alpha/organization'`);
|
||||
await f.wait(`document.querySelectorAll('[data-workspace-org]').length === 1`);
|
||||
assert.deepEqual(await f.b.evaluate(`[...document.querySelectorAll('[data-workspace-org]')].map(e=>e.dataset.workspaceOrg)`), ['alpha']);
|
||||
assert.equal(await f.b.evaluate(`document.querySelector('[data-workspace-org="alpha"] a').getAttribute('aria-current')`), 'page');
|
||||
assert.deepEqual(await f.b.evaluate(`window.MosaicOrg.list.then(rows => rows.map(row => row.id))`), ['alpha']);
|
||||
});
|
||||
|
||||
|
||||
@@ -60,15 +60,16 @@ for (const [width, height] of [[1400, 900], [390, 844]]) {
|
||||
});
|
||||
}
|
||||
|
||||
test('the organization picker offers New organization', { timeout: 60000 }, async t => {
|
||||
// U2 (#1873): the sidebar's Organizations group replaced the organization picker.
|
||||
test('the sidebar Organizations group offers New organization', { timeout: 60000 }, async t => {
|
||||
const { base, b, wait, formFocused } = await consoleWith(t, ['alpha', 'beta']);
|
||||
await b.viewport(1400, 900);
|
||||
await b.navigate(`${base}#/orgs/alpha/settings/business`);
|
||||
const link = `document.querySelector('section[aria-labelledby="org-picker-title"] a[href="#/settings/organizations?new=1"]')`;
|
||||
await wait(`document.querySelector('#org-picker') && ${link}`, 'picker link');
|
||||
assert.equal(await b.evaluate(`${link}.textContent`), 'New organization');
|
||||
const link = `document.querySelector('#workspace-nav a.ws-add[href="#/settings/organizations?new=1"]')`;
|
||||
await wait(`document.querySelectorAll('[data-workspace-org]').length === 2 && ${link}`, 'sidebar link');
|
||||
assert.equal(await b.evaluate(`${link}.getAttribute('aria-label')`), 'New organization');
|
||||
await b.evaluate(`${link}.click()`);
|
||||
await formFocused('#/orgs/alpha', 'picker');
|
||||
await formFocused('#/orgs/alpha', 'sidebar');
|
||||
});
|
||||
|
||||
test('the no-organization empty state offers New organization next to its Settings link', { timeout: 60000 }, async t => {
|
||||
|
||||
@@ -128,7 +128,7 @@ test('project creation offers repositories from the scoped business read', async
|
||||
assert.equal(await b.evaluate(`document.querySelector('#chat-repo option[value=work]').value`), 'work');
|
||||
});
|
||||
|
||||
test('Settings lists organizations and switching changes the route and header', async t => {
|
||||
test('sidebar lists organizations and navigating changes the route and header', async t => {
|
||||
const organizations = {
|
||||
list: async () => [{ id: 'alpha', label: 'Alpha', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: false }],
|
||||
resolve: async id => ['alpha', 'beta'].includes(id) ? {
|
||||
@@ -141,11 +141,12 @@ test('Settings lists organizations and switching changes the route and header',
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(390, 900);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/settings/business`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#org-picker'))return;await new Promise(r=>setTimeout(r,30))}throw Error('Organization picker did not appear')})()`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelectorAll('[data-workspace-org]').length===2 && document.querySelector('#settings-page-title'))return;await new Promise(r=>setTimeout(r,30))}throw Error('Organizations did not appear')})()`);
|
||||
assert.match(await b.evaluate(`document.querySelector('header').textContent`), /Alpha/);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#settings-page-title').textContent`), 'Organization');
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelector('#org-picker').options].map(o=>o.value)`), ['alpha', 'beta']);
|
||||
await b.evaluate(`(()=>{const e=document.querySelector('#org-picker');e.value='beta';e.dispatchEvent(new Event('change',{bubbles:true}))})()`);
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('#org-picker')`), false);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-workspace-org]')].map(o=>o.dataset.workspaceOrg)`), ['alpha', 'beta']);
|
||||
await b.evaluate(`document.querySelector('[data-workspace-org=beta] a').click()`);
|
||||
let switched = false;
|
||||
for (let i = 0; i < 150 && !switched; i++) {
|
||||
try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('header')?.textContent.includes('Beta')`); } catch { /* reload replaced the page */ }
|
||||
@@ -178,12 +179,12 @@ test('a chat draft is kept on Stay and discarded on an organization switch', asy
|
||||
assert.equal(await b.evaluate(`!!document.querySelector('#chat-session-form, #chat-share-form, #chat-clear')`), false, 'unmapped session and sharing controls are unavailable');
|
||||
await b.evaluate(`(()=>{const el=document.querySelector('#chat-body');el.value='unsent alpha';el.dispatchEvent(new Event('input',{bubbles:true}))})()`);
|
||||
await b.evaluate(`location.hash='#/settings/business'`);
|
||||
await wait('!!document.querySelector("#org-picker")');
|
||||
await b.evaluate(`(()=>{const el=document.querySelector('#org-picker');el.value='beta';el.dispatchEvent(new Event('change',{bubbles:true}))})()`);
|
||||
await wait('!!document.querySelector("[data-workspace-org=beta] a")');
|
||||
await b.evaluate(`document.querySelector('[data-workspace-org=beta] a').click()`);
|
||||
await wait('!!document.querySelector("dialog[open] [data-stay]")');
|
||||
await b.evaluate(`document.querySelector('[data-stay]').click()`);
|
||||
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/alpha\//);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#org-picker').value`), 'alpha');
|
||||
assert.equal(await b.evaluate(`window.MosaicOrg.id`), 'alpha');
|
||||
await b.evaluate(`location.hash='#/chat/threads/${thread}'`);
|
||||
await wait('!!document.querySelector("#chat-body")');
|
||||
assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`), 'unsent alpha');
|
||||
@@ -210,8 +211,16 @@ test('organization switch isolates browser board preferences', async t => {
|
||||
const base = `http://127.0.0.1:${server.address().port}/`;
|
||||
await b.navigate(base + '#/orgs/alpha/');
|
||||
await b.evaluate(`document.querySelector('#show-machine').click()`);
|
||||
await b.navigate(base + '#/orgs/beta/');
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(window.MosaicOrg?.id === 'beta')return;await new Promise(r=>setTimeout(r,30))}throw Error('beta did not load')})()`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), true);
|
||||
// The switch reloads the page. The navigate helper's load checks can be in flight when that
|
||||
// reload commits, so set the hash in the page and poll one short check at a time.
|
||||
await b.evaluate(`location.hash = '#/orgs/beta/'`);
|
||||
let loaded = false;
|
||||
for (let i = 0; i < 150 && !loaded; i++) {
|
||||
try { loaded = await b.evaluate(`window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')`); } catch {}
|
||||
if (!loaded) await new Promise(r => setTimeout(r, 30));
|
||||
}
|
||||
assert.equal(loaded, true, 'beta did not load');
|
||||
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { createOrgAdmin } from '../src/org-admin.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
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)})})()`);
|
||||
|
||||
// Three organizations as sample 01 and 03 show them: two running buses and one stopped.
|
||||
function workspaceOrgs({ failThreads = false, seen = null } = {}) {
|
||||
const ORGS = [
|
||||
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
|
||||
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
|
||||
];
|
||||
const PROJECTS = { ddigital: [{ id: 'spring-launch', name: 'Spring launch' }, { id: 'brand-refresh', name: 'Brand refresh' }], forge: [{ id: 'api-gateway', name: 'API gateway' }], home: [] };
|
||||
const INSTANCES = { ddigital: { chief: ['chief-of-staff', ['r1']], copywriter: ['copywriter', ['r2']], 'montage-producer': ['producer', ['r3', 'r4']], editor: ['editor', []] }, forge: { chief: ['chief-of-staff', []] } };
|
||||
const THREADS = { ddigital: [{ id: 't0', name: 'Kickoff notes', project: 'spring-launch', instance: 'chief' }, { id: 't1', name: 'V-112 Launch email, draft 2', project: 'spring-launch', instance: 'copywriter' }, { id: 't2', name: 'V-118 Teaser reel', project: 'spring-launch', instance: 'montage-producer' }, { id: 't3', name: 'Weekly plan', project: 'brand-refresh', instance: 'chief' }], forge: [] };
|
||||
// The bus's derived state per held role: chief waits on the human, copywriter works, montage-producer
|
||||
// reports none, so its activity is not known and it shows only as running.
|
||||
const AGENTS = { ddigital: [{ role: 'chief', holder_run: 'r1', state: 'waiting' }, { role: 'copywriter', holder_run: 'r2', state: 'working' }], forge: [] };
|
||||
const claim = (thread, holder_run) => ({ thread, holder_run, harness: 'claude', at: '2026-11-09T12:00:00.000Z' });
|
||||
const CLAIMS = { ddigital: [claim('t3', 'r1'), claim('t1', 'r2'), claim('t2', 'r3')], forge: [] };
|
||||
const FILES = { 'spring-launch': ['docs/north-star.md', 'docs/launch-plan.md', 'src/index.js'], 'brand-refresh': ['docs/brand-voice-guide.md'], 'api-gateway': [] };
|
||||
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
|
||||
instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])) });
|
||||
const business = id => ({ business: { id, human: 'jason', arbiters: ['chief'], projects: PROJECTS[id].map(p => p.id), vars: id === 'ddigital' ? { 'tracker.kind': 'vikunja' } : {},
|
||||
instances: Object.keys(INSTANCES[id]).map(n => ({ instance: n, vars: { model: 'sonnet-5.5' } })), appearance: { theme: ORGS.find(o => o.id === id).theme } } });
|
||||
return {
|
||||
list: async () => ORGS,
|
||||
resolve: async id => {
|
||||
seen?.push(id);
|
||||
const org = ORGS.find(o => o.id === id); if (!org) return null;
|
||||
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
|
||||
return {
|
||||
busHealthy: true,
|
||||
reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', notifier: { binding: 'discord' }, launch: { limits: { total: 4 } } }),
|
||||
queue: async () => ({ rows: id === 'ddigital' ? [{ state: 'done' }, { state: 'in-progress' }, { state: 'queued' }] : [] }), busStatus: async () => ({}) },
|
||||
bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => AGENTS[id], 'thread.claims': async () => CLAIMS[id], inbox: async () => [], tasks: async () => [] },
|
||||
chat: { projects: async () => PROJECTS[id], threads: async () => { if (failThreads) throw new Error('threads down'); return THREADS[id]; } },
|
||||
projectFiles: async ({ project }) => ({ head: 'a'.repeat(40), files: (FILES[project] || []).map(path => ({ path })) }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
// No bus or setup work runs in these tests: every restart below is refused before the admin is used.
|
||||
const refusingAdmin = () => createOrgAdmin({ service: {}, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
|
||||
const SETTINGS = ['Identity', 'Appearance', 'System types', 'Projects', 'Agents and roles', 'Models and routing', 'Arbiters', 'Task tracker', 'Channels', 'Launch limits', 'Assistant', 'Token use', 'Model proxy', 'Console', 'Organizations', 'About', 'Members and invites', 'Bus', 'Archive or delete'];
|
||||
const TAGS = ['org file', 'override', 'system', 'root', 'not built', 'danger'];
|
||||
// Every page the Settings view has for the owner (bus.js settingsPages).
|
||||
const SETTINGS_PAGES = ['appearance', 'business', 'limits', 'organizations', 'routing', 'agents', 'assistant', 'tokens', 'model-proxy', 'console', 'about'];
|
||||
|
||||
test('workspace navigation exposes all organizations and remembers project grouping', async t => {
|
||||
const organizations = {
|
||||
list: async () => [{ id:'alpha', label:'Alpha', theme:null, busHealthy:true }, { id:'beta', label:'Beta', theme:'cobalt', busHealthy:false }],
|
||||
resolve: async id => ({ busHealthy:id === 'alpha', reads:{ business:async()=>({ business:{ id, projects:[] } }) }, bus:{}, chat:{projects:async()=>[],threads:async()=>[]} }),
|
||||
};
|
||||
const server = await startServer({port:0, sso:createDevOwner(), organizations, log:()=>{}}); t.after(()=>close(server));
|
||||
const b=await browser({machine:false}); t.after(()=>b.close());
|
||||
const wait = waitFor(b);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/`);
|
||||
await wait('document.querySelectorAll("[data-workspace-org]").length === 2');
|
||||
assert.match(await b.evaluate('document.querySelector("[data-workspace-org=beta]").textContent'), /Beta.*bus unavailable/is);
|
||||
// An organization's colour is its theme's action colour; no theme is the harbor default.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-workspace-org=alpha] .ws-av").style.getPropertyValue("--org-l")'), '#2266bf');
|
||||
await b.evaluate('document.querySelector("[data-org-toggle]").click()');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-list]").hidden'),true);
|
||||
await b.evaluate('document.querySelector("[data-org-toggle]").click(); document.querySelector("[data-group=flat]").click()');
|
||||
// The Projects group collapses too, and each group's open state is remembered (sample 01).
|
||||
await b.evaluate('document.querySelector("[data-projects-toggle]").click()');
|
||||
assert.deepEqual(await b.evaluate('[document.querySelector("#ws-project-list").hidden, document.querySelector("[data-projects-toggle]").getAttribute("aria-expanded"), document.activeElement?.dataset.focusKey]'), [true, 'false', 'projects-toggle']);
|
||||
await b.evaluate('location.reload()');
|
||||
// The reload repaints the sidebar in stages; wait for both lists before reading them.
|
||||
await wait('document.querySelector("[data-group=flat]")?.getAttribute("aria-pressed") === "true" && !!document.querySelector("#ws-project-list") && !!document.querySelector("[data-org-list]")');
|
||||
assert.deepEqual(await b.evaluate('[document.querySelector("#ws-project-list").hidden, document.querySelector("[data-org-list]").hidden]'), [true, false]);
|
||||
await b.evaluate('document.querySelector("[data-projects-toggle]").click()');
|
||||
assert.equal(await b.evaluate('document.querySelector("#ws-project-list").hidden'), false);
|
||||
await b.viewport(390,844);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
|
||||
});
|
||||
|
||||
test('sidebar follows sample 01 and Queue opens from the organization dashboard, not the sidebar', async t => {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const wait = waitFor(b);
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-org-settings]") && document.querySelectorAll("#workspace-nav .ws-proj").length === 3');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections > li > a .t")].map(e => e.textContent)'), ['Dashboard', 'Inbox', 'Tasks', 'Projects', 'Side by side', 'Agents', 'Assistant']);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections-foot a")].map(e => e.textContent.trim())'), ['Settings']);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-org-toggle]").textContent.replace(/\\s+/g, " ").trim()'), /^Organizations\s*3$/);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-workspace-org]")].map(li => [li.dataset.workspaceOrg, !!li.querySelector(".ws-av"), [...li.querySelector(".ws-dot").classList].find(c => c.startsWith("s-"))])'),
|
||||
[['ddigital', true, 's-working'], ['forge', true, 's-working'], ['home', true, 's-offline']]);
|
||||
assert.equal(await b.evaluate('document.querySelector("#workspace-nav [data-group=org]").getAttribute("aria-pressed")'), 'true');
|
||||
// The four-tile logo stays.
|
||||
assert.equal(await b.evaluate('document.querySelectorAll(".wordmark .mosaic-logo rect").length'), 4);
|
||||
// No organization switcher remains anywhere on the page.
|
||||
assert.equal(await b.evaluate('document.querySelector("#org-picker, select[name=org], [data-org-switch]")'), null);
|
||||
// Projects has no conversation tree under it; the Projects group links to each project page.
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections #project-tree, #sections .project-tree-list")'), null);
|
||||
assert.match(await b.evaluate('document.querySelector("#workspace-nav .ws-proj").getAttribute("href")'), /^#\/orgs\/[a-z0-9-]+\/projects\/[^/]+$/);
|
||||
// Queue and Docs are not sidebar items.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#console-nav a[href]")].filter(a => /\\/(queue|docs)$/.test(a.getAttribute("href"))).length'), 0);
|
||||
// Settings stays pinned to the foot of the sidebar.
|
||||
assert.equal(await b.evaluate('(() => { const nav = document.getElementById("console-nav").getBoundingClientRect(), foot = document.getElementById("sections-foot").getBoundingClientRect(); return foot.bottom >= nav.bottom - 24; })()'), true);
|
||||
const queue = await b.evaluate('document.querySelector("[data-org-dashboard] [data-org-queue]").getAttribute("href")');
|
||||
assert.equal(queue, '#/queue');
|
||||
await b.evaluate('document.querySelector("[data-org-queue]").click()');
|
||||
await wait('location.hash === "#/orgs/ddigital/queue" && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Queue")');
|
||||
// Views not built yet open and say so.
|
||||
for (const [route, title] of [['inbox', 'Inbox'], ['tasks', 'Tasks'], ['side-by-side', 'Side by side']]) {
|
||||
await b.evaluate(`location.hash = "#/orgs/ddigital/${route}"`);
|
||||
await wait(`document.getElementById("bus-view").innerText.includes("not built yet") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === ${JSON.stringify(title)})`);
|
||||
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /error|failed/i, route);
|
||||
}
|
||||
});
|
||||
|
||||
test('organization dashboard renders every section from the per-organization reads', async t => {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const wait = waitFor(b);
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
|
||||
await wait('document.querySelectorAll("[data-org-dashboard] .ws-doc").length > 0');
|
||||
const head = await b.evaluate(`(() => { const h = document.querySelector('[data-org-dashboard] .ws-orghead'); return { avatar: h.querySelector('.ws-av')?.textContent, title: h.querySelector('h1').textContent, meta: h.querySelector('.ws-meta').innerText, colour: h.style.getPropertyValue('--org-l') }; })()`);
|
||||
assert.equal(head.avatar, 'DD'); assert.equal(head.title, 'Distinctly Digital');
|
||||
assert.match(head.meta, /Kind not recorded/); assert.match(head.meta, /id ddigital/); assert.match(head.meta, /Tracker: Vikunja/); assert.match(head.meta, /bus running, read \d/);
|
||||
assert.match(head.colour, /^#[0-9a-f]{6}$/i);
|
||||
assert.match(await b.evaluate('document.querySelector(".ws-typechips").innerText'), /System types\s+No system types yet/i);
|
||||
// Docs come from the project files reads: only documents, named by project.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-doc")].map(a => a.innerText.replace(/\\s+/g, " ").trim())'), ['North star Spring launch', 'Launch plan Spring launch', 'Brand voice guide Brand refresh']);
|
||||
// KPIs and metrics: one honest empty tile each, with no invented number.
|
||||
const tiles = await b.evaluate('[...document.querySelectorAll(".ws-tiles")].map(t => [t.querySelectorAll(".ws-tile").length, t.querySelector(".ws-tile").classList.contains("is-empty"), t.innerText.replace(/\\s+/g, " ").trim()])');
|
||||
assert.deepEqual(tiles, [[1, true, 'No KPIs defined in the organization file'], [1, true, 'No metrics recorded yet']]);
|
||||
// Active agent sessions: the running instances, the thread their live run holds (chief's older
|
||||
// Kickoff notes is not it), the model with its Change link, and the state the bus reports. With no
|
||||
// reported state (montage-producer) the row says only running.
|
||||
const rows = await b.evaluate('[...document.querySelectorAll(".ws-tbl tbody tr")].map(tr => [...tr.cells].map(c => c.innerText.replace(/\\s+/g, " ").trim()))');
|
||||
assert.deepEqual(rows, [
|
||||
['chief', 'Brand refresh Weekly plan', 'sonnet-5.5 Change', '◐ waiting on you'],
|
||||
['copywriter', 'Spring launch V-112 Launch email, draft 2', 'sonnet-5.5 Change', '● working'],
|
||||
['montage-producer', 'Spring launch V-118 Teaser reel', 'sonnet-5.5 Change', '● running 2 sessions'],
|
||||
]);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl a.ws-change")].map(a => [a.getAttribute("href"), a.getAttribute("aria-label")])'),
|
||||
['chief', 'copywriter', 'montage-producer'].map(n => ['#/settings/routing', `Change the model for ${n}`]));
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl thead th")].map(th => th.textContent)'), ['Agent', 'Project and task', 'Model', 'Status']);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /Queue\s+3 rows, 2 not done\s+Open queue/);
|
||||
// Settings: one row per setting, each with an icon, a value, a source tag and, where a page exists, Open.
|
||||
const settings = await b.evaluate(`[...document.querySelectorAll('[data-org-settings] > li')].map(li => ({ name: li.dataset.orgSetting, icon: !!li.querySelector('svg'), value: li.querySelector('.v').textContent, tag: li.querySelector('.src-tag').textContent, href: li.querySelector('a.go')?.getAttribute('href') ?? null, danger: li.classList.contains('danger') }))`);
|
||||
assert.deepEqual(settings.map(s => s.name), SETTINGS);
|
||||
for (const s of settings) { assert.ok(s.icon, s.name); assert.ok(s.value, s.name); assert.ok(TAGS.includes(s.tag), `${s.name}: ${s.tag}`); }
|
||||
const by = Object.fromEntries(settings.map(s => [s.name, s]));
|
||||
assert.deepEqual([by['Members and invites'].tag, by['Members and invites'].href], ['not built', null]);
|
||||
assert.deepEqual([by['System types'].tag, by['System types'].href], ['not built', null]);
|
||||
assert.deepEqual([by['Archive or delete'].tag, by['Archive or delete'].danger, by['Archive or delete'].href], ['danger', true, '#/settings/organizations']);
|
||||
assert.equal(by.Identity.value, 'Distinctly Digital · ddigital · kind not recorded');
|
||||
assert.equal(by['Task tracker'].value, 'Vikunja');
|
||||
assert.equal(by['Launch limits'].value, '4 sessions at once');
|
||||
assert.equal(by['Agents and roles'].value, '4 agents · 3 running');
|
||||
for (const page of SETTINGS_PAGES) assert.ok(settings.some(s => s.href === `#/settings/${page}`), `no row opens settings/${page}`);
|
||||
});
|
||||
|
||||
test('organization dashboard empty states say so in one short line', async t => {
|
||||
const organizations = { list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: true }], resolve: async () => ({ busHealthy: true,
|
||||
reads: { business: async () => ({ business: { id: 'alpha', projects: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: { instances: async () => ({ business: 'alpha', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }), messages: async () => ({ messages: [], unread: 0 }), agents: async () => [] },
|
||||
chat: { projects: async () => [], threads: async () => [] } }) };
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/organization`);
|
||||
await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")');
|
||||
const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
|
||||
// Section labels are uppercased by CSS, so innerText carries the uppercase form.
|
||||
for (const line of [/System types\s+No system types yet\. They arrive with the system types update\./i, /Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
|
||||
assert.match(text, line);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length);
|
||||
});
|
||||
|
||||
test('organization dashboard presents sections and honest unavailable controls', async t => {
|
||||
const organizations = { list:async()=>[{id:'alpha',label:'Alpha',busHealthy:false}], resolve:async()=>({busHealthy:false,reads:{business:async()=>({business:{id:'alpha',projects:[]}})},bus:{},chat:{projects:async()=>[],threads:async()=>[]}}) };
|
||||
const server=await startServer({port:0,sso:createDevOwner(),organizations,log:()=>{}}); t.after(()=>close(server));
|
||||
const b=await browser({machine:false}); t.after(()=>b.close());
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/organization`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-dashboard]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('dashboard missing')})()`);
|
||||
const text=await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
|
||||
for(const label of ['Docs','KPIs','Metrics','Active agent sessions','Settings','Queue']) assert.match(text,new RegExp(label,'i'));
|
||||
assert.match(text,/reload organization file/i);
|
||||
assert.match(text,/no metrics recorded yet/i);
|
||||
assert.match(text,/bus unavailable, read \d/);
|
||||
assert.match(text,/Sessions unavailable/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-reload]").disabled'),true);
|
||||
});
|
||||
|
||||
test('each header control is present, reload is disabled, and restart is refused without the owner', async t => {
|
||||
const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(owner));
|
||||
const readOnly = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(readOnly));
|
||||
const signedOut = await startServer({ port: 0, organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(signedOut));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const wait = waitFor(b);
|
||||
const header = server => b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`).then(() => wait('document.querySelector("[data-org-dashboard] [data-org-settings]")')).then(() => b.evaluate(`(() => {
|
||||
const h = document.querySelector('[data-org-dashboard] .ws-orghead'), live = h.querySelector('[data-bus-restart]'), off = h.querySelector('[data-bus-restart-off]'), reload = h.querySelector('[data-org-reload]');
|
||||
return { live: live ? [live.textContent, live.disabled] : null, off: off ? [off.textContent, off.disabled, !!off.title] : null,
|
||||
reload: [reload.textContent, reload.disabled, reload.title, document.getElementById(reload.getAttribute('aria-describedby'))?.textContent] };
|
||||
})()`));
|
||||
const RELOAD = ['Reload organization file', true, 'Reload arrives with the cross-org update', 'Restart is a trailed human action; reload arrives with the cross-org update.'];
|
||||
// The owner on a Console started with --send gets console-restart.js's restart button.
|
||||
assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, reload: RELOAD });
|
||||
// Without --send, or with nobody signed in, the control is present and disabled, and the route refuses.
|
||||
const post = 'fetch("/api/orgs/ddigital/restart/bus", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async r => [r.status, (await r.json()).error])';
|
||||
assert.deepEqual(await header(readOnly), { live: null, off: ['Restart bus', true, true], reload: RELOAD });
|
||||
assert.deepEqual(await b.evaluate(post), [403, 'read-only']);
|
||||
// With nobody signed in the organization list is not readable, so the dashboard reads nothing and
|
||||
// shows no controls at all.
|
||||
await b.navigate(`http://127.0.0.1:${signedOut.address().port}/#/orgs/ddigital/organization`);
|
||||
await wait('document.querySelector("[data-org-unavailable]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-bus-restart], [data-bus-restart-off], [data-org-reload]")'), null);
|
||||
// With nobody signed in, the server refuses every per-org route before the admin hooks run.
|
||||
assert.deepEqual(await b.evaluate(post), [404, 'org-unavailable']);
|
||||
// There is no reload route.
|
||||
assert.equal(await b.evaluate('fetch("/api/orgs/ddigital/reload", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(r => r.ok)'), false);
|
||||
});
|
||||
|
||||
// Tab from the first sidebar link through the dashboard and return each stop's region and box.
|
||||
async function tabStops(b, start) {
|
||||
await b.evaluate(`(${start}).focus()`);
|
||||
const stops = [];
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const at = await b.evaluate(`(() => { const a = document.activeElement, sec = a.closest('[data-org-dashboard] section'); return { i: [...document.querySelectorAll('*')].indexOf(a), region: a.closest('#console-nav') ? 'nav' : sec ? sec.getAttribute('aria-label') : a.closest('[data-org-dashboard]') ? 'head' : 'other', key: a.dataset.focusKey || a.getAttribute('href') || a.textContent.trim() }; })()`);
|
||||
if (stops.length && at.region === 'other' && stops.at(-1).region !== 'other') break;
|
||||
stops.push(at);
|
||||
await b.key('Tab');
|
||||
}
|
||||
const boxes = await b.evaluate(`(${JSON.stringify(stops.map(s => s.i))}).map(i => { const r = document.querySelectorAll('*')[i].getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left }; })`);
|
||||
return stops.map((s, n) => ({ ...s, ...boxes[n] }));
|
||||
}
|
||||
function assertVisualOrder(stops, regions, label) {
|
||||
for (let n = 1; n < stops.length; n++) {
|
||||
const a = stops[n - 1], z = stops[n], where = `${label}: ${a.key} then ${z.key}`;
|
||||
assert.ok(z.i > a.i, `tab order leaves DOM order at ${where}`);
|
||||
assert.ok(regions.indexOf(z.region) >= regions.indexOf(a.region), `region order at ${where}`);
|
||||
if (z.region !== a.region) continue;
|
||||
const sameRow = z.top < a.bottom && z.bottom > a.top;
|
||||
assert.ok(sameRow ? z.left > a.left : z.top >= a.top, `visual order at ${where}`);
|
||||
}
|
||||
}
|
||||
|
||||
test('keyboard focus follows visual order and survives a data refresh', async t => {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const wait = waitFor(b);
|
||||
await b.viewport(1440, 900);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
|
||||
await wait('document.querySelectorAll("[data-org-dashboard] .ws-doc").length > 0 && document.querySelectorAll("#workspace-nav .ws-proj").length === 3');
|
||||
const wide = await tabStops(b, 'document.querySelector("#sections a")');
|
||||
const regions = ['nav', 'head', 'Measures and sessions', 'Settings'];
|
||||
assert.deepEqual([...new Set(wide.map(s => s.region))].filter(r => r !== 'other'), regions);
|
||||
assertVisualOrder(wide.filter(s => s.region !== 'other'), regions, '1440');
|
||||
const keys = wide.map(s => s.key);
|
||||
for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'queue-open', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`);
|
||||
// The disabled reload is not a tab stop; the restart button is.
|
||||
assert.ok(!keys.includes('org-reload')); assert.ok(keys.includes('Restart bus'));
|
||||
await b.viewport(390, 844);
|
||||
await wait('document.querySelector("[data-org-dashboard]")');
|
||||
const narrow = (await tabStops(b, 'document.querySelector("[data-org-dashboard] .ws-crumb a")')).filter(s => s.region !== 'other');
|
||||
assertVisualOrder(narrow, regions.slice(1), '390');
|
||||
for (let n = 1; n < narrow.length; n++) assert.ok(narrow[n].top >= narrow[n - 1].top - 1 || narrow[n].region === narrow[n - 1].region, `stacked order at 390: ${narrow[n - 1].key} then ${narrow[n].key}`);
|
||||
await b.viewport(1440, 900);
|
||||
|
||||
// A refresh repaints the dashboard, the sidebar and the restart button; focus stays on the same control.
|
||||
for (const key of ['set:Projects', 'queue-open', 'doc:spring-launch:docs/launch-plan.md']) {
|
||||
await wait(`document.querySelector('[data-focus-key="${key}"]')`);
|
||||
await b.evaluate(`window.oldDashboard = document.querySelector('[data-org-dashboard]'); document.querySelector('[data-focus-key="${key}"]').focus(); document.getElementById('refresh').click()`);
|
||||
await wait('document.querySelector("[data-org-dashboard]") && document.querySelector("[data-org-dashboard]") !== window.oldDashboard');
|
||||
assert.equal(await b.evaluate('document.activeElement.dataset.focusKey'), key);
|
||||
}
|
||||
await b.evaluate(`window.oldDashboard = document.querySelector('[data-org-dashboard]'); document.querySelector('[data-bus-restart]').focus(); document.getElementById('refresh').click()`);
|
||||
await wait('document.querySelector("[data-org-dashboard]") !== window.oldDashboard');
|
||||
assert.equal(await b.evaluate('document.activeElement.hasAttribute("data-bus-restart")'), true);
|
||||
await b.evaluate(`window.oldRow = document.querySelector('[data-workspace-org=forge]'); document.querySelector('[data-focus-key="org:forge"]').focus(); window.MosaicOrg.refreshList()`);
|
||||
await wait('document.querySelector("[data-workspace-org=forge]") !== window.oldRow');
|
||||
assert.equal(await b.evaluate('document.activeElement.dataset.focusKey'), 'org:forge');
|
||||
});
|
||||
|
||||
test('phone drawer opens over the dashboard with no horizontal scroll', async t => {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const wait = waitFor(b);
|
||||
await b.viewport(390, 844);
|
||||
for (const route of ['organization', 'agents']) {
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/${route}`);
|
||||
await wait(route === 'organization' ? 'document.querySelector("[data-org-dashboard] [data-org-settings]")' : 'document.querySelector("[data-org-agents] .ws-levels")');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, route);
|
||||
}
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.getElementById("console-nav")).visibility === "hidden" || document.getElementById("console-nav").getBoundingClientRect().right <= 0'), true);
|
||||
await b.evaluate('document.getElementById("nav-open").click()');
|
||||
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "true"');
|
||||
await wait('document.querySelectorAll("[data-workspace-org]").length === 3');
|
||||
const drawer = await b.evaluate('(() => { const r = document.getElementById("console-nav").getBoundingClientRect(); return { left: r.left, right: r.right, sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth }; })()');
|
||||
assert.ok(drawer.left >= 0 && drawer.right <= 390 && drawer.right > 200, JSON.stringify(drawer));
|
||||
assert.ok(drawer.sw <= drawer.cw, JSON.stringify(drawer));
|
||||
// The open drawer itself does not scroll sideways; the only clipped children are screen-reader text.
|
||||
assert.deepEqual(await b.evaluate('(() => { const nav = document.getElementById("console-nav"); return [nav.scrollWidth <= nav.clientWidth, [...nav.querySelectorAll("*")].filter(e => e.scrollWidth > e.clientWidth + 1 && getComputedStyle(e).overflowX !== "visible" && !e.classList.contains("ws-sr")).map(e => e.className)]; })()'), [true, []]);
|
||||
});
|
||||
|
||||
test('agents view includes organization-scoped instances without inventing root or project agents', async t => {
|
||||
const organizations={list:async()=>[{id:'alpha',label:'Alpha',busHealthy:true},{id:'beta',label:'Beta',busHealthy:false}],resolve:async id=>({busHealthy:id==='alpha',reads:{business:async()=>({business:{id}})},bus:{messages:async()=>({messages:[],unread:0}),agents:async()=>[],instances:async()=>({business:id,revision:'a'.repeat(64),launch:true,noHire:[],retired:[],instances:{chief:{definition:'chief-of-staff',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1'],alive:[]}},definitions:{'chief-of-staff':{services:[],accepted:true}}})},chat:{threads:async()=>[],projects:async()=>[]}})};
|
||||
const server=await startServer({port:0,sso:createDevOwner(),organizations,log:()=>{}});t.after(()=>close(server));
|
||||
const b=await browser({machine:false});t.after(()=>b.close());
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/agents`);
|
||||
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/);
|
||||
// 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 => {
|
||||
const organizations={list:async()=>[{id:'alpha',label:'Alpha',busHealthy:true},{id:'beta',label:'Beta',busHealthy:false}],resolve:async id=>({busHealthy:id==='alpha',reads:{business:async()=>({business:{id}})},bus:id==='alpha'?{agents:async()=>[],messages:async()=>({messages:[],unread:0}),instances:async()=>({business:id,revision:'a'.repeat(64),launch:true,noHire:[],retired:[],instances:{chief:{definition:'chief-of-staff',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1'],alive:[]}},definitions:{'chief-of-staff':{services:[],accepted:true}}})}:{},chat:{threads:async()=>[],projects:async()=>[]}})};
|
||||
const server=await startServer({port:0,sso:createDevOwner(),organizations,log:()=>{}});t.after(()=>close(server));
|
||||
const b=await browser({machine:false});t.after(()=>b.close());
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/beta/agents`);
|
||||
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,/Agents unavailable/);
|
||||
});
|
||||
|
||||
test('a failed conversation read says unavailable, never that none exists', async t => {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs({ failThreads: true }), 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/organization`);
|
||||
await waitFor(b)('document.querySelectorAll("[data-org-dashboard] .ws-tbl tbody tr").length === 3');
|
||||
const where = await b.evaluate('[...document.querySelectorAll(".ws-tbl tbody tr")].map(tr => tr.cells[1].innerText.trim())');
|
||||
assert.deepEqual(where, ['Conversation unavailable', 'Conversation unavailable', 'Conversation unavailable']);
|
||||
assert.doesNotMatch(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /No conversation/);
|
||||
});
|
||||
|
||||
test('an organization missing from the list, or an unreadable list, is never read', async t => {
|
||||
const seen = [];
|
||||
const orgs = workspaceOrgs({ seen });
|
||||
for (const [label, organizations, message] of [['not listed', orgs, /not on your list/], ['list fails', { ...orgs, list: async () => { throw new Error('list down'); } }, /could not be read/]]) {
|
||||
seen.length = 0;
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const target = label === 'not listed' ? 'denied' : 'ddigital';
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/${target}/organization`);
|
||||
await waitFor(b)('document.querySelector("[data-org-unavailable]")');
|
||||
const view = await b.evaluate('document.querySelector("[data-org-unavailable]").innerText');
|
||||
assert.match(view, /Organization unavailable/, label); assert.match(view, message, label);
|
||||
// None of the dashboard's per-organization reads went out for the target.
|
||||
const asked = await b.evaluate(`performance.getEntriesByType('resource').map(e => new URL(e.name).pathname).filter(p => p.startsWith('/api/orgs/${target}/'))`);
|
||||
// The app shell's own boot (app.js, unchanged here) reads /business once; the dashboard adds none.
|
||||
const dashboardReads = /\/(settings|queue|chat\/threads|chat\/projects|bus\/instances|bus\/agents|bus\/thread\.claims)$/;
|
||||
assert.deepEqual(asked.filter(p => dashboardReads.test(p)), [], `${label}: ${asked.join(' ')}`);
|
||||
assert.ok(asked.filter(p => p.endsWith('/business')).length <= 1, `${label}: ${asked.join(' ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('agents grid shows the reported state and a model Change link', async t => {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), 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] .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[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']);
|
||||
// Keyboard reaches the Change links.
|
||||
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