Compare commits
21
Commits
org-sys1
...
org-m-host
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89848a2fa1 | ||
|
|
fe820d17bf | ||
|
|
a7b2199390 | ||
|
|
4c495647cd | ||
|
|
f5cc712a59 | ||
|
|
821fbe8e9b | ||
|
|
d247b4eb61 | ||
|
|
e358886826 | ||
|
|
01b79e1db4 | ||
|
|
0a75cc82f3 | ||
|
|
c66cd58846 | ||
|
|
69af2fe94b | ||
|
|
4106bf7344 | ||
|
|
8630b8dfc0 | ||
|
|
5eb6dda049 | ||
|
|
8710cc0e1e | ||
|
|
364f1ae7a6 | ||
|
|
9b7bf11afb | ||
|
|
feb98d5529 | ||
|
|
221c1e9d35 | ||
|
|
544918a715 |
No files matched your search
@@ -6345,3 +6345,5 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
|
||||
2026-11-09 UTC, organizations F3 (#1873, PR #1884, a8f9f0957). The WebUI restarts the Console (supervised Consoles exit and `mosaic console start` relaunches them), restarts an organization's bus from the bus-down banner with a done receipt, and sends the owner's routing, agent, instance, name, assistant, limits and model proxy writes to the selected organization's bus. Routing apply lists how a change reaches each saved agent and offers Restart NAME now. Console restarts, bus restarts and routing, agent and instance changes append to `<dataRoot>/console/actions.jsonl`. Builder plus two refactor merges: combined list 354/0 on 999d173fd. Sol review CHANGES 1/0/1. P1: on a `--dev-local-owner` Console a local agent can send the right Origin and Fetch Metadata headers and reach these controls. The pinned startup warning and the accepted no-auth dev flags already cover this, but the README claimed no agent reaches them. Lead decision: correct the README and file the cooperative local-agent peer refusal as #1883, without building it in this round. P3: the instances route wrote no trail line. Fixed in 0a73279d6; the regression test fails on 999d173fd and passes on the fix. After the refactor merge, 25 touched or README-reading files 195/0 (Node 26.12.1, one file at a time, env -i). uiorg redeployed at a8f9f0957, 22:45Z; live config offers consoleRestart and org settingsSend. Correction to this session's process: after the test run completed I replied "No response requested" and stopped until Jason asked. Open follow-ups: defect l (a bus restart after adding a credentialed agent can refuse to start), the bus restart refusal code, a stale-lock Recover control, one control for banner Restart and Settings Start, #1883. Report: /mnt/storage/scratch/mosaic-lead/reports/org-f3-review.md.
|
||||
|
||||
2026-11-09 UTC, PRD 1.20 and defect l (#1873, #1886; PRs #1887 03fe321b1 and #1888 7a97cfc4f). PRD 1.20: Jason approved draft 2 at 22:57Z ("The file looks fine other than that"; the Montage module name stays deferred). The PRD now has 118 requirements, with REQ-WS-1..8, REQ-AGENT-1..3, REQ-SYS-1..2, REQ-ACCT-1..2 and REQ-SETUP-1..2 added and REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3 amended. Builder Codex Terra xhigh declined to commit because stack-copy failed 20/1 (node-gyp missing from the test Node's npm); that failure is pre-existing on 483eccb3d, so the lead committed. Sol review APPROVE 0 (word-for-word diff against draft 2, catalog order, both requirements tests). Defect l: before, an agent that declared a store credential made the next bus start refuse, because Credentials refuses a missing store root and only issue() created it. After: start creates the root 0700 when any reference names the store; a root that exists with loose modes or as a symlink still refuses. Two new credential-verbs tests fail on the parent. Bus suite 171 files one at a time, 1713/0 (Node 26.12.1, env -i). Sol review APPROVE 0. uiorg redeployed at 7a97cfc4f, 23:15Z. Follow-up: the stack-copy node-gyp test environment. Reports: /mnt/storage/scratch/mosaic-lead/reports/{prd120-land,prd120-review,defectl-review}.md.
|
||||
|
||||
2026-11-10 UTC, unified workspace U1 and SYS1 (#1873; PRs #1889 d35447005 and #1890 2c6556ab1). U1, before: each org page read only its own bus and nothing read every org at once. After: /api/workspace returns projects, sessions, inbox, tasks and metrics from every org the owner holds, each row tagged with its org and each org with a state (ok, starting, down, timeout, invalid); `mosaic console restart` and `mosaic org reload <org>` send the F3 routes. Sol review CHANGES 0/2 on 2109458b3: malformed data from one org turned the whole read into a 503, and the per-org deadline left sockets and transport children running. Terra fix 0672d5fe8 validates each org's result inside its boundary and carries an absolute deadline and AbortSignal through the status check and transport; the new workspace-failures test is 0/10 on the parent and 10/0 on the fix. The lead re-pinned the webui bus.mjs hash in bus channel-proof (the deliberate signal/deadline change; nonces, argv and limits unchanged) and amended as d35447005. SYS1, before: no system-type kind. After: a system-type plug-in kind with five modules (business-management, coding, creative-writing, marketing, montage), an org enable list under plugins.system-type, owner-only project selection with an append-only project_system_types table (migration 3t-3u) and CLI verbs; the modules grant no tools yet (REQ-SYS-2 deferred). Sol review CHANGES 1/2 on a9629f332: the restore guard did not count project_system_types, the CLI channel did not admit the two project verbs, and the #811 migration-reader pin still expected 18 steps. Lead fix 2c6556ab1 (rebased on d35447005) adds the table to COUNTED and LISTED with refusal and in-run recount tests, admits both verbs with a run()-level channel test (red on the unfixed channel), and moves the pin to 19 steps ending 3t-3u; fix files 323/0 and rebase overlap files 63/0 (Node 26.12.1, one file at a time, env -i). Full gate on a9629f332 4040/5: the two stack-release pins above, and tester-reads 3/3, which also fails on 1616d1dd6 (project.mjs:95 refuses an org --tester init; follow-up). Correction to the uiorg deploy at 00:50Z: the deploy script switched the tree without the offline schema migration, so all five buses refused to start (normal startup refuses an older schema by design). The lead ran migrate-schema.mjs for the five org stores (3t to 3u, all ready), restarted the buses (201 each) and added the migration to the deploy script with stop-on-refusal. test3 refused writer-locked and stays down, as before. Reports: /mnt/storage/scratch/mosaic-lead/reports/{u1-review,u1-fix,org-sys1-build,sys1-review}.md.
|
||||
@@ -2123,3 +2123,4 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-11-07 UTC | Codex implementation sub-agent | Organizations release fix round 1 on ui-org-r1 | Local candidate for independent review; verification and findings recorded in /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-fix-r1.md. Queue refused linked worktree; no push, merge or deployment.
|
||||
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | Organizations F3 fix round and landing (#1873) | PR #1884 merged a8f9f0957; review P3 fixed, P1 README corrected with follow-up #1883; 195/0 focused suite; uiorg redeployed 22:45Z.
|
||||
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | PRD 1.20 landing and defect l (#1873, #1886) | PR #1887 merged 03fe321b1, PR #1888 merged 7a97cfc4f; both Sol reviews APPROVE 0; bus suite 1713/0; uiorg redeployed 23:15Z; U1, U2 and U4a builds dispatched.
|
||||
2026-11-10 UTC | Mos (lead, Claude Opus 5.5) | U1 and SYS1 landings (#1873) | PR #1889 merged d35447005, PR #1890 merged 2c6556ab1; both Sol reviews CHANGES, fixed (U1 by Terra, SYS1 by the lead); uiorg at 2c6556ab1 after the missed 3t-3u store migration was run; deploy script now migrates.
|
||||
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;
|
||||
+383
-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
|
||||
@@ -184,6 +185,8 @@ const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collabora
|
||||
// Row 275 (row 193 design 4.3): the owner's board reads, which a reader session may call. A user,
|
||||
// collaborator or viewer, is refused every one at the USER_VERBS gate.
|
||||
export const BOARD_VERBS = Object.freeze(['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
|
||||
// The person's task-store writes, distinct from the agent's singular task.* actions.
|
||||
export const PERSON_TASK_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete']);
|
||||
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
|
||||
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
|
||||
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
||||
@@ -295,6 +298,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 +646,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 +658,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 +700,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 +1213,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 +4747,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 +4769,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 +4801,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 +5780,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,
|
||||
),
|
||||
@@ -6595,6 +6917,8 @@ export class Broker {
|
||||
#dispatch(s, verb, a, spec = null, binding = null) {
|
||||
const b = this.#business(s.business),
|
||||
by = s.human || s.run;
|
||||
// Preserve the common request gates and refuse additions until the vocabulary names them.
|
||||
if (verb.startsWith('tasks.') && !PERSON_TASK_VERBS.includes(verb)) fail('unknown-verb');
|
||||
switch (verb) {
|
||||
case 'role.claim': {
|
||||
keys(a, []);
|
||||
@@ -7611,6 +7935,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 +8750,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) });
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import * as bus from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'pt-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const broker = new bus.Broker({ store, businesses: {
|
||||
acme: { id: 'acme', human: 'jason', roles: {
|
||||
pm: { authority: { withinRole: [], crossRole: [] } },
|
||||
cto: { authority: { withinRole: [], crossRole: [] } },
|
||||
}, arbiters: { technical: 'cto', delivery: 'pm' } },
|
||||
} });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
return { broker, human };
|
||||
}
|
||||
|
||||
test('person task vocabulary pins exactly the five frozen store verbs', () => {
|
||||
assert.deepEqual(bus.PERSON_TASK_VERBS, [
|
||||
'tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete',
|
||||
]);
|
||||
assert.ok(Object.isFrozen(bus.PERSON_TASK_VERBS));
|
||||
});
|
||||
|
||||
test('every person task handler is declared in the vocabulary, even if the dispatch guard regresses', () => {
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
const handlers = [...source.matchAll(/^\s*case\s+(['"])(tasks\.[^'"]+)\1\s*:/gm)].map(match => match[2]);
|
||||
assert.deepEqual(handlers.sort(), [...bus.PERSON_TASK_VERBS].sort());
|
||||
});
|
||||
|
||||
test('every listed person task verb reaches its handler, while an unlisted verb is unknown', t => {
|
||||
const { broker, human } = fixture(t);
|
||||
assert.ok(Array.isArray(bus.PERSON_TASK_VERBS));
|
||||
for (const verb of bus.PERSON_TASK_VERBS) {
|
||||
// Missing required fields reaches the handler's field validation, not unknown-verb.
|
||||
assert.throws(() => broker.request(human, { verb, args: {} }), { code: 'invalid-request' }, verb);
|
||||
}
|
||||
assert.throws(() => broker.request(human, { verb: 'tasks.foo', args: {} }), { code: 'unknown-verb' });
|
||||
});
|
||||
|
||||
test('unknown person task verbs retain common argument and reader refusal precedence', t => {
|
||||
const { broker, human } = fixture(t);
|
||||
assert.throws(() => broker.request(human, { verb: 'tasks.foo', args: [] }), { code: 'invalid-request' });
|
||||
const reader = broker.bindReader({ business: 'acme' });
|
||||
assert.throws(() => broker.request(reader, { verb: 'tasks.foo' }), { code: 'read-only' });
|
||||
for (const verb of ['tasks.foo', 'tasks.create']) {
|
||||
assert.throws(() => broker.request(reader, { verb }), { code: 'read-only' }, verb);
|
||||
}
|
||||
});
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/org-rights.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/org-rights.mjs";
|
||||
@@ -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',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { existsSync, lstatSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { main } from "../src/cli.mjs";
|
||||
@@ -124,10 +124,10 @@ test("row 175: --tester on limits set, reset and migrate and every write form ou
|
||||
// W7 through the real tester transport: the CLI runs as its own process, so the tester-cli.mjs it
|
||||
// spawns can reach the runtime in this one.
|
||||
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
|
||||
function mosaic(args, env) {
|
||||
function command(executable, args, env) {
|
||||
const clean = Object.fromEntries(Object.entries(env).filter(([k]) => !/^MOSAIC_BUS_(TESTER|CLI)_NONCE$/.test(k)));
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(MOSAIC, args, { env: clean, timeout: 60000 });
|
||||
const child = spawn(executable, args, { env: clean, timeout: 60000 });
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
@@ -136,6 +136,8 @@ function mosaic(args, env) {
|
||||
child.on("close", (status) => resolve({ status, out, err }));
|
||||
});
|
||||
}
|
||||
const mosaic = (args, env) => command(MOSAIC, args, env);
|
||||
|
||||
// sage-1081: these six fell through to the seat CLI, which refused with its own usage, so W1
|
||||
// (`plugins set`) and W4 (`name`) failed through the wrapper. Each now reaches packages/cli.
|
||||
test("row 175: scripts/mosaic sends plugins, name, names, follow, threads and thread to packages/cli, not the seat CLI", (t) => {
|
||||
@@ -157,36 +159,55 @@ test("row 175: scripts/mosaic sends plugins, name, names, follow, threads and th
|
||||
});
|
||||
|
||||
const listing = (dir) => readdirSync(dir, { recursive: true }).map(String).sort();
|
||||
// Names, types and file bytes: a rewrite of an existing file shows up, not just a new path.
|
||||
const contents = (dir) => listing(dir).map((name) => {
|
||||
const path = join(dir, name), st = lstatSync(path);
|
||||
return [name, st.isFile() ? readFileSync(path).toString("base64") : st.isDirectory() ? "dir" : `mode ${st.mode}`];
|
||||
});
|
||||
async function w7(t, vars, edit) {
|
||||
const { root, f, org } = await orgFixture(t, "acme", edit);
|
||||
const rt = await startBroker({ dataRoot: org.root, businesses: bus(vars) });
|
||||
const root = tmp(t, "w7-");
|
||||
const f = fixture(root, "acme", edit);
|
||||
const rt = await startBroker({ dataRoot: f.dataRoot, businesses: bus(vars) });
|
||||
t.after(() => rt.close());
|
||||
return { root, f, agent: { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } };
|
||||
// Production CLI routes its transport through an organization. Inject the real tester transport
|
||||
// in a child so the legacy non-org project path can still exercise the broker and its proof.
|
||||
const driver = join(root, "driver.mjs");
|
||||
writeFileSync(driver, `
|
||||
import { run } from ${JSON.stringify(new URL("../src/cli.mjs", import.meta.url).href)};
|
||||
import { testerTransport } from ${JSON.stringify(new URL("../src/transport.mjs", import.meta.url).href)};
|
||||
const [socket, ...args] = process.argv.slice(2);
|
||||
process.exitCode = await run(args, undefined, {
|
||||
testerTransport: business => testerTransport({ socket, business }),
|
||||
});
|
||||
`);
|
||||
const agent = { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" };
|
||||
const project = (args, env = agent) => command(process.execPath, [driver, join(f.dataRoot, "bus", "broker.sock"), ...args], env);
|
||||
return { root, f, agent, project };
|
||||
}
|
||||
|
||||
test("row 175 W7: project init --tester makes the repository under the project area, committed by tester", async (t) => {
|
||||
const { f, agent } = await w7(t);
|
||||
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
|
||||
const { f, agent, project } = await w7(t);
|
||||
const r = await project(["project", "init", "acme", "stack", "--tester"], agent);
|
||||
assert.equal(r.status, 0, r.err);
|
||||
const repo = join(f.dataRoot, "projects", "acme", "stack");
|
||||
assert.match(r.out, new RegExp(`^initialized ${repo}\ncommit [0-9a-f]{40}\n$`));
|
||||
const log = spawnSync("git", ["-C", repo, "log", "--format=%an|%cn"], { encoding: "utf8" });
|
||||
assert.equal(log.stdout, "tester|tester\n");
|
||||
// Without --tester the same init inside an agent refuses, as before.
|
||||
const again = await mosaic(["project", "init", "acme", "stack"], agent);
|
||||
const again = await project(["project", "init", "acme", "stack"], agent);
|
||||
assert.equal(again.status, 3);
|
||||
assert.match(again.err, /outside any agent run/);
|
||||
});
|
||||
|
||||
test("row 175 W7: a repo outside the project area refuses tester-project-area before any bus call, and nothing changes", async (t) => {
|
||||
// The switch is off: a bind would refuse tester-off (exit 3), so exit 2 shows the area check ran first.
|
||||
const { f, agent } = await w7(t, null, (doc) => {
|
||||
const { f, agent, project } = await w7(t, null, (doc) => {
|
||||
doc.projects.stack.repo = join(doc.projects.stack.root, "..", "records");
|
||||
return doc;
|
||||
});
|
||||
const records = f.doc.projects.stack.repo;
|
||||
const data = listing(f.dataRoot);
|
||||
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
|
||||
const r = await project(["project", "init", "acme", "stack", "--tester"], agent);
|
||||
assert.equal(r.status, 2, r.err);
|
||||
assert.match(r.err, /tester-project-area/);
|
||||
assert.equal(existsSync(records), false);
|
||||
@@ -196,7 +217,7 @@ test("row 175 W7: a repo outside the project area refuses tester-project-area be
|
||||
test("row 175 W7: the tester bind refuses tester-off and tester-required before any directory", async (t) => {
|
||||
const off = await w7(t, null);
|
||||
const data = listing(off.f.dataRoot);
|
||||
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], off.agent);
|
||||
const r = await off.project(["project", "init", "acme", "stack", "--tester"], off.agent);
|
||||
assert.equal(r.status, 3, r.err);
|
||||
assert.match(r.err, /tester-off/);
|
||||
assert.deepEqual(listing(off.f.dataRoot), data);
|
||||
@@ -204,10 +225,21 @@ test("row 175 W7: the tester bind refuses tester-off and tester-required before
|
||||
const on = await w7(t);
|
||||
const before = listing(on.f.dataRoot);
|
||||
for (const marker of ["MOSAIC_RUN_ID", "MOSAIC_BUS_CAP", "MOSAIC_AGENT_RUN"]) {
|
||||
const under = await mosaic(["project", "init", "acme", "stack", "--tester"], { ...on.agent, [marker]: "x" });
|
||||
const under = await on.project(["project", "init", "acme", "stack", "--tester"], { ...on.agent, [marker]: "x" });
|
||||
assert.equal(under.status, 3, `${marker}: ${under.err}`);
|
||||
assert.match(under.err, /tester-required/, marker);
|
||||
}
|
||||
assert.deepEqual(listing(on.f.dataRoot), before);
|
||||
assert.equal(existsSync(join(on.f.dataRoot, "projects")), false);
|
||||
});
|
||||
|
||||
// Organization setup deliberately refuses tester init before creating a project record.
|
||||
test("organization project init --tester refuses without writing", async (t) => {
|
||||
const { f, org } = await orgFixture(t);
|
||||
const before = contents(org.root);
|
||||
const agent = { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" };
|
||||
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
|
||||
assert.equal(r.status, 3, r.err);
|
||||
assert.match(r.err, /organization project init --tester is unavailable/);
|
||||
assert.deepEqual(contents(org.root), before);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -0,0 +1,129 @@
|
||||
// Accounts design draft 3, section 3 / M1 (#1873). Pure org policy only:
|
||||
// these tables do not grant access, resolve ownership, or inspect a caller.
|
||||
const record = entries => Object.freeze(Object.assign(Object.create(null), entries));
|
||||
const flag = grantable => Object.freeze({ grantable });
|
||||
|
||||
export const RIGHTS = record({
|
||||
'org.view': flag(true),
|
||||
'thread.read': flag(true),
|
||||
'thread.write': flag(true),
|
||||
'board.read': flag(true),
|
||||
'task.write': flag(true),
|
||||
decide: flag(true),
|
||||
'request.write': flag(false),
|
||||
'members.manage': flag(false),
|
||||
'org.setup': flag(false),
|
||||
'org.lifecycle': flag(false),
|
||||
});
|
||||
|
||||
// Templates are expanded into stored rights, never evaluated as live roles.
|
||||
// Only owner.json confers ownership; the owner bundle is never grantable.
|
||||
export const ROLE_RIGHTS = record({
|
||||
owner: Object.freeze(Object.keys(RIGHTS).sort()),
|
||||
member: Object.freeze(['board.read', 'org.view', 'thread.read']),
|
||||
collaborator: Object.freeze(['board.read', 'org.view', 'task.write', 'thread.read', 'thread.write']),
|
||||
approver: Object.freeze(['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write']),
|
||||
});
|
||||
|
||||
// A reserved right still names the owner's operation. Null marks an explicit
|
||||
// owner-only operation with no member right. An unknown verb yields undefined.
|
||||
// Null-prototype records keep inherited property names from becoming rights.
|
||||
// M5 must separately exclude credential.mint from decide and assignees from
|
||||
// member task writes; these names alone cannot express action/field restrictions.
|
||||
export const VERB_RIGHTS = record({
|
||||
'project.list': 'org.view',
|
||||
names: 'org.view',
|
||||
'thread.list': 'thread.read',
|
||||
'thread.messages': 'thread.read',
|
||||
plugins: 'thread.read',
|
||||
'thread.open': 'thread.write',
|
||||
'thread.create': 'thread.write',
|
||||
'thread.send': 'thread.write',
|
||||
'thread.share': 'thread.write',
|
||||
'thread.unshare': 'thread.write',
|
||||
'thread.shares': 'thread.write',
|
||||
inbox: 'board.read',
|
||||
agents: 'board.read',
|
||||
'thread.claims': 'board.read',
|
||||
tasks: 'board.read',
|
||||
decisions: 'board.read',
|
||||
usage: 'board.read',
|
||||
limits: 'board.read',
|
||||
'request.get.console': 'board.read',
|
||||
'request.projects.console': 'board.read',
|
||||
'request.artifact.console': 'board.read',
|
||||
'request.workspace.console': 'board.read',
|
||||
trail: null,
|
||||
credentials: null,
|
||||
replies: null,
|
||||
messages: null,
|
||||
state: null,
|
||||
'tasks.create': 'task.write',
|
||||
'tasks.edit': 'task.write',
|
||||
'tasks.archive': 'task.write',
|
||||
'tasks.unarchive': 'task.write',
|
||||
'tasks.delete': null,
|
||||
'decision.preview.console': 'decide',
|
||||
'decision.resolve.console': 'decide',
|
||||
'request.answer.console': 'request.write',
|
||||
'request.attach.console': 'request.write',
|
||||
'request.cancel.console': 'request.write',
|
||||
'request.mode.console': 'request.write',
|
||||
'request.draft.console': 'request.write',
|
||||
'prd.revoke.console': 'request.write',
|
||||
'plugins.set': 'org.setup',
|
||||
'system-types.console': 'org.setup',
|
||||
'project.remote.get.console': 'org.setup',
|
||||
'project.remote.set.console': 'org.setup',
|
||||
});
|
||||
|
||||
function refuse(code) {
|
||||
const error = new Error(code);
|
||||
error.code = code;
|
||||
throw error;
|
||||
}
|
||||
const known = right => typeof right === 'string' && Object.hasOwn(RIGHTS, right);
|
||||
|
||||
// Only thread writes need the collaborator scope level. Unknown rights must
|
||||
// refuse before the viewer fallback can run.
|
||||
export function rightLevel(right) {
|
||||
if (!known(right)) refuse('unknown-right');
|
||||
return right === 'thread.write' ? 'collaborator' : 'viewer';
|
||||
}
|
||||
|
||||
function memberRights(rights) {
|
||||
if (!Array.isArray(rights)) refuse('invalid-rights');
|
||||
for (const right of rights) {
|
||||
if (right === 'owner') refuse('right-not-grantable');
|
||||
if (!known(right)) refuse('unknown-right');
|
||||
if (!RIGHTS[right].grantable) refuse('right-not-grantable');
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize a membership write; never mutate its current rights or the role
|
||||
// template. Explicit removal of the compulsory org.view right always refuses.
|
||||
// When add and remove overlap, removal wins (except org.view, which refuses).
|
||||
export function normalizeRights(rights, options = {}) {
|
||||
if (options === null || typeof options !== 'object' || Array.isArray(options) ||
|
||||
Object.keys(options).some(key => !['add', 'remove'].includes(key))) refuse('invalid-rights');
|
||||
const add = Object.hasOwn(options, 'add') ? options.add : [];
|
||||
const remove = Object.hasOwn(options, 'remove') ? options.remove : [];
|
||||
memberRights(rights);
|
||||
memberRights(add);
|
||||
memberRights(remove);
|
||||
if (remove.includes('org.view')) refuse('right-not-removable');
|
||||
const result = new Set(['org.view', ...rights, ...add]);
|
||||
for (const right of remove) result.delete(right);
|
||||
return Object.freeze([...result].sort());
|
||||
}
|
||||
|
||||
// Subset check only: the caller must independently establish the fresh owner's
|
||||
// members.manage authority. A malformed or unknown granter set grants nothing.
|
||||
export function canGrant(granterRights, rights) {
|
||||
if (!Array.isArray(granterRights) || !Array.isArray(rights)) return false;
|
||||
for (const right of granterRights) if (!known(right)) return false;
|
||||
for (const right of rights) {
|
||||
if (!known(right) || !RIGHTS[right].grantable || !granterRights.includes(right)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { hasModule } from "../../plugins/src/loader.mjs";
|
||||
import { checkMapping } from "../../library/src/mapping.mjs";
|
||||
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
|
||||
import { checkAppearance } from "./appearance.mjs";
|
||||
import { checkProjectAgents } from "./agent-levels.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
} from "./util.mjs";
|
||||
@@ -279,7 +280,10 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
|
||||
projects[id] = checkMapping(entry, `${file} projects.${id}`, { fail: (code, message) => refuse(`${code}: ${message}`) });
|
||||
continue;
|
||||
}
|
||||
rejectUnknownKeys(entry, ["root", "repo", "remote", "git"], `${file} projects.${id}`);
|
||||
rejectUnknownKeys(entry, ["root", "repo", "remote", "git", "agents"], `${file} projects.${id}`);
|
||||
// #1873 A1: the project's own agent entries. Checked so a bad entry refuses the whole file, but
|
||||
// not copied below: nothing a bus loads sees them; only the Console's agent-levels read does.
|
||||
if (Object.hasOwn(entry, "agents")) checkProjectAgents(entry.agents, { rolesDir, where: `projects.${id}.agents` });
|
||||
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
|
||||
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// #1873 Phase A slice A1: the three agent levels and their union (design
|
||||
// docs/plans/2026-11-10_agent-levels-design.md sections 2, 3 and 6). Read-only: nothing here writes.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
checkProjectAgents, checkRootAgents, loadRootAgents, projectUnion, resolveAgentLevels, validateOrganizationDocument,
|
||||
} from "../src/index.mjs";
|
||||
import { businessDoc, REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const rolesDir = REPO_ROLES;
|
||||
const NONE = Object.freeze({ state: "absent", agents: {}, assistant: null });
|
||||
const root = (agents) => checkRootAgents({ agentsVersion: 1, agents }, { rolesDir });
|
||||
const project = (agents) => checkProjectAgents(agents, { rolesDir, where: "projects.web.agents" });
|
||||
const ORG = { pm: { definition: "pm", vars: {} }, reviewer: { definition: "reviewer", vars: {} }, chief: { definition: "chief-of-staff", vars: {} } };
|
||||
const names = (union) => union.agents.map((a) => `${a.level}:${a.name}`);
|
||||
const refuses = (fn, pattern) => assert.throws(fn, (error) => error.name === "BusinessError" && pattern.test(error.message));
|
||||
|
||||
test("root level alone: each root agent runs in the organization it is used from", () => {
|
||||
const r = root({ researcher: { definition: "research", vars: { model: "claude-opus-5-5" } }, assistant: { definition: "assistant" } });
|
||||
assert.equal(r.assistant, "assistant");
|
||||
const union = projectUnion({ orgId: "acme", root: r, org: {}, project: {}, projectId: "web" });
|
||||
assert.deepEqual(names(union), ["root:researcher"]);
|
||||
assert.deepEqual(union.agents[0].scope, { kind: "org", org: "acme" });
|
||||
assert.equal(union.agents[0].vars.model, "claude-opus-5-5");
|
||||
// The same root agent used from another organization is scoped to that one, never to both.
|
||||
assert.deepEqual(projectUnion({ orgId: "home", root: r, org: {} }).agents[0].scope, { kind: "org", org: "home" });
|
||||
});
|
||||
|
||||
test("organization level alone: its instances, scoped to it", () => {
|
||||
const union = projectUnion({ orgId: "acme", root: NONE, org: ORG });
|
||||
assert.deepEqual(names(union), ["org:chief", "org:pm", "org:reviewer"]);
|
||||
assert.ok(union.agents.every((a) => a.scope.org === "acme"));
|
||||
assert.equal(union.project, null);
|
||||
});
|
||||
|
||||
test("project level alone: a project-defined agent", () => {
|
||||
const union = projectUnion({ orgId: "acme", root: NONE, org: {}, project: project({ checker: { definition: "fact-checker" } }), projectId: "web" });
|
||||
assert.deepEqual(names(union), ["project:checker"]);
|
||||
assert.equal(union.agents[0].project, "web");
|
||||
assert.equal(union.agents[0].definition, "fact-checker");
|
||||
});
|
||||
|
||||
test("clash: the most specific level wins and the shadowed definitions are recorded", () => {
|
||||
const r = root({ reviewer: { definition: "reviewer", vars: { model: "glm-5.3-flash" } } });
|
||||
const p = project({ reviewer: { definition: "security-reviewer" } });
|
||||
const union = projectUnion({ orgId: "acme", root: r, org: ORG, project: p, projectId: "web" });
|
||||
const reviewer = union.agents.find((a) => a.name === "reviewer");
|
||||
assert.equal(reviewer.level, "project");
|
||||
assert.equal(reviewer.definition, "security-reviewer");
|
||||
assert.deepEqual(reviewer.overrides, [{ level: "root", definition: "reviewer" }, { level: "org", definition: "reviewer" }]);
|
||||
// The whole entry wins: the root's model does not leak into the project definition.
|
||||
assert.equal(reviewer.vars.model, undefined);
|
||||
assert.equal(union.agents.filter((a) => a.name === "reviewer").length, 1);
|
||||
});
|
||||
|
||||
test("project narrowing: intersect keys only, never wider than what it inherits", () => {
|
||||
const org = { ...ORG, coder: { definition: "coder", vars: { model: "claude-sonnet-5-5", "limits.network": "api-only", "limits.tools": ["read", "bash"] } } };
|
||||
const p = project({ coder: { vars: { "limits.network": "open", "limits.tools": ["read", "edit"] } } });
|
||||
const coder = projectUnion({ orgId: "acme", root: NONE, org, project: p, projectId: "web" }).agents.find((a) => a.name === "coder");
|
||||
assert.equal(coder.level, "org");
|
||||
assert.equal(coder.vars["limits.network"], "api-only"); // asking for open stays at the narrower api-only
|
||||
assert.deepEqual(coder.vars["limits.tools"], ["read"]);
|
||||
assert.equal(coder.vars.model, "claude-sonnet-5-5");
|
||||
assert.deepEqual(coder.narrowed, ["limits.network", "limits.tools"]);
|
||||
assert.equal(coder.vars["tracker.kind"], undefined); // no registry defaults leak in
|
||||
refuses(() => project({ coder: { vars: { model: "claude-opus-5-5" } } }), /can only narrow limits\.tools, limits\.network, limits\.authority, not "model"/);
|
||||
});
|
||||
|
||||
test("exclusions: a root opt-out per organization and a project exclude", () => {
|
||||
const r = root({ reviewer: { definition: "reviewer", excludeOrgs: ["home"] }, researcher: { definition: "research" } });
|
||||
assert.deepEqual(names(projectUnion({ orgId: "home", root: r, org: {} })), ["root:researcher"]);
|
||||
assert.deepEqual(projectUnion({ orgId: "home", root: r, org: {} }).excluded, [{ name: "reviewer", level: "root", by: "root" }]);
|
||||
const union = projectUnion({ orgId: "acme", root: r, org: {}, project: project({ researcher: { exclude: true } }), projectId: "web" });
|
||||
assert.deepEqual(names(union), ["root:reviewer"]);
|
||||
assert.deepEqual(union.excluded, [{ name: "researcher", level: "root", by: "project" }]);
|
||||
});
|
||||
|
||||
test("a name the root turned off, or a project excluded, that a more specific level defines is available, not excluded", () => {
|
||||
const r = root({ reviewer: { definition: "reviewer", excludeOrgs: ["home"] } });
|
||||
// The organization's own reviewer: available under the organization's definition, no opt-out listed.
|
||||
const home = projectUnion({ orgId: "home", root: r, org: { reviewer: { definition: "reviewer", vars: {} } } });
|
||||
assert.deepEqual(names(home), ["org:reviewer"]);
|
||||
assert.deepEqual(home.excluded, []);
|
||||
// A project that defines the name the root turned off for its organization.
|
||||
const web = projectUnion({ orgId: "home", root: r, org: {}, project: project({ reviewer: { definition: "security-reviewer" } }), projectId: "web" });
|
||||
assert.deepEqual(names(web), ["project:reviewer"]);
|
||||
assert.deepEqual(web.excluded, []);
|
||||
// Without a definition of its own, the opt-out is still reported.
|
||||
assert.deepEqual(projectUnion({ orgId: "home", root: r, org: {}, project: {}, projectId: "web" }).excluded, [{ name: "reviewer", level: "root", by: "root" }]);
|
||||
});
|
||||
|
||||
test("the root assistant is cross-organization and never in one organization's union", () => {
|
||||
const r = root({ assistant: { definition: "assistant" } });
|
||||
assert.deepEqual(projectUnion({ orgId: "acme", root: r, org: {} }).agents, []);
|
||||
// An organization's own assistant instance works inside that organization only.
|
||||
const own = projectUnion({ orgId: "acme", root: r, org: { assistant: { definition: "assistant", vars: {} } } });
|
||||
assert.deepEqual(names(own), ["org:assistant"]);
|
||||
assert.deepEqual(own.agents[0].scope, { kind: "org", org: "acme" });
|
||||
refuses(() => root({ helper: { definition: "assistant" } }), /assistant definition is the root agent named assistant only/);
|
||||
});
|
||||
|
||||
test("refusals in a project's union: unknown targets, the chief, the assistant", () => {
|
||||
refuses(() => projectUnion({ orgId: "acme", root: NONE, org: ORG, project: project({ ghost: { exclude: true } }), projectId: "web" }), /names no root or organization agent to exclude/);
|
||||
refuses(() => projectUnion({ orgId: "acme", root: NONE, org: ORG, project: project({ ghost: { vars: { "limits.network": "none" } } }), projectId: "web" }), /to narrow/);
|
||||
refuses(() => projectUnion({ orgId: "acme", root: NONE, org: ORG, project: project({ chief: { exclude: true } }), projectId: "web" }), /can't change the organization's chief-of-staff/);
|
||||
refuses(() => projectUnion({ orgId: "acme", root: NONE, org: ORG, project: project({ chief: { definition: "reviewer" } }), projectId: "web" }), /can't change the organization's chief-of-staff/);
|
||||
});
|
||||
|
||||
test("malformed root definitions refuse", () => {
|
||||
refuses(() => checkRootAgents({ agentsVersion: 2, agents: {} }, { rolesDir }), /agentsVersion/);
|
||||
refuses(() => checkRootAgents({ agentsVersion: 1, agents: {}, extra: 1 }, { rolesDir }), /unsupported root agents key: "extra"/);
|
||||
refuses(() => root({ boss: { definition: "chief-of-staff" } }), /can't be a root agent/);
|
||||
refuses(() => root({ old: { definition: "researcher" } }), /version 1/);
|
||||
refuses(() => root({ x: { definition: "no-such-role" } }), /no usable role definition named no-such-role/);
|
||||
refuses(() => root({ x: { definition: "reviewer", credentials: {} } }), /can't carry credentials/);
|
||||
refuses(() => root({ x: { definition: "reviewer", vars: { "tracker.project": 3 } } }), /can't be set at the agent layer/);
|
||||
refuses(() => root({ x: { definition: "reviewer", excludeOrgs: ["a", "a"] } }), /duplicate entry/);
|
||||
refuses(() => root({ "Bad Name": { definition: "reviewer" } }), /agent name must match/);
|
||||
});
|
||||
|
||||
test("a refused var names its key, never its value", () => {
|
||||
const secret = "sk-secret-marker-7731";
|
||||
const hides = (fn, key) => assert.throws(fn, (error) => error.name === "BusinessError" && error.message.includes(`variable ${key} has a value it can't take`) && !error.message.includes(secret));
|
||||
hides(() => root({ x: { definition: "reviewer", vars: { thinking: secret } } }), "thinking");
|
||||
hides(() => root({ x: { definition: "reviewer", vars: { model: "opus-5.5", harness: `${secret}!` } } }), "harness");
|
||||
hides(() => root({ x: { definition: "reviewer", vars: { "limits.tools": ["read", secret] } } }), "limits.tools");
|
||||
hides(() => project({ x: { definition: "reviewer", vars: { thinking: secret } } }), "thinking");
|
||||
hides(() => project({ x: { vars: { "limits.network": secret } } }), "limits.network");
|
||||
// A message that quotes no value is kept whole, so the owner still reads the rule.
|
||||
refuses(() => root({ x: { definition: "reviewer", vars: { "tracker.project": 3 } } }), /variable tracker\.project can't be set at the agent layer/);
|
||||
refuses(() => root({ x: { definition: "reviewer", vars: { "fallback.model": "m" } } }), /a fallback sets all of/);
|
||||
});
|
||||
|
||||
test("malformed project definitions refuse", () => {
|
||||
refuses(() => project({ x: { exclude: true, vars: {} } }), /exactly \{"exclude": true\}/);
|
||||
refuses(() => project({ x: { exclude: false } }), /exactly/);
|
||||
refuses(() => project({ x: {} }), /defines \(definition\), narrows \(vars\) or excludes/);
|
||||
refuses(() => project({ x: { definition: "chief-of-staff" } }), /can't be a project agent/);
|
||||
refuses(() => project({ x: { definition: "assistant" } }), /can't be a project agent/);
|
||||
refuses(() => project({ x: { definition: "reviewer", hosts: [] } }), /can't carry hosts/);
|
||||
refuses(() => project({ x: { definition: "reviewer", other: 1 } }), /unsupported projects\.web\.agents\.x key/);
|
||||
});
|
||||
|
||||
test("root file: absent, owner and mode checks, symbolic link, bad JSON, and no host path in a message", () => {
|
||||
const dir = tmp();
|
||||
assert.equal(loadRootAgents(dir, { rolesDir }).state, "absent");
|
||||
const file = writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { reviewer: { definition: "reviewer" } } });
|
||||
const ok = loadRootAgents(dir, { rolesDir });
|
||||
assert.equal(ok.state, "ok");
|
||||
assert.deepEqual(Object.keys(ok.agents), ["reviewer"]);
|
||||
chmodSync(file, 0o620);
|
||||
refuses(() => loadRootAgents(dir, { rolesDir }), /writable by group or other/);
|
||||
chmodSync(file, 0o600);
|
||||
writeFileSync(file, "{ nope");
|
||||
assert.throws(() => loadRootAgents(dir, { rolesDir }), (error) => /not valid JSON \(agents\.json\)/.test(error.message) && !error.message.includes(dir));
|
||||
// The parser's own text can quote the file's bytes; the refusal never carries it.
|
||||
writeFileSync(file, '{"agentsVersion": 1, "agents": {"x": secret-marker-7731}}');
|
||||
assert.throws(() => loadRootAgents(dir, { rolesDir }), (error) => error.message === "root agent file is not valid JSON (agents.json)" && error.exitCode !== undefined);
|
||||
// An id check's quote of what it was given is dropped too.
|
||||
writeFileSync(file, JSON.stringify({ agentsVersion: 1, agents: { x: { definition: "Secret Marker 7731" } } }));
|
||||
assert.throws(() => loadRootAgents(dir, { rolesDir }), (error) => /root agents\.x\.definition must match/.test(error.message) && !error.message.includes("Marker"));
|
||||
const linked = tmp();
|
||||
symlinkSync(file, join(linked, "agents.json"));
|
||||
assert.throws(() => loadRootAgents(linked, { rolesDir }), (error) => error.exitCode === 4 && !error.message.includes(linked));
|
||||
});
|
||||
|
||||
test("organization file: project agents are checked but not loaded; a malformed one refuses the file", () => {
|
||||
const dir = tmp();
|
||||
const doc = businessDoc(dir);
|
||||
doc.projects.stack.agents = { coder: { vars: { "limits.network": "none" } }, checker: { definition: "fact-checker" } };
|
||||
const loaded = validateOrganizationDocument(structuredClone(doc), "acme.json", { rolesDir });
|
||||
assert.equal(loaded.projects.stack.agents, undefined);
|
||||
const levels = resolveAgentLevels({ orgId: "acme", doc, root: NONE, rolesDir });
|
||||
assert.equal(levels.unions.stack.ok, true);
|
||||
assert.ok(levels.unions.stack.agents.some((a) => a.name === "checker" && a.level === "project"));
|
||||
assert.deepEqual(levels.unions.stack.agents.find((a) => a.name === "coder").narrowed, ["limits.network"]);
|
||||
doc.projects.stack.agents = { boss: { definition: "chief-of-staff" } };
|
||||
refuses(() => validateOrganizationDocument(doc, "acme.json", { rolesDir }), /can't be a project agent/);
|
||||
});
|
||||
|
||||
test("one project's bad union refuses only that project", () => {
|
||||
const dir = tmp();
|
||||
const doc = businessDoc(dir);
|
||||
mkdirSync(join(dir, "other"));
|
||||
doc.projects.other = { root: join(dir, "other"), agents: { ghost: { exclude: true } } };
|
||||
const levels = resolveAgentLevels({ orgId: "acme", doc, root: NONE, rolesDir });
|
||||
assert.equal(levels.unions.stack.ok, true);
|
||||
assert.equal(levels.unions.other.ok, false);
|
||||
assert.match(levels.unions.other.reason, /names no root or organization agent to exclude/);
|
||||
assert.ok(levels.base.agents.length > 0);
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as policy from '../src/org-rights.mjs';
|
||||
import { CONSOLE_VERBS, USER_VERBS, BOARD_VERBS, PERSON_TASK_VERBS } from '../../bus/src/broker.mjs';
|
||||
|
||||
// Accounts design draft 3, section 3 and M1 (#1873). Expected assignments are
|
||||
// written out: adding a real bus verb or widening a bundle must change this pin.
|
||||
const RIGHTS = {
|
||||
'org.view': { grantable: true },
|
||||
'thread.read': { grantable: true },
|
||||
'thread.write': { grantable: true },
|
||||
'board.read': { grantable: true },
|
||||
'task.write': { grantable: true },
|
||||
decide: { grantable: true },
|
||||
'request.write': { grantable: false },
|
||||
'members.manage': { grantable: false },
|
||||
'org.setup': { grantable: false },
|
||||
'org.lifecycle': { grantable: false },
|
||||
};
|
||||
const BUNDLES = {
|
||||
owner: ['board.read', 'decide', 'members.manage', 'org.lifecycle', 'org.setup', 'org.view', 'request.write', 'task.write', 'thread.read', 'thread.write'],
|
||||
member: ['board.read', 'org.view', 'thread.read'],
|
||||
collaborator: ['board.read', 'org.view', 'task.write', 'thread.read', 'thread.write'],
|
||||
approver: ['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write'],
|
||||
};
|
||||
const VERBS = {
|
||||
'project.list': 'org.view',
|
||||
names: 'org.view',
|
||||
'thread.list': 'thread.read',
|
||||
'thread.messages': 'thread.read',
|
||||
plugins: 'thread.read',
|
||||
'thread.open': 'thread.write',
|
||||
'thread.create': 'thread.write',
|
||||
'thread.send': 'thread.write',
|
||||
'thread.share': 'thread.write',
|
||||
'thread.unshare': 'thread.write',
|
||||
'thread.shares': 'thread.write',
|
||||
inbox: 'board.read',
|
||||
agents: 'board.read',
|
||||
'thread.claims': 'board.read',
|
||||
tasks: 'board.read',
|
||||
decisions: 'board.read',
|
||||
usage: 'board.read',
|
||||
limits: 'board.read',
|
||||
'request.get.console': 'board.read',
|
||||
'request.projects.console': 'board.read',
|
||||
'request.artifact.console': 'board.read',
|
||||
'request.workspace.console': 'board.read',
|
||||
trail: null,
|
||||
credentials: null,
|
||||
replies: null,
|
||||
messages: null,
|
||||
state: null,
|
||||
'tasks.create': 'task.write',
|
||||
'tasks.edit': 'task.write',
|
||||
'tasks.archive': 'task.write',
|
||||
'tasks.unarchive': 'task.write',
|
||||
'tasks.delete': null,
|
||||
'decision.preview.console': 'decide',
|
||||
'decision.resolve.console': 'decide',
|
||||
'request.answer.console': 'request.write',
|
||||
'request.attach.console': 'request.write',
|
||||
'request.cancel.console': 'request.write',
|
||||
'request.mode.console': 'request.write',
|
||||
'request.draft.console': 'request.write',
|
||||
'prd.revoke.console': 'request.write',
|
||||
'plugins.set': 'org.setup',
|
||||
'system-types.console': 'org.setup',
|
||||
'project.remote.get.console': 'org.setup',
|
||||
'project.remote.set.console': 'org.setup',
|
||||
};
|
||||
|
||||
test('M1 the ten rights pin the grantable flags, including compulsory org.view', () => {
|
||||
assert.deepEqual({ ...policy.RIGHTS }, RIGHTS);
|
||||
});
|
||||
|
||||
for (const [role, rights] of Object.entries(BUNDLES)) {
|
||||
test(`M1 ${role} has exactly its reviewed rights, expanded and sorted`, () => {
|
||||
assert.deepEqual(policy.ROLE_RIGHTS[role], rights);
|
||||
});
|
||||
}
|
||||
|
||||
test('M1 owner holds every right; only owner holds any reserved right', () => {
|
||||
assert.deepEqual(policy.ROLE_RIGHTS.owner, Object.keys(policy.RIGHTS).sort());
|
||||
for (const role of ['member', 'collaborator', 'approver']) {
|
||||
for (const right of policy.ROLE_RIGHTS[role]) assert.equal(policy.RIGHTS[right].grantable, true, `${role}: ${right}`);
|
||||
assert.ok(!policy.ROLE_RIGHTS[role].includes('request.write'));
|
||||
}
|
||||
assert.ok(!policy.ROLE_RIGHTS.member.includes('decide'));
|
||||
assert.ok(!policy.ROLE_RIGHTS.member.includes('task.write'));
|
||||
});
|
||||
|
||||
test('M1 canGrant allows only known grantable rights held by the granter', () => {
|
||||
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, policy.ROLE_RIGHTS.approver), true);
|
||||
assert.equal(policy.canGrant(['org.view', 'decide'], ['decide']), true);
|
||||
assert.equal(policy.canGrant(['org.view'], ['thread.read']), false);
|
||||
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.member, policy.ROLE_RIGHTS.collaborator), false);
|
||||
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, []), true);
|
||||
for (const right of ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle', 'future.right', '__proto__', 'constructor', 'toString']) {
|
||||
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, [right]), false, right);
|
||||
}
|
||||
assert.equal(policy.canGrant(['org.view', 'future.right'], ['org.view']), false);
|
||||
});
|
||||
|
||||
test('M1 canGrant refuses malformed lists and values, including sparse arrays', () => {
|
||||
for (const value of [undefined, null, 'owner', {}, 1, [null], [1], [new String('org.view')], Array(1)]) {
|
||||
assert.equal(policy.canGrant(value, ['org.view']), false);
|
||||
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, value), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('M1 normalizeRights sorts, deduplicates, adds org.view and applies explicit additions and removals', () => {
|
||||
const rights = Object.freeze(['thread.read', 'board.read', 'thread.read']);
|
||||
assert.deepEqual(policy.normalizeRights(rights), ['board.read', 'org.view', 'thread.read']);
|
||||
assert.deepEqual(policy.normalizeRights([]), ['org.view']);
|
||||
const add = Object.freeze(['task.write', 'decide', 'task.write']);
|
||||
const remove = Object.freeze(['thread.read']);
|
||||
assert.deepEqual(policy.normalizeRights(rights, { add, remove }), ['board.read', 'decide', 'org.view', 'task.write']);
|
||||
assert.deepEqual(rights, ['thread.read', 'board.read', 'thread.read']);
|
||||
assert.notEqual(policy.normalizeRights(rights), rights);
|
||||
});
|
||||
|
||||
test('M1 normalization refuses any attempt to remove org.view, even when re-added', () => {
|
||||
for (const rights of [[], ['org.view']]) {
|
||||
assert.throws(() => policy.normalizeRights(rights, { add: ['org.view'], remove: ['org.view'] }), { code: 'right-not-removable' });
|
||||
}
|
||||
});
|
||||
|
||||
test('M1 normalization refuses reserved, owner and unknown rights in every input before returning a result', () => {
|
||||
for (const right of ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle', 'future.right', '__proto__', 'constructor', 'toString']) {
|
||||
const code = ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle'].includes(right) ? 'right-not-grantable' : 'unknown-right';
|
||||
for (const [rights, options] of [[[right], {}], [[], { add: [right] }], [[], { remove: [right] }]]) {
|
||||
assert.throws(() => policy.normalizeRights(rights, options), { code }, right);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('M1 normalization refuses malformed lists, options and non-string rights', () => {
|
||||
for (const value of [undefined, null, {}, 'member', 0, [null], [42], [new String('org.view')], Array(1)]) {
|
||||
assert.throws(() => policy.normalizeRights(value));
|
||||
assert.throws(() => policy.normalizeRights([], { add: value }));
|
||||
assert.throws(() => policy.normalizeRights([], { remove: value }));
|
||||
}
|
||||
for (const options of [null, [], 'member', 1, { typo: [] }]) {
|
||||
assert.throws(() => policy.normalizeRights([], options), { code: 'invalid-rights' });
|
||||
}
|
||||
});
|
||||
|
||||
test('M1 VERB_RIGHTS pins every assignment from section 3, with explicit null for unmapped owner-only operations', () => {
|
||||
assert.deepEqual({ ...policy.VERB_RIGHTS }, VERBS);
|
||||
for (const right of Object.values(policy.VERB_RIGHTS)) assert.ok(right === null || Object.hasOwn(policy.RIGHTS, right));
|
||||
});
|
||||
|
||||
test('M1 every actual console, user, board and person-task verb has a reviewed assignment', () => {
|
||||
for (const verbs of [CONSOLE_VERBS, USER_VERBS, BOARD_VERBS, PERSON_TASK_VERBS]) {
|
||||
for (const verb of verbs) {
|
||||
assert.ok(Object.hasOwn(VERBS, verb), `${verb} needs a reviewed assignment`);
|
||||
assert.ok(Object.hasOwn(policy.VERB_RIGHTS, verb), `${verb} missing from VERB_RIGHTS`);
|
||||
assert.equal(policy.VERB_RIGHTS[verb], VERBS[verb], verb);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(Object.keys(VERBS).sort(), [...new Set([
|
||||
...CONSOLE_VERBS, ...USER_VERBS, ...BOARD_VERBS, ...PERSON_TASK_VERBS, 'plugins', 'plugins.set', 'limits',
|
||||
])].sort());
|
||||
});
|
||||
|
||||
test('M1 sensitive board reads, task delete, intake and PRD revoke remain owner-only', () => {
|
||||
for (const verb of ['trail', 'credentials', 'replies', 'messages', 'state', 'tasks.delete']) {
|
||||
assert.equal(policy.VERB_RIGHTS[verb], null, verb);
|
||||
}
|
||||
for (const verb of ['request.answer.console', 'request.attach.console', 'request.cancel.console', 'request.mode.console', 'request.draft.console', 'prd.revoke.console']) {
|
||||
assert.equal(policy.VERB_RIGHTS[verb], 'request.write', verb);
|
||||
assert.equal(policy.RIGHTS[policy.VERB_RIGHTS[verb]].grantable, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('M1 unknown verbs, rights and role property names never inherit an assignment', () => {
|
||||
for (const name of ['future.verb', 'tasks.foo', 'task.create', 'owner', '__proto__', 'constructor', 'toString', '', undefined, null]) {
|
||||
assert.equal(policy.VERB_RIGHTS[name], undefined);
|
||||
assert.equal(policy.RIGHTS[name], undefined);
|
||||
if (name !== 'owner') assert.equal(policy.ROLE_RIGHTS[name], undefined);
|
||||
assert.throws(() => policy.rightLevel(name), { code: 'unknown-right' });
|
||||
}
|
||||
});
|
||||
|
||||
test('M1 only thread.write needs collaborator; every other known right needs viewer', () => {
|
||||
for (const right of Object.keys(RIGHTS)) {
|
||||
assert.equal(policy.rightLevel(right), right === 'thread.write' ? 'collaborator' : 'viewer', right);
|
||||
}
|
||||
for (const value of [{}, [], 1, new String('org.view')]) assert.throws(() => policy.rightLevel(value), { code: 'unknown-right' });
|
||||
});
|
||||
|
||||
test('M1 policy records, grantable flags, bundles and normalized rights are frozen', () => {
|
||||
for (const map of [policy.RIGHTS, policy.ROLE_RIGHTS, policy.VERB_RIGHTS]) {
|
||||
assert.equal(Object.getPrototypeOf(map), null);
|
||||
assert.ok(Object.isFrozen(map));
|
||||
assert.throws(() => { map.extra = 'decide'; }, TypeError);
|
||||
}
|
||||
for (const descriptor of Object.values(policy.RIGHTS)) {
|
||||
assert.ok(Object.isFrozen(descriptor));
|
||||
assert.throws(() => { descriptor.grantable = !descriptor.grantable; }, TypeError);
|
||||
}
|
||||
for (const bundle of Object.values(policy.ROLE_RIGHTS)) {
|
||||
assert.ok(Object.isFrozen(bundle));
|
||||
assert.throws(() => bundle.push('decide'), TypeError);
|
||||
}
|
||||
assert.ok(Object.isFrozen(policy.normalizeRights(['thread.read'])));
|
||||
});
|
||||
@@ -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