Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
200b30b721 | ||
|
|
ff74bde424 | ||
|
|
85e9fcd894 | ||
|
|
fec416d1df | ||
|
|
52a82a4e56 | ||
|
|
5f3706e807 | ||
|
|
5f57008aed | ||
|
|
bc3e0a9f49 | ||
|
|
a71da41146 | ||
|
|
4c74e7ea49 | ||
|
|
ac3a863bec | ||
|
|
af7d330101 | ||
|
|
db75994f82 | ||
|
|
ac977d833b | ||
|
|
aa087e7380 | ||
|
|
68fd66903b | ||
|
|
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.
|
||||
@@ -0,0 +1,212 @@
|
||||
# System-type toolsets at launch
|
||||
|
||||
Status: design only. Nothing here is built and no `roles/` or contract file is
|
||||
changed. Prepared 2026-11-10 against `refactor` `8630b8dfc` for phase P2 of
|
||||
#1873 (unified workspace). PRD requirement: REQ-SYS-2, "An agent working in a
|
||||
project gets the toolsets of the system types that project uses."
|
||||
|
||||
## What exists today
|
||||
|
||||
- **Modules.** `installedSystemTypes()` (`packages/plugins/src/system-types.mjs`)
|
||||
loads every installed `system-type` module and returns
|
||||
`{id, label, description, toolsets}`. It is fail-closed: a refused module, a
|
||||
missing or extra key, or a toolset name that fails `NAME` throws
|
||||
`system-type-invalid`. All five shipped modules (business-management, coding,
|
||||
creative-writing, marketing, montage) declare `toolsets: []`.
|
||||
- **Organization enable.** The organization file's
|
||||
`plugins['system-type']` holds the enabled ids. `plugins.set` writes it
|
||||
through the organization writer behind the owner fence
|
||||
(`organization-system-types-human-only`) and returns a transaction receipt.
|
||||
The bus reads the current set with `system-types.current` on every read and
|
||||
write, so it never trusts a stale snapshot.
|
||||
- **Project selection.** `project.system-types.set` appends a row to
|
||||
`project_system_types` (bus store, `seq`-ordered, latest wins) and accepts
|
||||
only enabled ids (`system-type-not-enabled`). A read returns
|
||||
`{selected, active, inactive}`, where `active = selected ∩ enabled` and
|
||||
`inactive = selected − enabled`.
|
||||
- **Launch.** `resolveInstance` (`packages/organization/src/resolve.mjs`)
|
||||
starts from the role definition's `tools` and `network`, narrows them by
|
||||
each layer's `limits.tools` and `limits.network` (system, business,
|
||||
project, agent), and freezes the result with a digest. The role's `tools`
|
||||
is therefore a ceiling: a launch gets those tools minus whatever a layer
|
||||
limit removes, never more. The launcher (`packages/cli/src/launcher.mjs`) passes that
|
||||
result to `buildBundle` (`packages/harness/src/bundle.mjs`), which writes
|
||||
`policy.json` (`tools: builtinTools(resolved.limits.tools)`), `tools.json`
|
||||
(typed host tools) and `manifest.json` once each (`flag: "wx"`). Typed
|
||||
host tools (`typedTools`, `packages/harness/src/tools.mjs`) are not granted
|
||||
by tool name: each is built only when the resolved `authority` holds the
|
||||
action it needs.
|
||||
- **Run tasks.** `scripts/mosaic-task.mjs` validates a mission's
|
||||
`capabilities.tools` as a ceiling and a task's `capabilities.tools` as the
|
||||
allowlist, both drawn from `SUPPORTED_TOOLS`. A task narrows the mission.
|
||||
It never widens it.
|
||||
|
||||
Nothing on the launch path reads system types today, so every launch gets
|
||||
its role's tools as narrowed by the layer limits. That is the coding
|
||||
module's behavior, and the design keeps it as the default.
|
||||
|
||||
## Resolution at launch
|
||||
|
||||
A launch resolves its toolsets in the host-side launcher, before
|
||||
`resolveInstance` freezes the digest. In order:
|
||||
|
||||
1. **Pick the project.** The launch's project is the one the session is
|
||||
started for: the thread's project for a project thread, or none. A launch
|
||||
with no single project (an organization-wide session, or a role started
|
||||
with several code projects in scope) gets no system-type toolsets. Taking
|
||||
the union across projects would widen one project's agent with another
|
||||
project's tools, so the union is ruled out.
|
||||
2. **Read the active set.** The launcher asks the bus, as the host, for the
|
||||
project's selection and computes `active = selected ∩ enabled`. `enabled`
|
||||
comes from `system-types.current`, the same trusted read the routes use.
|
||||
Inactive selections are dropped and recorded in the manifest. They do not
|
||||
refuse the launch, because the owner may disable a type at any time and
|
||||
that must not strand running work.
|
||||
3. **Load the modules.** `installedSystemTypes()` runs. If it throws
|
||||
`system-type-invalid`, the launch refuses with that code. An active id
|
||||
with no installed module refuses `system-type-missing`.
|
||||
4. **Map toolset names to tools.** Each active module's `toolsets` are looked
|
||||
up in the reviewed mapping file (below). The result is a set of tool names,
|
||||
each a member of the role vocabulary `TOOLS`
|
||||
(`packages/organization/src/vocabulary.mjs`). Typed host tools cannot be
|
||||
named: they stay gated by the role's `authority` actions exactly as today,
|
||||
and a system type never changes `authority`.
|
||||
5. **Bound by the ceilings.** The role's `tools` stays the hard ceiling for
|
||||
every effective tool, including any a system type switches on. A role may
|
||||
hold some of its tools back for typed projects by listing them in
|
||||
`systemTypeTools` (new, see the proposal), which must be a subset of the
|
||||
role's `tools`. Then:
|
||||
- `base` = (role `tools` − `systemTypeTools`), narrowed by every layer's
|
||||
`limits.tools`, as `resolveInstance` does today;
|
||||
- `added` = mapped tools ∩ `systemTypeTools`, narrowed by every layer's
|
||||
`limits.tools` the same way;
|
||||
- `effective` = `base ∪ added`;
|
||||
- for a run task, `effective` is then intersected with the mission's
|
||||
`capabilities.tools` and then the task's. That intersection covers
|
||||
base tools too, as it does today.
|
||||
|
||||
Every term is a subset of role `tools` ∩ every layer limit, so a system
|
||||
type can only switch on a tool the role already holds and the layers
|
||||
already allow. Invariant 7 holds because each later step can only remove
|
||||
entries. A role without `systemTypeTools` behaves exactly as today.
|
||||
6. **Record it.** `resolveInstance` takes the added list as an input and
|
||||
includes `systemTypes: {project, active, dropped, toolsets, added}` in the
|
||||
resolved object, so the digest covers it. `buildBundle` writes the same
|
||||
block into `manifest.json`. The run record then shows which types and
|
||||
mapping produced each tool.
|
||||
|
||||
No step adds credentials, network reach or authority. `limits.network` and
|
||||
`authority` are computed exactly as today. A toolset that needs a credential
|
||||
gets it the way any role does, through the role file's `credentials` and a
|
||||
reviewed commit. A system type never grants one.
|
||||
|
||||
## The mapping file
|
||||
|
||||
The mapping is repository policy, so it lives under `roles/` and changes only
|
||||
through a reviewed commit, the same rule that governs `roles/*.json`
|
||||
(invariant 7, AGENTS.md "Role model"). Proposed path:
|
||||
`roles/toolsets/system-types.json`. It sits in a subdirectory because two
|
||||
loaders read every `roles/*.json` as a role definition
|
||||
(`instance-settings.mjs` `definitions()`, `harness/tests/tools.test.mjs`).
|
||||
A mapping file at `roles/` top level would be read as a broken role.
|
||||
|
||||
Proposed format:
|
||||
|
||||
```json
|
||||
{
|
||||
"toolsetsVersion": 1,
|
||||
"toolsets": {
|
||||
"repository": { "tools": ["read", "write", "edit", "bash", "grep", "find", "ls"] },
|
||||
"web-research": { "tools": ["web"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The two entries are illustrations, not part of the proposal. No shipped
|
||||
module names a toolset today, so the file would ship as
|
||||
`{"toolsetsVersion": 1, "toolsets": {}}`.
|
||||
|
||||
Rules a loader enforces, fail-closed:
|
||||
|
||||
- Exactly the keys `toolsetsVersion` (must be `1`) and `toolsets`. Each entry
|
||||
has exactly the key `tools`.
|
||||
- Each toolset name matches `NAME` from `packages/plugins/src/kinds.mjs`, the
|
||||
same pattern modules must use.
|
||||
- `tools` is a non-empty list of distinct names, each in `TOOLS`. A typed
|
||||
host tool name, or anything else, refuses `toolset-mapping-invalid` at
|
||||
load.
|
||||
- The file is read from the release copy, like the role files, and its
|
||||
sha256 goes into `manifest.sources`.
|
||||
|
||||
Who may change it: a reviewed commit on the repository, landed by the lead
|
||||
with Jason's authority over role policy. An organization owner cannot change
|
||||
it at runtime, and neither the Console nor the bus has a write path to it.
|
||||
Organization owners choose which types are enabled and projects choose among
|
||||
them. The repository decides what a type's toolsets may contain.
|
||||
|
||||
## Fail-closed behavior
|
||||
|
||||
| Condition | Result |
|
||||
| --- | --- |
|
||||
| An installed module is refused or malformed | launch refuses `system-type-invalid` |
|
||||
| An active id has no installed module | launch refuses `system-type-missing` |
|
||||
| A module names a toolset the mapping lacks | launch refuses `toolset-unmapped` |
|
||||
| The mapping file is missing, unreadable or invalid | launch refuses `toolset-mapping-invalid`, but only when at least one active type declares a toolset; a launch with nothing to map never reads it |
|
||||
| A mapped tool is outside the role's `systemTypeTools` (and so not switched on) | the tool is dropped and recorded under `dropped`, launch continues |
|
||||
| The bus cannot answer the project's selection | launch refuses `system-types-unavailable`; it does not fall back to "no types", which could hide a missing toolset the operator expects |
|
||||
| A selection is inactive (disabled in the organization) | dropped and recorded, launch continues |
|
||||
|
||||
The two "continue" rows are narrowing, never widening. Every row that could
|
||||
change what an agent may do refuses instead.
|
||||
|
||||
## The coding module
|
||||
|
||||
Coding declares `toolsets: []`. It adds nothing, and a coding project's agent
|
||||
keeps its role's tools as the layer limits narrow them: for
|
||||
`roles/coder.json` the ceiling is `read, write, edit, bash, grep, find, ls`
|
||||
with network `api-only`, the same as today. Turning the design on changes nothing for any shipped module until a
|
||||
module declares a toolset and a reviewed commit maps it.
|
||||
|
||||
## Proposed `roles/` change (not applied)
|
||||
|
||||
1. Add an optional V2 key `systemTypeTools` to the role schema
|
||||
(`packages/organization/src/role.mjs`): a distinct list from `TOOLS` (same
|
||||
check as `checkTools`, but empty is allowed) that must be a subset of the
|
||||
role's own `tools`, or the role file refuses at load. It names the tools
|
||||
the role holds back unless an active system type maps them. Absent means
|
||||
`[]`: nothing is held back and no system type changes that role's tools.
|
||||
Every existing role file stays valid and behaves as today, and
|
||||
`checkCreative` keeps inspecting `tools`, which still bounds everything.
|
||||
|
||||
```diff
|
||||
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
|
||||
-const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable"];
|
||||
+const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable", "systemTypeTools"];
|
||||
```
|
||||
|
||||
2. Add `roles/toolsets/system-types.json` with an empty `toolsets` object, and
|
||||
a loader beside the role loader with the checks listed above.
|
||||
3. Have `resolveInstance` accept `systemTypes` (the launcher's step 1-4
|
||||
result) and apply step 5. Have `buildBundle` copy the block into the
|
||||
manifest. `scripts/mosaic-task.mjs` already bounds tools by mission and
|
||||
task. It would apply that filter to the final effective set.
|
||||
|
||||
No contract under `contracts/` changes: the constitution's least-privilege
|
||||
rule already covers this. The design adds no grant path: the effective set
|
||||
stays within role `tools` and every layer limit, and `authority`, network and
|
||||
credentials are untouched. What it does add is a runtime input (the
|
||||
project's active system types) that can switch a held-back tool on, so a
|
||||
role author who lists a tool in `systemTypeTools` is deciding that an
|
||||
organization owner's enable plus a project's selection may turn it on. Each of the
|
||||
three steps is a reviewed change to role policy and needs Jason's sign-off
|
||||
before it is built.
|
||||
|
||||
## Open points for the build
|
||||
|
||||
- Business-file projects (`b.projects`, used for checkouts) and bus projects
|
||||
(UUIDs in `project_system_types`) are separate records today. Step 1 needs
|
||||
the launch to carry the bus project id of the thread it serves. The build
|
||||
should confirm how a project thread names its project at launch before
|
||||
wiring step 2.
|
||||
- A running session keeps the toolsets it launched with. Disabling a type
|
||||
takes effect at the next launch, which matches how role changes behave.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serv
|
||||
import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
|
||||
import { organizationConsole } from './organizations.mjs';
|
||||
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
|
||||
import { SYSTEM_TYPE_VERBS } from './system-types-http.mjs';
|
||||
import { consoleAccess } from './access.mjs';
|
||||
import { accessContextFor } from '../../cli/src/access.mjs';
|
||||
import { accessFilePath, loadAccess } from '../../organization/src/access.mjs';
|
||||
@@ -16,6 +17,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';
|
||||
@@ -168,10 +170,15 @@ try {
|
||||
// The owner reads request status on the selected organization's bus, as on the root Console. Request
|
||||
// writes stay off: the README "Console actions" inventory marks them unavailable for the organizations release.
|
||||
requestRead: session.user === undefined ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
|
||||
// Phase P2 (#1873): the owner's System types writes, the organization's enable switch (plugins.set
|
||||
// with its revision) and a project's selection, over the owner's human transport under --send only.
|
||||
systemTypesWrite: session.user === undefined && sendOn ? agentCaller(owner, SYSTEM_TYPE_VERBS) : null,
|
||||
reads: consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: id, orgRoot: root }),
|
||||
};
|
||||
},
|
||||
}) : 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 +190,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 });
|
||||
|
||||
@@ -12,6 +12,9 @@ export const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.
|
||||
export const PROXY_VERBS = Object.freeze(['proxy.accounts', 'proxy.login.start', 'proxy.login.submit',
|
||||
'proxy.login.status', 'proxy.login.cancel', 'proxy.apikey.add', 'proxy.priority.set', 'proxy.group.models.set', 'proxy.keys', 'plugins', 'plugins.set']);
|
||||
export const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request'];
|
||||
// U4b (#1873): the task board's writes, the person's built-in store verbs (broker tasks.*). The
|
||||
// caller refuses any other, so the board's taskWrite never reaches unarchive or delete.
|
||||
export const TASK_WRITE_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive']);
|
||||
export const agentCaller = (call, verbs) => async (verb, args) => {
|
||||
if (!verbs.includes(verb)) throw new Error(`the Console does not call ${verb}`);
|
||||
return call(verb, args);
|
||||
@@ -36,13 +39,14 @@ export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
|
||||
projectFiles: args => call('project.files', args),
|
||||
projectFile: args => call('project.file', args),
|
||||
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
|
||||
send: null, librarySync: null, libraryRevert: null, librarySave: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
|
||||
send: null, librarySync: null, libraryRevert: null, librarySave: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null, taskWrite: null,
|
||||
};
|
||||
if (sendOn) {
|
||||
o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.libraryRevert = args => call('library.revert', args); o.settingsWrite = args => call('limits.set', args);
|
||||
o.librarySave = args => call('library.file.save', args);
|
||||
o.projectSave = args => call('project.file.save', args); o.nameWrite = args => call('name.set', args); o.assistantStop = args => call('assistant.stop', args);
|
||||
o.instancesWrite = args => call('instances.set', args); o.routesWrite = args => call('routes.set', args); o.agentWrite = agentCaller(call, AGENT_VERBS);
|
||||
o.taskWrite = agentCaller(call, TASK_WRITE_VERBS);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
@@ -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,11 +711,13 @@
|
||||
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}
|
||||
<p class="small muted">Board seats stay on the <a href="#/">control board</a>. This view is about roles.</p>`;
|
||||
<p class="small muted">Board seats stay on the <a href="${window.MosaicOrg ? '#/board' : '#/'}">control board</a>. This view is about roles.</p>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- queue, business, settings
|
||||
@@ -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') {
|
||||
@@ -1361,7 +1391,7 @@
|
||||
});
|
||||
|
||||
// Not found and states
|
||||
function notFound(why) { return `${head('Not found')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="#/">Go to the Board</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
|
||||
function notFound(why) { return `${head('Not found')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="#/">Go to the ${window.MosaicOrg ? 'Dashboard' : 'Board'}</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
|
||||
const loadingView = (source = null) => `${head('Reading…')}<div class="skel-rows" aria-busy="true"><span class="sr-only">${source ? `Reading the ${esc(source.toLowerCase())}.` : 'Reading from the bus.'}</span><span class="skel"></span><span class="skel"></span><span class="skel"></span><span class="skel"></span></div>`;
|
||||
const retry = '<div class="actions"><button type="button" class="btn" data-retry>Read again</button></div>';
|
||||
// Why a read failed, in words, with the bus code as given. Never a path or a guess.
|
||||
@@ -1416,6 +1446,11 @@
|
||||
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
|
||||
if (parts.includes(null)) return notFound();
|
||||
const [a, b, c] = parts, n = parts.length;
|
||||
if (n === 0 && window.MosaicOrg) return window.MosaicWorkspace.home();
|
||||
if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q);
|
||||
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
|
||||
if (a === 'inbox' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.inboxPage(q);
|
||||
if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view();
|
||||
if (a === 'inbox' && n === 1) return inboxView(get);
|
||||
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
|
||||
if (a === 'messages' && n === 1) return messagesView(get, q);
|
||||
@@ -1435,12 +1470,15 @@
|
||||
let gen = 0, shown = null, timer, proxyCleanup=null, orgCleanup=null;
|
||||
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
|
||||
// A file open in the editor is never refreshed from under its draft; Reload is the person's to press.
|
||||
function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), 10000); }
|
||||
// The cross-organization pages (U3) poll at the workspace read's pollMs.
|
||||
const wsPage = path => !!window.MosaicOrg && (path === '/' || path === '' || path === '/projects' || path === '/inbox');
|
||||
function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), wsPage(routeOf().split('?')[0]) ? window.MosaicWorkspace?.pollMs || 10000 : 10000); }
|
||||
// Keep focus, open <details> and the copy status across a refresh of the same page.
|
||||
// In the send form, the focused field and its caret too (its values come from `draft`).
|
||||
function keep() {
|
||||
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,
|
||||
@@ -1448,6 +1486,7 @@
|
||||
field: view.contains(a) ? a.dataset?.field : null, caret: view.contains(a) && typeof a.selectionStart === 'number' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null, send: view.contains(a) && a.hasAttribute?.('data-send'),
|
||||
decide: view.contains(a) ? a.dataset?.decide ?? (a.id === 'decide-status' ? 'status' : null) : null,
|
||||
open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '',
|
||||
scroll: [document.scrollingElement?.scrollTop ?? 0, $('main').scrollTop, [...view.querySelectorAll('.ws-tbl, .ws-insp')].map(e => [e.scrollLeft, e.scrollTop])],
|
||||
};
|
||||
}
|
||||
function restore(k) {
|
||||
@@ -1459,7 +1498,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 }); if (k.caret && keyed.tagName === 'INPUT' && typeof keyed.setSelectionRange === 'function') try { keyed.setSelectionRange(k.caret[0], k.caret[1]); } catch {} }
|
||||
else if (setting) { setting.focus({preventScroll:true}); if(k.caret && typeof setting.setSelectionRange==='function'){setting.setSelectionRange(k.caret[0],k.caret[1]);setting.scrollTop=k.caret[2];} }
|
||||
else if (choice) choice.focus({preventScroll:true});
|
||||
else if (dec) dec.focus({ preventScroll: true });
|
||||
else if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
|
||||
@@ -1467,26 +1509,47 @@
|
||||
if (k.caret && el.tagName === 'TEXTAREA') { el.setSelectionRange(k.caret[0], k.caret[1]); el.scrollTop = k.caret[2]; }
|
||||
}
|
||||
else if (k.h1) $('bus-view').querySelector('h1')?.focus({ preventScroll: true });
|
||||
// A repaint replaces the tables: put their horizontal scroll and the page's back.
|
||||
if (k.scroll) {
|
||||
const [top, main, inner] = k.scroll;
|
||||
[...$('bus-view').querySelectorAll('.ws-tbl, .ws-insp')].forEach((e, i) => { if (inner[i]) { e.scrollLeft = inner[i][0]; e.scrollTop = inner[i][1]; } });
|
||||
if (document.scrollingElement && document.scrollingElement.scrollTop !== top) document.scrollingElement.scrollTop = top;
|
||||
if ($('main').scrollTop !== main) $('main').scrollTop = main;
|
||||
}
|
||||
}
|
||||
async function render(navigated, opts = {}) {
|
||||
// A refresh due while this render reads would replace it: a navigation would lose its heading focus.
|
||||
clearTimeout(timer);
|
||||
if (out()) return;
|
||||
const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === '' || path === '/board';
|
||||
const route = routeOf(), [path, query = ''] = route.split('?'), board = window.MosaicOrg ? path === '/board' : path === '/' || path === '' || path === '/board';
|
||||
// On the cross-organization pages a filter or selection changes only the query: that is a
|
||||
// repaint of the same page, not a navigation (no loading view, focus and scroll kept).
|
||||
const samePage = wsPage(path) && typeof shown === 'string' && shown.split('?')[0] === path;
|
||||
if (path !== '/settings/model-proxy' && proxyCleanup) {proxyCleanup();proxyCleanup=null;}
|
||||
if ((navigated || path !== '/settings/organizations') && orgCleanup) {orgCleanup();orgCleanup=null;}
|
||||
// Leaving a decision discards its confirmation immediately, including a preview in flight.
|
||||
if (answer && (navigated || path !== `/inbox/${answer.id}`)) answer = null;
|
||||
const g = ++gen, routeVersion = routesEditVersion;
|
||||
const current = () => g === gen && route === routeOf() && !out();
|
||||
// In organization mode #/ is the cross-organization dashboard; the processing controls stay on the board.
|
||||
if (window.MosaicOrg) for (const id of ['processing-link', 'mobile-processing-link']) if ($(id) && $(id).getAttribute('href') !== '#/board') $(id).setAttribute('href', '#/board');
|
||||
document.body.classList.toggle('on-bus', !board);
|
||||
$('bus-view').hidden = board;
|
||||
sections(path);
|
||||
document.querySelectorAll('.rail a').forEach(a => { const target = a.getAttribute('href').slice(1); const active = target === '/projects' ? /^\/(projects|chat)(\/|$)/.test(path) : target === '/' ? path === '/' : path === target; if (active) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); });
|
||||
const chatPath = path === '/projects' ? '/chat' : /^\/projects\/(threads|rooms)\//.test(path) ? '/chat' + path.slice('/projects'.length) : /^\/projects\/[^/]+$/.test(path) ? '/chat/projects/' + path.slice('/projects/'.length) : path;
|
||||
const chatPath = path === '/projects' && !window.MosaicOrg ? '/chat' : /^\/projects\/(threads|rooms)\//.test(path) ? '/chat' + path.slice('/projects'.length) : /^\/projects\/[^/]+$/.test(path) ? '/chat/projects/' + path.slice('/projects/'.length) : path;
|
||||
window.MosaicChat?.leave(chatPath);
|
||||
if (path === '/business') { location.replace('#/settings/business'); return; }
|
||||
if (path === '/settings/display') { location.replace('#/settings/appearance'); return; }
|
||||
// U5: Side by side draws its own two panes (panes.js); every other page stops their polling.
|
||||
if (path !== '/side-by-side') window.MosaicPanes?.unmount();
|
||||
else if (window.MosaicOrg && window.MosaicPanes) {
|
||||
const fresh = navigated || shown !== path;
|
||||
shown = path;
|
||||
if (fresh) { unmountEditor(); await window.MosaicPanes.mount($('bus-view'), query, { navigated }); }
|
||||
else window.MosaicPanes.refresh();
|
||||
return;
|
||||
}
|
||||
if (chatPath === '/chat' || chatPath.startsWith('/chat/') || /^\/projects\/[0-9a-f-]+\/(?:settings|conversations|tasks|requirements|files)$/i.test(chatPath)) {
|
||||
// Re-read the scoped capability as the Console and bus recover. A failed read
|
||||
// withdraws the control; the chat renderer retains the draft across repaint.
|
||||
@@ -1553,11 +1616,11 @@
|
||||
if (path === '/settings/model-proxy' && shown === route && !navigated) return;
|
||||
if (path === '/settings/organizations' && shown?.split('?')[0] === path && !navigated) return;
|
||||
if (($('agent-dialog')?.open || document.querySelector('.agent-more[aria-expanded=true]')) && !navigated) { schedule(); return; }
|
||||
if (navigated || shown !== route) { unmountEditor(); $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
|
||||
if (navigated || (shown !== route && !samePage)) { unmountEditor(); $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
|
||||
let html, state = 'fresh';
|
||||
const live = reader(true);
|
||||
try {
|
||||
if (path !== '/messages' && !['/settings','/settings/appearance','/settings/model-proxy','/settings/organizations'].includes(path)) {
|
||||
if (path !== '/organization' && !wsPage(path) && !(path === '/agents' && window.MosaicOrg) && path !== '/messages' && !['/settings','/settings/appearance','/settings/model-proxy','/settings/organizations'].includes(path)) {
|
||||
try { await live('messages'); }
|
||||
catch (err) { messageCount = null; if (err.status === 403 || err.code === 'signed-out') throw err; }
|
||||
}
|
||||
@@ -1573,11 +1636,12 @@
|
||||
freshBus(state, SOURCES[path.split('/')[1]]);
|
||||
// A route edit after the markup was prepared must win over that pending redraw.
|
||||
if(path==='/settings/routing' && routeVersion!==routesEditVersion){render(false);return;}
|
||||
const k = !navigated && shown === route ? keep() : null;
|
||||
const k = !navigated && (shown === route || samePage) ? keep() : null;
|
||||
unmountEditor();
|
||||
$('bus-view').innerHTML = html; shown = route;
|
||||
syncAppearance(true);
|
||||
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 +1703,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)}` }));
|
||||
@@ -1755,10 +1819,14 @@
|
||||
}
|
||||
if (!canSend && !isTester && !ssoOn) return;
|
||||
const path = routeOf().split('?')[0];
|
||||
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
|
||||
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')||wsPage(path)) render(false); else if (window.MosaicOrg ? path === '/board' : path === '/' || path === '') paintHeld(heldErr);
|
||||
};
|
||||
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {});
|
||||
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') applyConfig(e.detail); });
|
||||
// The cross-organization pages ask for a repaint after a filter, sort or selection change,
|
||||
// and report the inbox count across organizations for the sidebar.
|
||||
document.addEventListener('mosaic-workspace-render', () => { if (wsPage(routeOf().split('?')[0])) render(false); });
|
||||
document.addEventListener('mosaic-workspace-inbox', e => { const d = e.detail; if (d && Number.isFinite(d.open)) { inboxCount = { open: d.open, blocking: Number.isFinite(d.blocking) ? d.blocking : 0 }; sections(routeOf().split('?')[0]); } });
|
||||
// This context comes from the conversations renderer after its authorized read.
|
||||
let projectContext = null;
|
||||
document.addEventListener('mosaic-project-context', event => {
|
||||
|
||||
@@ -99,7 +99,9 @@
|
||||
}
|
||||
return data;
|
||||
}
|
||||
const legacyRoute = () => (window.MosaicOrg?.route() ?? location.hash.slice(1)).startsWith('/chat');
|
||||
// In organization mode #/projects is the cross-organization page, so the organization's own
|
||||
// project list lives at #/chat. That list keeps the project surface and its #/projects links.
|
||||
const legacyRoute = () => { const r = window.MosaicOrg?.route() ?? location.hash.slice(1); return r.startsWith('/chat') && !(window.MosaicOrg && r.split('?')[0] === '/chat'); };
|
||||
const projectPath = id => legacyRoute() ? `#/chat/projects/${id}` : `#/projects/${id}`;
|
||||
const threadPath = id => legacyRoute() ? `#/chat/threads/${id}` : `#/projects/threads/${id}`;
|
||||
const roomPath = id => legacyRoute() ? `#/chat/rooms/${id}` : `#/projects/rooms/${id}`;
|
||||
@@ -618,7 +620,7 @@
|
||||
html += p && !c.refused && redesigned ? window.MosaicProjects.header(p, c.section, c.threads) : heading(p?.name ?? 'Project unavailable');
|
||||
if (p && !c.refused) {
|
||||
if (p.archived && redesigned && !c.canSend) html += '<p class="project-panel" role="status">This project is archived. Its saved conversations and records stay readable.</p>';
|
||||
if (c.settings) html += `<p><a href="#/projects/${esc(c.id)}">Project overview</a></p><h2>Project information</h2><p>Repository: ${txt(p.repo || 'Not linked')}</p><div data-chat-remote>Repository setting unavailable.</div>`;
|
||||
if (c.settings) html += `<p><a href="#/projects/${esc(c.id)}">Project overview</a></p><h2>Project information</h2><p>Repository: ${txt(p.repo || 'Not linked')}</p><div data-chat-remote>Repository setting unavailable.</div><div data-chat-system-types>System types unavailable.</div>`;
|
||||
else if (c.section === 'conversations' && redesigned) html += `<div class="project-section-title"><h2>Conversations</h2></div><div data-project-conversations>${c.threads.filter(t=>!t.shared && t.project===p.id).map(t=>`<a class="project-conversation-row" href="${threadPath(esc(t.id))}" data-conversation-id="${esc(t.id)}"><span class="project-conversation-icon" aria-hidden="true">◌</span><span><strong>${txt(t.name)}</strong><small>With ${txt(t.instance)}</small></span><span data-conversation-status></span><span aria-hidden="true">›</span></a>`).join('') || '<p>No conversations yet. Start one to begin work.</p>'}</div>${p.archived ? '' : `<details class="project-new-conversation"${location.hash.includes('new=1')?' open':''}><summary class="btn primary">New conversation</summary>${creation('thread', c.id)}</details>`}<h2>Shared with me</h2>${c.threads.some(t=>t.shared && t.project===p.id)?threads(c.threads.filter(t=>t.shared && t.project===p.id)):'<p>Nothing is shared with you yet.</p>'}`;
|
||||
else if (c.section === 'conversations') html += '<h2>Conversations</h2>' + threads(c.threads.filter(t => !t.shared)) + (p.archived ? '' : creation('thread', c.id));
|
||||
else if (redesigned) html += '<div data-project-content>Reading…</div>';
|
||||
@@ -682,6 +684,9 @@
|
||||
const remote = c.container.querySelector('[data-chat-remote]');
|
||||
const project = c.projects.find(p => p.id === c.id);
|
||||
if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); }
|
||||
// Phase P2 (#1873): the project's System types selection, the owner's, mounted like the remote.
|
||||
const types = c.container.querySelector('[data-chat-system-types]');
|
||||
if (types && roomOwner() && typeof window.MosaicSystemTypes?.mount === 'function') window.MosaicSystemTypes.mount({ container: types, projectId: c.id, archived: !!c.projectArchived });
|
||||
if (!window.MosaicOrg && (!redesigned || c.kind !== 'project' || c.settings || c.projectArchived)) window.MosaicProjectArchive?.mount(c.container, c);
|
||||
if (redesigned && c.kind === 'project' && c.projectArchived && !c.settings) {
|
||||
const controls = c.container.querySelector('.project-archive-control');
|
||||
|
||||
@@ -33,8 +33,9 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
|
||||
return;
|
||||
}
|
||||
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
|
||||
// /api/workspace reads and writes across organizations (U1, U4b), so it keeps its own path.
|
||||
window.fetch = (input, init) => {
|
||||
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$))/.test(input))
|
||||
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/levels(?:\?|$))/.test(input))
|
||||
input = `/api/orgs/${id}/${input.slice('/api/'.length)}`;
|
||||
return nativeFetch(input, init);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-design="console" data-palette="harbor">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Dashboard · Mosaic Console</title>
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/inbox.css"><link rel="stylesheet" href="/requests.css"><link rel="stylesheet" href="/files.css"><link rel="stylesheet" href="/project-remote.css"><link rel="stylesheet" href="/shell.css">
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/inbox.css"><link rel="stylesheet" href="/requests.css"><link rel="stylesheet" href="/files.css"><link rel="stylesheet" href="/project-remote.css"><link rel="stylesheet" href="/shell.css"><link rel="stylesheet" href="/tasks-board.css">
|
||||
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
|
||||
<link rel="stylesheet" href="/project-archive.css"><link rel="stylesheet" href="/projects.css">
|
||||
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
|
||||
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script>
|
||||
<script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/panes.css">
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/system-types.css"><script src="/system-types.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/panes.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<body>
|
||||
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
@@ -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>
|
||||
|
||||
@@ -628,7 +628,7 @@
|
||||
[$('org-quick-run'), 'click', quickRun],
|
||||
// F2: the interview opens a conversation with the assistant; Open organization skips it.
|
||||
[$('org-quick-interview'), 'click', async () => { const b = $('org-quick-interview'); b.disabled = true; if (!await window.MosaicOnboarding?.start({ ...quickDone, status })) b.disabled = false; }],
|
||||
[$('org-quick-open-org'), 'click', () => { history.replaceState(null, '', `#/orgs/${encodeURIComponent(st.id)}/projects`); location.reload(); }],
|
||||
[$('org-quick-open-org'), 'click', () => { history.replaceState(null, '', `#/orgs/${encodeURIComponent(st.id)}/chat`); location.reload(); }],
|
||||
[$('org-open-form'), 'submit', e => { e.preventDefault(); const id = $('org-open-id').value; if (id) openExisting(id); }],
|
||||
[$('org-form'), 'submit', save],
|
||||
[$('org-form'), 'input', onFormInput],
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/* Side by side (U5, sample 07 "EXT 8"): a bar, then two panes with a divider between them. Each pane
|
||||
has its own header with the organization's mark and edge, and its own scroll. One pane and a
|
||||
shown-pane switch on a phone. Loaded after shell.css; every rule is scoped to the side-by-side page. */
|
||||
html[data-organizations] .content:has(> #bus-view > .sbs){padding:0}
|
||||
html[data-organizations] .sbs{margin:0;display:flex;flex-direction:column;min-height:0;height:calc(100vh - var(--bar-h,53px) - var(--foot-h,33px))}
|
||||
html[data-organizations] .sbs .panebar{display:flex;align-items:center;gap:10px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--canvas);flex-wrap:wrap}
|
||||
html[data-organizations] .sbs .panebar h1{font-size:16px;font-weight:700;margin:0;line-height:1.4}
|
||||
html[data-organizations] .sbs .panebar h1:focus{outline:none}
|
||||
html[data-organizations] .sbs .panebar .note{font-size:13px;color:var(--muted)}
|
||||
html[data-organizations] .sbs .panebar [data-pane-add]{margin-left:auto}
|
||||
html[data-organizations] .sbs .ws-seg.pane-tabs{display:none;margin-left:0}
|
||||
html[data-organizations] .sbs .pane-tabs>button{gap:5px;max-width:44vw}
|
||||
html[data-organizations] .sbs .pane-tabs>button>span:last-child{overflow:hidden;text-overflow:ellipsis}
|
||||
|
||||
html[data-organizations] .panes{--sbs-a:1fr;--sbs-b:1fr;display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-height:0}
|
||||
html[data-organizations] .panes.two{grid-template-columns:minmax(0,var(--sbs-a)) 1px minmax(0,var(--sbs-b))}
|
||||
html[data-organizations] .pane{display:flex;flex-direction:column;min-width:0;min-height:0}
|
||||
html[data-organizations] .pane[hidden]{display:none}
|
||||
html[data-organizations] .pane-h{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--surface);box-shadow:inset 0 3px 0 var(--org,var(--line));min-width:0}
|
||||
html[data-organizations] .pane-h .ttl{font-weight:700;color:var(--strong,var(--text));overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto}
|
||||
html[data-organizations] .pane-h .ttl small{font-weight:500;color:var(--muted);margin-left:6px;font-size:inherit}
|
||||
html[data-organizations] .pane-h [data-pane-state]:empty{display:none}
|
||||
html[data-organizations] .pane-pick{flex:0 1 180px;min-width:0;max-width:220px;margin-left:auto;font:500 12px/1.4 var(--font);color:var(--text);background:var(--canvas);border:1px solid var(--line);border-radius:var(--r);padding:3px 6px;min-height:0}
|
||||
html[data-organizations] .pane-h .tools{display:flex;gap:2px;flex:none}
|
||||
html[data-organizations] .pane-tool{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:var(--r);color:var(--muted);border:0;background:none;padding:0;min-height:0;cursor:pointer}
|
||||
html[data-organizations] .pane-tool:hover{background:var(--raised);color:var(--text)}
|
||||
html[data-organizations] .pane-tool[hidden]{display:none}
|
||||
html[data-organizations] .pane-tool svg.i{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||
html[data-organizations] .pane-line{margin:0;padding:6px 14px;border-bottom:1px solid var(--line);font-size:13px;color:var(--text);background:color-mix(in srgb,var(--warn,#b7791f) 12%,var(--surface))}
|
||||
html[data-organizations] .pane-line[hidden]{display:none}
|
||||
html[data-organizations] .pane-body{overflow:auto;padding:14px;min-height:0;flex:1}
|
||||
html[data-organizations] .pane-body:focus{outline:none}
|
||||
html[data-organizations] .pane-body:focus-visible{outline:2px solid var(--focus,var(--action));outline-offset:-2px}
|
||||
/* A page inside a pane is a section of the side-by-side page: smaller headings, no page chrome. */
|
||||
html[data-organizations] .pane-body .ws h1{font-size:20px}
|
||||
html[data-organizations] .pane-body .chat-area.pane-project{display:block;height:auto;min-height:0;overflow:visible;padding:0}
|
||||
/* A project in a pane is compact (sample 07): the pane header already names the project and its
|
||||
organization, so the page heading stays only for assistive technology and focus, the full-page
|
||||
actions give way (North star and PRD are on the Requirements tab, Settings in the full view), and
|
||||
the tabs, measures and current work come first. Project pages size some parts from the window
|
||||
(projects.css); in a pane they size from the pane. */
|
||||
html[data-organizations] .pane-body{container-type:inline-size}
|
||||
html[data-organizations] .pane-body .project-header{display:block;width:auto;max-width:100%;margin:0 0 6px;padding:0}
|
||||
html[data-organizations] .pane-body .project-header .project-heading,html[data-organizations] .pane-body .project-header .project-heading>div{display:block}
|
||||
html[data-organizations] .pane-body .project-header-icon,html[data-organizations] .pane-body .project-actions{display:none}
|
||||
html[data-organizations] .pane-body .project-header h1{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
||||
html[data-organizations] .pane-body:has(.project-header h1:focus-visible){outline:2px solid var(--focus,var(--action));outline-offset:-2px}
|
||||
html[data-organizations] .pane-body .project-header .project-heading p{margin:0;font-size:13px;color:var(--muted)}
|
||||
html[data-organizations] .pane-body .project-tabs a{min-height:40px;padding:0 12px;font-size:14px}
|
||||
html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:10px;padding:6px 0}
|
||||
html[data-organizations] .pane-body .project-metrics a{padding:4px 12px;border-right:0;border-bottom:0;line-height:1.3}
|
||||
html[data-organizations] .pane-body .project-metrics a span,html[data-organizations] .pane-body .project-metrics small{font-size:12px;overflow-wrap:anywhere}
|
||||
html[data-organizations] .pane-body .project-metrics strong{font-size:20px}
|
||||
html[data-organizations] .pane-body .project-metrics p{margin:6px 12px 0;padding-top:6px}
|
||||
html[data-organizations] .pane-body .project-columns{grid-template-columns:minmax(0,1fr);gap:14px;margin-top:12px}
|
||||
html[data-organizations] .pane-body .project-columns h2{font-size:16px;margin:12px 0 8px}
|
||||
html[data-organizations] .pane-body .project-columns h3{font-size:14px}
|
||||
html[data-organizations] .pane-body .project-columns .project-section-title h2,html[data-organizations] .pane-body .project-columns aside>h2:first-child{margin-top:0}
|
||||
@container (min-width:560px){
|
||||
html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
html[data-organizations] .pane-body .project-metrics a:not(:nth-child(4)){border-right:1px solid var(--line)}
|
||||
}
|
||||
/* The side-by-side page has its own bar; the page breadcrumb would name only one pane. */
|
||||
body:has(#bus-view > .sbs) #breadcrumbs{display:none}
|
||||
html[data-organizations] .content:has(> #bus-view > .sbs) > .org-cue{margin:8px 14px}
|
||||
|
||||
html[data-organizations] .pane-div{position:relative;background:var(--line);cursor:col-resize;touch-action:none;outline:none}
|
||||
html[data-organizations] .pane-div::before{content:"";position:absolute;inset:0 -4px;z-index:1}
|
||||
html[data-organizations] .pane-div:hover,html[data-organizations] .pane-div.dragging{background:var(--action)}
|
||||
html[data-organizations] .pane-div:focus-visible{background:var(--action);box-shadow:0 0 0 2px var(--action)}
|
||||
html[data-organizations] .pane-div[hidden]{display:none}
|
||||
|
||||
@media (max-width:760px){
|
||||
html[data-organizations] .content:has(> #bus-view > .sbs){padding:0}
|
||||
html[data-organizations] .sbs{height:auto}
|
||||
html[data-organizations] .sbs .ws-seg.pane-tabs{display:inline-flex}
|
||||
html[data-organizations] .sbs .hide-m{display:none}
|
||||
html[data-organizations] .panes,html[data-organizations] .panes.two{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .pane.alt,html[data-organizations] .pane-div{display:none}
|
||||
html[data-organizations] .pane-body{overflow:visible}
|
||||
html[data-organizations] .pane-h{flex-wrap:wrap}
|
||||
html[data-organizations] .pane-pick{flex:1 1 100%;max-width:none;order:5;margin-left:0}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
// Side by side (U5, sample 07): two panes, each showing one page of any organization. A pane holds
|
||||
// the organization dashboard, the Agents levels or a project page (overview, conversations, tasks,
|
||||
// requirements, files), drawn by the same modules as the full view (workspace.js, projects.js) into
|
||||
// the pane instead of #bus-view. Every read names the pane's organization, so the panes never share
|
||||
// state. Pages that keep one global form, dialog or editor (a conversation, the room, settings, the
|
||||
// queue, docs, the inbox) are not offered in a pane; their links open the full view.
|
||||
//
|
||||
// The panes' routes, the divider and the phone's shown pane live in the URL
|
||||
// (#/orgs/<page>/side-by-side?a=<org>/<route>&b=<org>/<route>&w=<percent>&show=a|b), updated with
|
||||
// replaceState so nothing reloads, and the last layout is remembered in this browser.
|
||||
(() => {
|
||||
'use strict';
|
||||
if (!document.documentElement.hasAttribute('data-organizations')) return;
|
||||
const data = window.MosaicWorkspaceData, ws = () => window.MosaicWorkspace;
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const enc = encodeURIComponent;
|
||||
const STORE = 'mosaic-side-by-side-v1', POLL = 10000, LIST_TTL = 3000, PROJECTS_TTL = 30000;
|
||||
const MIN = 25, MAX = 75, STEP = 5;
|
||||
const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const PROJECT = /^\/projects\/([A-Za-z0-9][A-Za-z0-9._-]{0,159})(?:\/(conversations|tasks|requirements|files))?$/;
|
||||
const SECTIONS = { overview: 'Overview', conversations: 'Conversations', tasks: 'Tasks', requirements: 'Requirements', files: 'Files' };
|
||||
const FOCUSABLE = 'a[href],button:not([disabled]),select,textarea,input,summary,[tabindex]:not([tabindex="-1"])';
|
||||
const NAMES = { a: 'left', b: 'right' };
|
||||
const load = () => { try { return localStorage.getItem(STORE); } catch { return null; } };
|
||||
const save = value => { try { localStorage.setItem(STORE, value); } catch {} };
|
||||
const paused = () => document.getElementById('pause')?.getAttribute('aria-pressed') === 'true';
|
||||
const svg = d => `<svg class="i sm" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>`;
|
||||
const FULL = svg('<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/>');
|
||||
const CLOSE = svg('<path d="M6 6l12 12M18 6 6 18"/>');
|
||||
const PLUS = svg('<path d="M12 5v14M5 12h14"/>');
|
||||
|
||||
// A pane's page: the routes a pane can draw. Anything else is not pane-safe.
|
||||
function page(route) {
|
||||
if (route === '/organization') return { kind: 'dashboard' };
|
||||
if (route === '/agents') return { kind: 'agents' };
|
||||
const m = PROJECT.exec(route);
|
||||
if (m && m[1] !== 'threads' && m[1] !== 'rooms') return { kind: 'project', id: m[1], section: m[2] || 'overview' };
|
||||
return null;
|
||||
}
|
||||
// "<org>/<route>" from the URL; null when either part is not one a pane draws.
|
||||
function parsePane(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const cut = value.indexOf('/');
|
||||
if (cut < 1) return null;
|
||||
const org = value.slice(0, cut), route = value.slice(cut);
|
||||
return ORG.test(org) && page(route) ? { org, route } : null;
|
||||
}
|
||||
const paneValue = p => `${p.org}${p.route}`;
|
||||
const clampW = n => Math.min(MAX, Math.max(MIN, Math.round(n)));
|
||||
|
||||
// ---------- shared reads (one organization list and project lists for both panes) ----------
|
||||
let listAt = 0, listPromise = null, projectReads = new Map();
|
||||
function orgList(fresh = false) {
|
||||
if (!listPromise || fresh || Date.now() - listAt > LIST_TTL) {
|
||||
listAt = Date.now();
|
||||
listPromise = Promise.all([data.organizations(), ws()?.themes?.()]).then(([rows]) => (Array.isArray(rows) ? rows : null)).catch(() => null);
|
||||
}
|
||||
return listPromise;
|
||||
}
|
||||
function projectsOf(org, fresh = false) {
|
||||
const hit = projectReads.get(org);
|
||||
if (hit && !fresh && Date.now() - hit.at < PROJECTS_TTL) return hit.promise;
|
||||
const promise = data.projects(org).then(b => (Array.isArray(b?.rows) ? b.rows.filter(p => p && typeof p.id === 'string') : null)).catch(() => null);
|
||||
projectReads.set(org, { at: Date.now(), promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---------- state ----------
|
||||
let mounted = null; // { root, panes: [pane], w, show, gen }
|
||||
let mountGen = 0;
|
||||
let listCache = null; // the last organization list read, for the shown-pane tabs
|
||||
|
||||
function readQuery(query) {
|
||||
const q = new URLSearchParams(query || '');
|
||||
return { a: parsePane(q.get('a')), b: parsePane(q.get('b')), w: Number(q.get('w')), show: q.get('show'), named: q.has('a') || q.has('b') };
|
||||
}
|
||||
function queryOf(state) {
|
||||
const q = new URLSearchParams();
|
||||
state.panes.forEach((p, i) => q.set(i ? 'b' : 'a', paneValue(p)));
|
||||
q.set('w', String(state.w));
|
||||
q.set('show', state.show);
|
||||
return q.toString().replace(/%2F/g, '/');
|
||||
}
|
||||
function writeUrl() {
|
||||
if (!mounted) return;
|
||||
const qs = queryOf(mounted);
|
||||
save(qs);
|
||||
const next = `#/orgs/${enc(window.MosaicOrg.id)}/side-by-side?${qs}`;
|
||||
if (location.hash !== next) history.replaceState(null, '', next);
|
||||
}
|
||||
// The first pane is this page's organization; the second is the next organization on the list,
|
||||
// or this one's Agents when there is no other.
|
||||
async function defaults() {
|
||||
const here = window.MosaicOrg.id, list = await orgList();
|
||||
const other = (list || []).find(o => o && !o.invalid && o.id !== here && ORG.test(o.id));
|
||||
return [{ org: here, route: '/organization' }, other ? { org: other.id, route: '/organization' } : { org: here, route: '/agents' }];
|
||||
}
|
||||
|
||||
// ---------- markup ----------
|
||||
function paneShell(key) {
|
||||
const name = NAMES[key];
|
||||
return `<section class="pane" id="pane-${key}" data-pane="${key}" aria-label="${name[0].toUpperCase()}${name.slice(1)} pane">
|
||||
<div class="pane-h ws-o" data-pane-head>
|
||||
<span data-pane-av></span><span class="ttl" data-pane-ttl></span><span data-pane-state></span>
|
||||
<label class="ws-sr" for="pane-${key}-pick">Page in the ${name} pane</label>
|
||||
<select id="pane-${key}-pick" class="pane-pick" data-pane-pick></select>
|
||||
<span class="tools"><a class="pane-tool" data-pane-full href="#" title="Open in full view"><span class="ws-sr" data-pane-full-label>Open in full view</span>${FULL}</a><button type="button" class="pane-tool" data-pane-close title="Close pane" aria-label="Close the ${name} pane">${CLOSE}</button></span>
|
||||
</div>
|
||||
<p class="pane-line" data-pane-line role="status"></p>
|
||||
<div class="pane-body" data-pane-body tabindex="0" aria-label="${name[0].toUpperCase()}${name.slice(1)} pane content"></div>
|
||||
</section>`;
|
||||
}
|
||||
function frame() {
|
||||
return `<section class="sbs" data-side-by-side aria-labelledby="sbs-title">
|
||||
<div class="panebar"><h1 id="sbs-title" tabindex="-1">Side by side</h1><span class="note" data-sbs-note></span>
|
||||
<span class="ws-seg pane-tabs" role="group" aria-label="Shown pane" data-sbs-tabs></span>
|
||||
<button type="button" class="ws-btn hide-m" data-pane-add>${PLUS}Add pane</button></div>
|
||||
<div class="panes" data-sbs-panes>${paneShell('a')}<div class="pane-div" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Resize panes" aria-controls="pane-a pane-b" aria-valuemin="${MIN}" aria-valuemax="${MAX}" data-sbs-divider></div>${paneShell('b')}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// Orgs in the picker: every organization on the list. A pane draws a valid one's dashboard, Agents or
|
||||
// any project the organization's project read lists; an invalid one is listed and cannot be chosen.
|
||||
async function pickerHtml(p, list) {
|
||||
const groups = await Promise.all((list || []).filter(o => o && ORG.test(o.id)).map(async org => {
|
||||
const name = ws().label(org);
|
||||
if (org.invalid) return `<optgroup label="${esc(name)} · invalid"><option disabled value="">Organization invalid</option></optgroup>`;
|
||||
const rows = await projectsOf(org.id);
|
||||
const opt = (route, text) => `<option value="${esc(`${org.id}${route}`)}">${esc(text)}</option>`;
|
||||
const projects = rows === null ? '<option disabled value="">Projects unavailable</option>' : rows.filter(r => !r.archived && PROJECT.test(`/projects/${r.id}`)).map(r => opt(`/projects/${r.id}`, `Project: ${r.name || r.id}`)).join('');
|
||||
return `<optgroup label="${esc(name)}${org.busHealthy ? '' : ' · bus unavailable'}">${opt('/organization', 'Dashboard')}${opt('/agents', 'Agents')}${projects}</optgroup>`;
|
||||
}));
|
||||
let html = groups.join('');
|
||||
const value = paneValue(p);
|
||||
// A project section, or a page the list no longer names, is still the pane's page: keep it selectable.
|
||||
if (!html.includes(`value="${esc(value)}"`)) html = `<option value="${esc(value)}">${esc(p.title || value)}</option>${html}`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---------- drawing one pane ----------
|
||||
const els = p => ({
|
||||
head: p.el.querySelector('[data-pane-head]'), av: p.el.querySelector('[data-pane-av]'), ttl: p.el.querySelector('[data-pane-ttl]'),
|
||||
state: p.el.querySelector('[data-pane-state]'), pick: p.el.querySelector('[data-pane-pick]'), full: p.el.querySelector('[data-pane-full]'),
|
||||
fullLabel: p.el.querySelector('[data-pane-full-label]'), close: p.el.querySelector('[data-pane-close]'), line: p.el.querySelector('[data-pane-line]'), body: p.el.querySelector('[data-pane-body]'),
|
||||
});
|
||||
const setHtml = (el, html) => { if (el && el.innerHTML !== html) el.innerHTML = html; };
|
||||
const setText = (el, text) => { if (el && el.textContent !== text) el.textContent = text; };
|
||||
|
||||
// Links a page draws are relative to the page's organization. In a pane they name the pane's
|
||||
// organization; reads the page links to (/api/...) name it too.
|
||||
function rewrite(p, root) {
|
||||
const prefix = `#/orgs/${enc(p.org)}`;
|
||||
for (const a of root.querySelectorAll('a[href^="#/"]')) if (!a.getAttribute('href').startsWith('#/orgs/')) a.setAttribute('href', prefix + a.getAttribute('href').slice(1));
|
||||
for (const a of root.querySelectorAll('a[href^="/api/"]')) if (!a.getAttribute('href').startsWith('/api/orgs/')) a.setAttribute('href', `/api/orgs/${enc(p.org)}/${a.getAttribute('href').slice(5)}`);
|
||||
// The pane's title is the page's heading; the side-by-side page keeps the one level-1 heading.
|
||||
for (const h of root.querySelectorAll('h1:not([aria-level])')) h.setAttribute('aria-level', '2');
|
||||
}
|
||||
// Focus and scroll survive a repaint of this pane only. Focus in the other pane is never taken.
|
||||
function capture(p, root) {
|
||||
const a = document.activeElement, inside = !!a && a !== root && root.contains(a);
|
||||
return {
|
||||
top: root.scrollTop,
|
||||
focus: inside ? { key: a.dataset?.focusKey || null, href: a.getAttribute('href'), index: [...root.querySelectorAll(FOCUSABLE)].indexOf(a), h1: a.tagName === 'H1' } : null,
|
||||
};
|
||||
}
|
||||
function restore(p, root, kept) {
|
||||
root.scrollTop = kept.top;
|
||||
const f = kept.focus, a = document.activeElement;
|
||||
if (!f || (a && a !== document.body && !root.contains(a))) return;
|
||||
const target = (f.key && root.querySelector(`[data-focus-key="${CSS.escape(f.key)}"]`))
|
||||
|| (f.href && [...root.querySelectorAll('a[href]')].find(x => x.getAttribute('href') === f.href))
|
||||
|| (f.h1 && root.querySelector('h1'))
|
||||
|| root.querySelectorAll(FOCUSABLE)[f.index];
|
||||
(target || root).focus({ preventScroll: true });
|
||||
}
|
||||
function paintBody(p, html) {
|
||||
const { body } = els(p);
|
||||
if (p.html === html && body.childElementCount) return;
|
||||
const kept = capture(p, body);
|
||||
body.innerHTML = html; p.html = html; p.project = null;
|
||||
rewrite(p, body);
|
||||
restore(p, body, kept);
|
||||
}
|
||||
|
||||
// A focus move that finishes after an asynchronous read happens only if the viewer has not
|
||||
// moved on: focus is still where it was when the action started, nowhere, or inside this pane.
|
||||
function mayFocus(p, before) {
|
||||
const a = document.activeElement;
|
||||
return !a || a === document.body || a === before || p.el.contains(a);
|
||||
}
|
||||
async function drawPane(p, { fresh = false, focusHead = false } = {}) {
|
||||
if (!mounted || !p.el.isConnected) return;
|
||||
const before = document.activeElement;
|
||||
const turn = ++p.turn, gen = mountGen, live = () => mounted && gen === mountGen && turn === p.turn && p.el.isConnected;
|
||||
clearTimeout(p.timer);
|
||||
const e = els(p), what = page(p.route);
|
||||
const list = await orgList(fresh);
|
||||
if (!live()) return;
|
||||
if (list) listCache = list;
|
||||
const org = list?.find(o => o?.id === p.org) || null;
|
||||
const name = org ? ws().label(org) : p.org;
|
||||
// Header: the organization's mark, the page's title, its state, the picker and the tools.
|
||||
if (org) e.head.setAttribute('style', ws().colours(org)); else e.head.removeAttribute('style');
|
||||
setHtml(e.av, org ? ws().av(org, 'md') : '<span class="ws-av md" aria-hidden="true">?</span>');
|
||||
const [scls, sglyph, sword] = org ? ws().state(org) : ['s-unknown', '?', 'not on your list'];
|
||||
setHtml(e.state, org?.busHealthy && !org.invalid ? '' : ws().dot(scls, sglyph, sword, true));
|
||||
const fullHref = `#/orgs/${enc(p.org)}${p.route}`;
|
||||
if (e.full.getAttribute('href') !== fullHref) e.full.setAttribute('href', fullHref);
|
||||
|
||||
let line = '', html = null;
|
||||
if (!list) line = 'The organization list could not be read. This pane shows what its own reads return.';
|
||||
else if (!org) { line = `${p.org} is not on your organization list.`; html = '<p class="ws-line">Nothing is read for an organization that is not on your list.</p>'; }
|
||||
else if (org.invalid) { line = `${name}: organization invalid${org.reason ? ` (${org.reason})` : ''}.`; html = '<p class="ws-line">Nothing is read from an invalid organization. Fix its file under Settings, Organizations.</p>'; }
|
||||
else if (!org.busHealthy) line = `${name}: bus unavailable. Reads from this organization may fail; the other pane is not affected.`;
|
||||
setText(e.line, line); e.line.hidden = !line;
|
||||
|
||||
let title = what?.kind === 'agents' ? 'Agents' : 'Dashboard';
|
||||
if (html === null && what?.kind === 'dashboard') html = await ws().dashboard(p.org, { pane: true, idPrefix: `pane-${p.key}-` }).catch(() => '<p class="ws-line">Dashboard unavailable.</p>');
|
||||
else if (html === null && what?.kind === 'agents') html = await ws().agentGrid('', p.org).catch(() => '<p class="ws-line">Agents unavailable.</p>');
|
||||
if (!live()) return;
|
||||
if (html === null && what?.kind === 'project') {
|
||||
title = await drawProject(p, e, what, fresh, live);
|
||||
if (title === null || !live()) return;
|
||||
} else if (html !== null) paintBody(p, html);
|
||||
p.title = title;
|
||||
setHtml(e.ttl, `${esc(title)}<small>${esc(name)}</small>`);
|
||||
p.el.setAttribute('aria-label', `${NAMES[p.key][0].toUpperCase()}${NAMES[p.key].slice(1)} pane: ${title}, ${name}`);
|
||||
setText(e.fullLabel, `Open ${title}, ${name}, in full view`);
|
||||
// The picker is rebuilt only while it is closed and unfocused, so a choice in progress stays.
|
||||
if (document.activeElement !== e.pick) {
|
||||
const options = await pickerHtml(p, list);
|
||||
if (!live()) return;
|
||||
if (document.activeElement !== e.pick) { setHtml(e.pick, options); e.pick.value = paneValue(p); }
|
||||
}
|
||||
paintBar(); fit();
|
||||
if (focusHead && mayFocus(p, before)) (e.body.querySelector('h1') || e.body).focus({ preventScroll: true });
|
||||
p.timer = setTimeout(() => tick(p), POLL);
|
||||
}
|
||||
function tick(p) {
|
||||
if (!mounted || !p.el.isConnected) return;
|
||||
if (paused()) { p.timer = setTimeout(() => tick(p), POLL); return; }
|
||||
void drawPane(p);
|
||||
}
|
||||
|
||||
// A project page: projects.js's header and content, mounted into the pane with the pane's
|
||||
// organization, read-only (a pane never sends).
|
||||
// Every write below happens only while this draw is still the pane's current one (live()), so a
|
||||
// slow read never paints an old page or organization over the one the viewer has since chosen.
|
||||
async function drawProject(p, e, what, fresh, live) {
|
||||
const org = p.org;
|
||||
const [projects, threadsRead] = await Promise.all([projectsOf(org, fresh), data.threads(org).catch(() => null)]);
|
||||
if (!live() || p.org !== org) return null;
|
||||
const project = projects?.find(r => r.id === what.id);
|
||||
const sectionName = SECTIONS[what.section];
|
||||
if (!project) {
|
||||
paintBody(p, projects === null ? '<p class="ws-line">Projects unavailable. The organization did not answer the project read.</p>' : '<p class="ws-line">This project is not in the organization\'s project list.</p>');
|
||||
return what.id;
|
||||
}
|
||||
const threads = Array.isArray(threadsRead?.rows) ? threadsRead.rows : [];
|
||||
const head = window.MosaicProjects.header(project, what.section, threads);
|
||||
const conv = what.section === 'conversations' ? conversations(project, threads, threadsRead) : null;
|
||||
const key = JSON.stringify([org, project.id, what.section, head, conv]);
|
||||
const { body } = e;
|
||||
if (p.project?.key !== key) {
|
||||
const kept = capture(p, body);
|
||||
body.innerHTML = `<div class="chat-area pane-project">${head}${conv ?? '<div data-project-content>Reading…</div>'}</div>`;
|
||||
p.html = null; p.project = { key };
|
||||
rewrite(p, body);
|
||||
restore(p, body, kept);
|
||||
}
|
||||
const content = body.querySelector('[data-project-content]');
|
||||
if (content) await window.MosaicProjects.mountContent({ container: content, project, threads, section: what.section, canSend: false, org, pane: true, views: viewsOf(p, org) });
|
||||
return what.section === 'overview' ? project.name || project.id : `${project.name || project.id} · ${sectionName}`;
|
||||
}
|
||||
// A pane's request choice and PRD disclosure, per organization, kept on the pane so a repaint
|
||||
// that replaces the project content keeps them.
|
||||
function viewsOf(p, org) {
|
||||
if (!p.views.has(org)) p.views.set(org, { selected: new Map(), expanded: new Set() });
|
||||
return p.views.get(org);
|
||||
}
|
||||
function conversations(project, threads, read) {
|
||||
if (!read) return '<p class="ws-line">Conversations unavailable.</p>';
|
||||
const own = threads.filter(t => t && !t.shared && t.project === project.id && typeof t.id === 'string');
|
||||
const rows = own.map(t => `<a class="project-conversation-row" href="#/projects/threads/${enc(t.id)}" data-focus-key="${esc(`conv:${t.id}`)}"><span class="project-conversation-icon" aria-hidden="true">◌</span><span><strong>${esc(t.name || t.id)}</strong><small>With ${esc(t.instance || 'no agent recorded')}</small></span><span aria-hidden="true">›</span></a>`).join('');
|
||||
return `<div class="project-section-title"><h2>Conversations</h2></div><div data-project-conversations>${rows || '<p>No conversations yet.</p>'}</div><p class="ws-line">A conversation opens in its full view.</p>`;
|
||||
}
|
||||
|
||||
// ---------- the bar, tabs, divider and layout ----------
|
||||
function paintBar() {
|
||||
if (!mounted) return;
|
||||
const { root, panes, show } = mounted, two = panes.length === 2;
|
||||
setText(root.querySelector('[data-sbs-note]'), `${panes.length} ${panes.length === 1 ? 'pane' : 'panes'} · each pane reads its own organization's bus and keeps its own state`);
|
||||
const tabs = root.querySelector('[data-sbs-tabs]'), focusedTab = tabs.contains(document.activeElement) ? document.activeElement.dataset.paneShow : null;
|
||||
const list = listCache;
|
||||
const html = panes.map(p => {
|
||||
const org = list?.find(o => o?.id === p.org);
|
||||
return `<button type="button" data-pane-show="${p.key}" aria-pressed="${show === p.key}" aria-controls="pane-${p.key}">${org ? ws().av(org, 'xs') : ''}<span>${esc(p.title || 'Reading…')}</span></button>`;
|
||||
}).join('');
|
||||
if (tabs.innerHTML !== html) { tabs.innerHTML = html; if (focusedTab) tabs.querySelector(`[data-pane-show="${focusedTab}"]`)?.focus({ preventScroll: true }); }
|
||||
const add = root.querySelector('[data-pane-add]');
|
||||
add.disabled = two; add.title = two ? 'Side by side holds two panes. Close one to choose another.' : 'Open a second pane';
|
||||
for (const p of panes) { const c = p.el.querySelector('[data-pane-close]'); c.hidden = !two; }
|
||||
}
|
||||
function layout() {
|
||||
if (!mounted) return;
|
||||
const { root, panes, w, show } = mounted, two = panes.length === 2;
|
||||
const box = root.querySelector('[data-sbs-panes]'), divider = root.querySelector('[data-sbs-divider]');
|
||||
box.classList.toggle('two', two);
|
||||
box.style.setProperty('--sbs-a', `${w}fr`); box.style.setProperty('--sbs-b', `${100 - w}fr`);
|
||||
divider.hidden = !two;
|
||||
divider.setAttribute('aria-valuenow', String(w));
|
||||
divider.setAttribute('aria-valuetext', `Left pane ${w} percent, right pane ${100 - w} percent`);
|
||||
root.querySelector('#pane-b').hidden = !two;
|
||||
for (const p of panes) p.el.classList.toggle('alt', show !== p.key);
|
||||
fit();
|
||||
}
|
||||
// Wide screens: the panes fill the window below the bar and each scrolls on its own.
|
||||
const wide = window.matchMedia('(min-width: 761px)');
|
||||
function fit() {
|
||||
if (!mounted) return;
|
||||
const { root } = mounted;
|
||||
if (!wide.matches) { root.style.height = ''; return; }
|
||||
const foot = document.querySelector('body > .foot, .foot')?.getBoundingClientRect().height || 0;
|
||||
const top = root.getBoundingClientRect().top;
|
||||
root.style.height = `${Math.max(360, Math.floor(window.innerHeight - Math.max(top, 0) - foot))}px`;
|
||||
}
|
||||
function setWidth(w, persist = true) {
|
||||
if (!mounted) return;
|
||||
const next = clampW(w);
|
||||
if (next === mounted.w) return;
|
||||
mounted.w = next; layout(); if (persist) writeUrl();
|
||||
}
|
||||
|
||||
// ---------- events (bound once to the mount root) ----------
|
||||
function bind(root) {
|
||||
const divider = root.querySelector('[data-sbs-divider]');
|
||||
divider.addEventListener('keydown', e => {
|
||||
const w = mounted?.w; if (w === undefined) return;
|
||||
const next = { ArrowLeft: w - STEP, ArrowDown: w - STEP, ArrowRight: w + STEP, ArrowUp: w + STEP, PageDown: w - 10, PageUp: w + 10, Home: MIN, End: MAX }[e.key];
|
||||
if (next === undefined) return;
|
||||
e.preventDefault(); setWidth(next);
|
||||
});
|
||||
divider.addEventListener('pointerdown', e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault(); divider.focus({ preventScroll: true }); divider.setPointerCapture(e.pointerId); divider.classList.add('dragging');
|
||||
const box = root.querySelector('[data-sbs-panes]').getBoundingClientRect();
|
||||
const move = ev => setWidth(((ev.clientX - box.left) / box.width) * 100, false);
|
||||
const up = () => { divider.removeEventListener('pointermove', move); divider.removeEventListener('pointerup', up); divider.removeEventListener('pointercancel', up); divider.classList.remove('dragging'); writeUrl(); };
|
||||
divider.addEventListener('pointermove', move); divider.addEventListener('pointerup', up); divider.addEventListener('pointercancel', up);
|
||||
});
|
||||
root.addEventListener('change', e => {
|
||||
const pick = e.target.closest?.('[data-pane-pick]'); if (!pick || !mounted) return;
|
||||
const p = mounted.panes.find(x => x.el.contains(pick)), next = parsePane(pick.value);
|
||||
if (!p || !next) return;
|
||||
go(p, next, false);
|
||||
});
|
||||
root.addEventListener('click', e => {
|
||||
if (!mounted) return;
|
||||
const show = e.target.closest?.('[data-pane-show]');
|
||||
if (show) { mounted.show = show.dataset.paneShow === 'b' && mounted.panes.length === 2 ? 'b' : 'a'; layout(); paintBar(); writeUrl(); return; }
|
||||
if (e.target.closest?.('[data-pane-add]')) { void addPane(); return; }
|
||||
const close = e.target.closest?.('[data-pane-close]');
|
||||
if (close) { closePane(mounted.panes.find(x => x.el.contains(close))); return; }
|
||||
// A plain click on a link to a page a pane draws, in the pane's own organization, opens it in
|
||||
// the pane. Anything else (another organization, a page a pane does not draw, a new tab) is
|
||||
// an ordinary link to the full view.
|
||||
const a = e.target.closest?.('a[href^="#/orgs/"]');
|
||||
if (!a || a.hasAttribute('data-pane-full') || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
const p = mounted.panes.find(x => x.el.contains(a)); if (!p) return;
|
||||
const m = /^#\/orgs\/([^/?#]+)(\/[^?#]*)$/.exec(a.getAttribute('href'));
|
||||
let org, route;
|
||||
try { org = decodeURIComponent(m?.[1] ?? ''); route = decodeURIComponent(m?.[2] ?? ''); } catch { return; }
|
||||
if (org !== p.org || !page(route)) return;
|
||||
e.preventDefault();
|
||||
go(p, { org, route }, true);
|
||||
});
|
||||
}
|
||||
function go(p, next, focusHead) {
|
||||
p.org = next.org; p.route = next.route; p.html = null; p.project = null; p.title = null;
|
||||
els(p).body.scrollTop = 0;
|
||||
writeUrl();
|
||||
void drawPane(p, { focusHead });
|
||||
}
|
||||
async function addPane() {
|
||||
if (!mounted || mounted.panes.length !== 1) return;
|
||||
const before = document.activeElement;
|
||||
const [a] = mounted.panes, list = await orgList();
|
||||
if (!mounted || mounted.panes.length !== 1) return;
|
||||
const other = (list || []).find(o => o && !o.invalid && o.id !== a.org && ORG.test(o.id));
|
||||
const b = makePane('b', other ? { org: other.id, route: '/organization' } : { org: a.org, route: a.route === '/agents' ? '/organization' : '/agents' });
|
||||
mounted.panes.push(b); mounted.show = 'b';
|
||||
layout(); paintBar(); writeUrl();
|
||||
await drawPane(b);
|
||||
if (mounted?.panes.includes(b) && mayFocus(b, before)) els(b).pick.focus({ preventScroll: true });
|
||||
}
|
||||
function closePane(p) {
|
||||
if (!mounted || !p || mounted.panes.length !== 2) return;
|
||||
clearTimeout(p.timer); p.turn++;
|
||||
const keep = mounted.panes.find(x => x !== p);
|
||||
// The remaining pane becomes the first: its page moves into pane a's element.
|
||||
const a = mounted.panes[0];
|
||||
if (keep !== a) {
|
||||
const b = mounted.panes[1];
|
||||
clearTimeout(b.timer); b.turn++;
|
||||
a.org = keep.org; a.route = keep.route; a.html = null; a.project = null; a.title = keep.title; a.views = keep.views;
|
||||
}
|
||||
setHtml(els(mounted.panes[1]).body, ''); mounted.panes[1].html = null; mounted.panes[1].project = null;
|
||||
mounted.panes = [a]; mounted.show = 'a';
|
||||
layout(); paintBar(); writeUrl();
|
||||
// The close button just left the page; focus goes to the remaining pane's picker now, and again
|
||||
// after its draw only if the viewer has not moved elsewhere meanwhile.
|
||||
els(a).pick.focus({ preventScroll: true });
|
||||
const before = document.activeElement;
|
||||
void drawPane(a).then(() => { if (mounted?.panes[0] === a && mayFocus(a, before)) els(a).pick.focus({ preventScroll: true }); });
|
||||
}
|
||||
function makePane(key, at) {
|
||||
const el = mounted.root.querySelector(`#pane-${key}`);
|
||||
return { key, el, org: at.org, route: at.route, turn: 0, timer: null, html: null, project: null, title: null, views: new Map() };
|
||||
}
|
||||
|
||||
// ---------- public ----------
|
||||
async function mount(container, query, { navigated = false } = {}) {
|
||||
unmount();
|
||||
const gen = ++mountGen;
|
||||
container.innerHTML = frame();
|
||||
const root = container.querySelector('[data-side-by-side]');
|
||||
mounted = { root, panes: [], w: 50, show: 'a', gen, observers: [] };
|
||||
// projects.js paints a project's content after its own reads; its links are rewritten as they
|
||||
// land. One observer per pane element for the life of the mount, applying whichever pane object
|
||||
// holds that element now, so a closed and re-added pane never runs an old organization's rewrite.
|
||||
const m = mounted;
|
||||
for (const key of ['a', 'b']) {
|
||||
const body = root.querySelector(`#pane-${key} [data-pane-body]`);
|
||||
const observer = new MutationObserver(() => {
|
||||
if (mounted !== m) return;
|
||||
const p = m.panes.find(x => x.key === key);
|
||||
if (p) rewrite(p, body);
|
||||
});
|
||||
observer.observe(body, { childList: true, subtree: true });
|
||||
m.observers.push(observer);
|
||||
}
|
||||
bind(root);
|
||||
window.addEventListener('resize', fit);
|
||||
document.title = 'Side by side · Mosaic Console';
|
||||
if (navigated) root.querySelector('h1').focus({ preventScroll: true });
|
||||
let wanted = readQuery(query);
|
||||
if (!wanted.named) { const kept = readQuery(load() || ''); if (kept.a) wanted = { ...kept, named: true }; }
|
||||
let at = [wanted.a, wanted.b].filter(Boolean);
|
||||
if (!at.length) at = await defaults();
|
||||
if (!mounted || gen !== mountGen) return;
|
||||
listCache = await orgList();
|
||||
if (!mounted || gen !== mountGen) return;
|
||||
mounted.panes = at.slice(0, 2).map((x, i) => makePane(i ? 'b' : 'a', x));
|
||||
mounted.w = Number.isFinite(wanted.w) && wanted.w ? clampW(wanted.w) : 50;
|
||||
mounted.show = wanted.show === 'b' && mounted.panes.length === 2 ? 'b' : 'a';
|
||||
layout(); paintBar(); writeUrl();
|
||||
await Promise.all(mounted.panes.map(p => drawPane(p)));
|
||||
}
|
||||
function unmount() {
|
||||
if (!mounted) return;
|
||||
for (const p of mounted.panes) { clearTimeout(p.timer); p.turn++; }
|
||||
for (const o of mounted.observers) o.disconnect();
|
||||
window.removeEventListener('resize', fit);
|
||||
mounted = null; mountGen++;
|
||||
}
|
||||
// The Console's Refresh: both panes read again now, each keeping its own focus and scroll.
|
||||
function refresh() {
|
||||
if (!mounted) return;
|
||||
projectReads = new Map();
|
||||
void orgList(true).then(rows => { if (rows) listCache = rows; });
|
||||
for (const p of mounted.panes) void drawPane(p, { fresh: true });
|
||||
}
|
||||
// The list the tabs draw from follows each fresh read of the organization list.
|
||||
document.addEventListener('mosaic-org-list', e => { if (Array.isArray(e.detail?.rows)) listCache = e.detail.rows; });
|
||||
|
||||
window.MosaicPanes = Object.freeze({ mount, unmount, refresh, page });
|
||||
})();
|
||||
@@ -31,12 +31,15 @@
|
||||
let treeRows=[], treeThreads=[], treeGeneration=0, treeState, treeRead=null, treeReadAt=0;
|
||||
const treeFocus = new WeakMap();
|
||||
try { treeState=JSON.parse(localStorage.getItem(storageKey) || '{}'); if (!treeState || Array.isArray(treeState) || typeof treeState !== 'object') treeState={}; } catch { treeState={}; }
|
||||
// A side-by-side pane reads its own organization: its reads name that organization explicitly.
|
||||
const at=(org,url)=>org && url.startsWith('/api/') ? `/api/orgs/${encodeURIComponent(org)}/${url.slice(5)}` : url;
|
||||
async function read(url) {
|
||||
const response=await fetch(url,{cache:'no-store',redirect:'manual'});
|
||||
if(response.status===401 || response.type==='opaqueredirect') { document.dispatchEvent(new CustomEvent('mosaic-signed-out',{detail:{signIn:true}})); throw Error('signed-out'); }
|
||||
if(!response.ok) { const body=await response.json().catch(()=>null); const error=Error('unavailable'); error.status=response.status; error.code=body?.error; throw error; }
|
||||
return response.json();
|
||||
}
|
||||
const readAt=url=>read(url);
|
||||
function currentProject(route) {
|
||||
const id=route.match(/^\/(?:projects|chat)\/(?:projects\/|rooms\/)?([0-9a-f-]{36})/i)?.[1];
|
||||
if(id) return id;
|
||||
@@ -104,8 +107,9 @@
|
||||
const same=(a,b)=>JSON.stringify(canonical(a))===JSON.stringify(canonical(b));
|
||||
const material=(d,s)=>d && d.candidate && s?.candidate && same(d.candidate,s.candidate) && d.candidate.request===s.request && d.candidate.project===s.project && d.candidate.mode_revision===s.mode_revision && typeof d.prd==='string' && Array.isArray(d.requirements) && Array.isArray(d.candidate.requirements) && d.requirements.length===d.candidate.requirements.length && d.requirements.every((r,i)=>r && r.id===d.candidate.requirements[i]?.id && r.title===d.candidate.requirements[i]?.title && typeof r.text==='string') && d.snapshot && Number.isSafeInteger(d.snapshot.revision) && d.snapshot.revision>=0 && d.snapshot.id===s.snapshot && (d.snapshot.id===null || requestId(d.snapshot.id) && d.snapshot.revision>0);
|
||||
const ownThreads=(project,threads)=>threads.filter(t=>t.project===project.id && !t.shared && UUID.test(t.id));
|
||||
async function gather(project,threads) {
|
||||
const own=ownThreads(project,threads), key=project.id+':'+String(project.repo)+':'+own.map(t=>t.id).join(',');
|
||||
async function gather(project,threads,org=null) {
|
||||
const read=url=>readAt(at(org,url));
|
||||
const own=ownThreads(project,threads), key=(org?org+'|':'')+project.id+':'+String(project.repo)+':'+own.map(t=>t.id).join(',');
|
||||
const prior=cache.get(key); if(prior && Date.now()-prior.at<10000) return prior.promise;
|
||||
const started=epoch;
|
||||
const promise=(async()=>{
|
||||
@@ -133,7 +137,7 @@
|
||||
const [draft,workspace]=await Promise.all([read(base+'/draft').catch(()=>null),read(base+'/workspace').catch(()=>null)]);
|
||||
return {...item,state,draft:material(draft,state)?draft:null,materialFailed:!!state.candidate && !material(draft,state),workspace};
|
||||
}))).filter(Boolean);
|
||||
const [tasks,catalog,room]=await Promise.all([read('/api/bus/tasks').catch(()=>null),read('/api/chat/instances').catch(()=>null),readRoomStatus(project)]);
|
||||
const [tasks,catalog,room]=await Promise.all([read('/api/bus/tasks').catch(()=>null),read('/api/chat/instances').catch(()=>null),readRoomStatus(project,org)]);
|
||||
const members=Array.isArray(catalog?.rows)?catalog.rows.filter(m=>typeof m?.instance==='string' && own.some(t=>t.instance===m.instance && t.name===t.instance)).length:null;
|
||||
let docs=project.repo ? null : {files:[]}, north=null, recordsMissing=false, northMissing=false, northRecordsMissing=false;
|
||||
if(project.repo && /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/.test(project.repo)) {
|
||||
@@ -145,9 +149,9 @@
|
||||
})();
|
||||
cache.set(key,{at:Date.now(),promise}); return promise;
|
||||
}
|
||||
async function readRoomStatus(project) {
|
||||
async function readRoomStatus(project,org=null) {
|
||||
try {
|
||||
const response=await fetch(`/api/chat/rooms/${encodeURIComponent(project.id)}/messages?limit=100`,{cache:'no-store',redirect:'manual'});
|
||||
const response=await fetch(at(org,'/api/chat/rooms/')+`${encodeURIComponent(project.id)}/messages?limit=100`,{cache:'no-store',redirect:'manual'});
|
||||
if(response.status===401 || response.type==='opaqueredirect') { document.dispatchEvent(new CustomEvent('mosaic-signed-out',{detail:{signIn:true}})); return 'Unavailable'; }
|
||||
const body=await response.json();
|
||||
if(response.ok && Array.isArray(body?.rows) && typeof body.hasMore==='boolean') return 'Open';
|
||||
@@ -194,11 +198,18 @@
|
||||
return `<h2>Reviewed deliveries <span class="project-count">${f.reviewed.length}</span></h2>${f.reviewed.length?f.reviewed.map(({r,file})=>`<div class="project-panel"><span class="project-delivery-icon" aria-hidden="true">▤</span><strong>${clean(file.name)}</strong> <span class="project-chip success">Reviewed</span><p>Request ${clean(r.id)} · from ${clean(r.thread.name)} · Reviewed delivery. Not landed or deployed.</p><div class="project-delivery-actions"><a class="btn" target="_blank" rel="noopener noreferrer" href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}/text">Open as text</a> <a class="btn" download href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}">Download</a>${deliveryPr(r)?` <a class="btn" target="_blank" rel="noopener noreferrer" href="${esc(deliveryPr(r))}">Open pull request</a>`:''}</div><p>Open shows plain text in a new tab. Downloaded HTML can be opened locally after saving.</p></div>`).join(''):`<p>${data.disabled?'Files are unavailable.':data.failed?'Reviewed files are unavailable.':data.requests.length?'No reviewed file is available.':'No request files yet. Files appear when a request in this project produces them.'}</p>`}<h2>Draft workspace <span class="project-count">${f.drafts.length}</span></h2><p>Names only. Draft files may change and are not reviewed deliveries.</p><div class="project-panel project-file-list">${f.drafts.length?f.drafts.map(({r,name})=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><span>Request ${clean(r.id)} · <span class="project-chip">Draft</span></span></div>`).join(''):data.failed || data.requests.some(r=>r.workspace?.available!==true || !Array.isArray(r.workspace.entries))?'<p>Could not read draft workspace. Try again later.</p>':'<p>No draft files found.</p>'}${data.requests.some(r=>r.workspace?.truncated)?'<p>The draft list was truncated.</p>':''}</div><h2>Project documents ${docs?`<span class="project-count">${docs.length}</span>`:''}</h2><div class="project-panel project-file-list">${!project.repo?'No linked repository.':data.recordsMissing?window.MosaicRecords.markup(project.repo):!docs?'Project documents unavailable. Docs could not read this project’s repository.':docs.length?docs.map(name=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><a href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(name)}">Open in Docs</a></div>`).join(''):'No project documents found.'}</div>`;
|
||||
}
|
||||
const expandedPrds = new Set();
|
||||
function requirementView(data,project,selected) {
|
||||
// U5: a project in a Side by side pane keeps its own request choice and PRD disclosure, so two
|
||||
// panes showing the same project (or two organizations' projects with one id) never share them.
|
||||
// The pane passes `views`, kept on the pane per organization, so the state outlives a repaint
|
||||
// that replaces the content element (a new conversation changes the header). Without it, the
|
||||
// state lives as long as the container.
|
||||
const paneViews = new WeakMap();
|
||||
const viewState=(container,pane,views)=>{if(!pane) return {selected:selectedRequests,expanded:expandedPrds}; if(views?.selected instanceof Map && views.expanded instanceof Set) return views; if(!paneViews.has(container)) paneViews.set(container,{selected:new Map(),expanded:new Set()}); return paneViews.get(container);};
|
||||
function requirementView(data,project,selected,expandedSet=expandedPrds) {
|
||||
const options=data.requests.filter(r=>r.draft), r=options.find(x=>x.id===selected)||options[0], d=r?.draft;
|
||||
const north=typeof data.north?.content==='string'?data.north.content:null;
|
||||
const select=options.length>1?`<label>Request <select id="project-request-select">${options.map(x=>`<option value="${clean(x.id)}"${x===r?' selected':''}>${clean(x.id)} · ${clean(x.thread.name)}</option>`).join('')}</select></label>`:'';
|
||||
const candidate=d?.candidate, expanded=r && expandedPrds.has(project.id+':'+r.id);
|
||||
const candidate=d?.candidate, expanded=r && expandedSet.has(project.id+':'+r.id);
|
||||
const docPath=candidate?.prd_path, docLink=project.repo && typeof docPath==='string' && /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(docPath) ? `#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(docPath)}` : null;
|
||||
const rows=candidate ? [['PRD version',candidate.prd_version],['Approved snapshot',d.snapshot.id || 'None'],['PRD file',docPath],['PRD blob',candidate.prd_blob],['Records commit',candidate.records_commit],['Confirmed intent commit',candidate.spec_commit],['Mode revision',candidate.mode_revision]].map(([label,value])=>`<div class="project-provenance-row"><span>${label}</span>${label==='PRD file' && docLink?`<a href="${docLink}"><code>${clean(value)}</code></a>`:`<code>${clean(value)}</code>`}</div>`).join('') : '<p>Unavailable until a PRD is read.</p>';
|
||||
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?`<p>${window.MosaicRecords.markup(project.repo)}</p>`:data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details data-restore-key="req:${clean(r.id)}:${clean(req.id)}"><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details data-restore-key="provenance"${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
|
||||
@@ -241,31 +252,32 @@
|
||||
const wanted=new Set((restore.disclosures || []).map(String)), closed=new Set((restore.closed || []).map(String));
|
||||
[...root.querySelectorAll('details')].forEach((el,i)=>{const key=restoreKey(el,i);if(wanted.has(key)) el.open=true;else if(el.dataset.restoreKey && closed.has(key)) el.open=false;});
|
||||
const f=restore.projectFocus; if(!f) return;
|
||||
const focus=f.id ? document.getElementById(f.id) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index];
|
||||
const focus=f.id ? root.querySelector('#'+CSS.escape(f.id)) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index];
|
||||
focus?.focus({preventScroll:true});
|
||||
}
|
||||
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt}) {
|
||||
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null,pane=false,views:paneState=null}) {
|
||||
if(!container || !project) return;
|
||||
const generation=epoch, run=(contentRuns.get(container) || 0)+1;
|
||||
contentRuns.set(container,run);
|
||||
if (!contentPaints.has(container)) container.textContent='Reading…';
|
||||
const data=await gather(project,threads).catch(()=>null);
|
||||
const data=await gather(project,threads,org).catch(()=>null);
|
||||
if(!container.isConnected || generation!==epoch || contentRuns.get(container)!==run || document.body.classList.contains('signed-out')) return;
|
||||
if(!data){contentPaints.delete(container);container.textContent='Project data unavailable. Refresh to try again.';return;}
|
||||
const views=viewState(container,pane,paneState), selectedRequests=views.selected, expandedPrds=views.expanded;
|
||||
const selected=selectedRequests.get(project.id);
|
||||
if(selected && !data.requests.some(r=>r.id===selected && r.draft)) selectedRequests.delete(project.id);
|
||||
const selection=selectedRequests.get(project.id);
|
||||
const view = section==='requirements'?requirementView(data,project,selection):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
const view = section==='requirements'?requirementView(data,project,selection,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
const selectedRequest=data.requests.find(r=>r.id===(selection||data.requests.find(x=>x.draft)?.id));
|
||||
// The generated presentation and separately mounted Markdown determine visible content.
|
||||
const signature=JSON.stringify([view, section==='requirements' ? selectedRequest?.draft?.prd : null, ['requirements','overview'].includes(section) ? data.north?.content : null, data.failed && ['files','tasks'].includes(section)]);
|
||||
const header=container.closest('.chat-area')?.querySelector('.project-tabs');
|
||||
if(header){const tasks=header.querySelector(`a[href="${path(project.id,'tasks')}"]`), files=header.querySelector(`a[href="${path(project.id,'files')}"]`);const f=filesData(data);if(tasks && !tasks.querySelector('.project-count') && !data.failed)tasks.insertAdjacentHTML('beforeend',` <span class="project-count">${taskRefs(data).length}</span>`);if(files && !files.querySelector('.project-count') && !data.failed && f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries)))files.insertAdjacentHTML('beforeend',` <span class="project-count">${f.reviewed.length+f.drafts.length+f.docs.length}</span>`);}
|
||||
if(header){const tab=key=>header.querySelector(`a[href$="${path(project.id,key).slice(1)}"]`), tasks=tab('tasks'), files=tab('files');const f=filesData(data);if(tasks && !tasks.querySelector('.project-count') && !data.failed)tasks.insertAdjacentHTML('beforeend',` <span class="project-count">${taskRefs(data).length}</span>`);if(files && !files.querySelector('.project-count') && !data.failed && f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries)))files.insertAdjacentHTML('beforeend',` <span class="project-count">${f.reviewed.length+f.drafts.length+f.docs.length}</span>`);}
|
||||
revealTab(header);
|
||||
// Retained controls (the request selector) draw from the newest accepted read, even when
|
||||
// this read changes nothing visible and the DOM is kept.
|
||||
contentModels.set(container,{data,project,section,canSend,roomReceipt});
|
||||
window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
|
||||
if(!pane) window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
|
||||
if (contentPaints.get(container) === signature) return;
|
||||
contentPaints.set(container, signature);
|
||||
// Capture at commit time: the user may have interacted while the read was pending.
|
||||
@@ -273,7 +285,7 @@
|
||||
const liveRestore=contentPaints.has(container) && container.children.length ? captureView(container) : null;
|
||||
const draw=selected=>{
|
||||
const {data,project,section,canSend,roomReceipt}=contentModels.get(container);
|
||||
container.innerHTML=section==='requirements'?requirementView(data,project,selected):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
container.innerHTML=section==='requirements'?requirementView(data,project,selected,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
if(data.failed && ['files','tasks'].includes(section)) container.insertAdjacentHTML('afterbegin','<p role="status">Some request data could not be read; this list may be incomplete.</p>');
|
||||
const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id));
|
||||
const md=container.querySelector('[data-prd]'); if(md && selectedRequest?.draft) { md.replaceChildren(window.MosaicMarkdown?.render(selectedRequest.draft.prd) || document.createTextNode(selectedRequest.draft.prd)); const toggle=container.querySelector('.project-prd-toggle'); toggle.hidden=!md.classList.contains('expanded') && md.scrollHeight<=md.clientHeight; toggle.addEventListener('click',()=>{const key=project.id+':'+selectedRequest.id; if(expandedPrds.has(key)) expandedPrds.delete(key); else expandedPrds.add(key); const open=expandedPrds.has(key); md.classList.toggle('expanded',open); toggle.setAttribute('aria-expanded',String(open));toggle.textContent=open?'Show less':'Show full PRD';}); }
|
||||
@@ -285,7 +297,7 @@
|
||||
if (restore) {
|
||||
restoreView(container,restore);
|
||||
}
|
||||
const sectionTarget=new URLSearchParams(location.hash.split('?')[1] || '').get('section');
|
||||
const sectionTarget=pane ? null : new URLSearchParams(location.hash.split('?')[1] || '').get('section');
|
||||
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'});
|
||||
}
|
||||
// Cut at a word boundary, unless that would leave only a markdown marker (a long heading).
|
||||
@@ -334,7 +346,7 @@
|
||||
}
|
||||
const palette=()=>treeRows.filter(p=>!p.archived && UUID.test(p.id)).flatMap(p=>sections.map(([key,label])=>({href:path(p.id,key),label:key==='overview'?p.name:`${p.name}: ${label}`,group:'project'})));
|
||||
function breadcrumb(box, view) {
|
||||
const h=view.querySelector('.project-header'); if(!h)return false;
|
||||
const h=view.querySelector('.project-header'); if(!h||h.closest('[data-pane]'))return false;
|
||||
const id=h.dataset.projectId, name=h.dataset.projectName, section=h.dataset.projectSection, conversation=h.dataset.projectConversation==='true';
|
||||
const title=sections.find(([key])=>key===section)?.[1] || (section==='settings'?'Settings':'Overview');
|
||||
const leaf=conversation?view.querySelector('h1')?.textContent:title;
|
||||
|
||||
@@ -351,3 +351,360 @@ 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}
|
||||
|
||||
/* Across organizations (U3): the main dashboard, projects and inbox, samples 02, 04 and 05. */
|
||||
body:has([data-ws-home]) #breadcrumbs,body:has([data-ws-projects]) #breadcrumbs,body:has([data-ws-inbox]) #breadcrumbs{display:none}
|
||||
html[data-organizations] .ws-edge{box-shadow:inset 3px 0 0 var(--org)}
|
||||
html[data-organizations] .ws-orgref{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;color:var(--strong);font-weight:600}
|
||||
html[data-organizations] .ws-chip{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;padding:1px 7px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap;line-height:1.5}
|
||||
html[data-organizations] .ws-chip.gated{background:color-mix(in srgb,var(--danger) 14%,var(--surface));color:var(--danger)}
|
||||
html[data-organizations] .ws-chip.block{background:var(--danger);color:var(--surface)}
|
||||
html[data-organizations] .ws-chip.outline{background:none;box-shadow:inset 0 0 0 1px var(--line);color:var(--text);font-family:var(--mono);font-weight:500;font-size:11.5px}
|
||||
html[data-organizations] .ws-dot.s-ok{color:var(--success)}
|
||||
html[data-organizations] .ws-tbl .ws-dot,html[data-organizations] .ws-sess .ws-dot{color:var(--text)}
|
||||
html[data-organizations] .ws-tbl .ws-dot.s-working b,html[data-organizations] .ws-tbl .ws-dot.s-ok b{color:var(--success)}
|
||||
html[data-organizations] .ws-tbl .ws-dot.s-waiting b{color:var(--warning)}
|
||||
html[data-organizations] .ws-tbl .ws-dot.s-error b{color:var(--danger)}
|
||||
html[data-organizations] .ws-tbl .ws-dot.s-idle b,html[data-organizations] .ws-tbl .ws-dot.s-offline b{color:var(--muted)}
|
||||
html[data-organizations] .ws-g5{display:grid;gap:10px;grid-template-columns:repeat(5,minmax(0,1fr))}
|
||||
html[data-organizations] .ws-tile .ws-big{font-size:24px;font-weight:800;color:var(--strong);letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.1}
|
||||
html[data-organizations] .ws-tile .ws-big small{font-size:13px;font-weight:600;color:var(--muted);letter-spacing:0;margin-left:4px}
|
||||
html[data-organizations] .ws-tile .ws-delta{font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .ws-tile .ws-delta b{color:var(--success);font-weight:600}
|
||||
html[data-organizations] .ws-tile.quiet{border-style:dashed;background:transparent}
|
||||
html[data-organizations] .ws-bar{display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--raised);gap:2px}
|
||||
html[data-organizations] .ws-bar i{display:block;background:var(--org,var(--action))}
|
||||
html[data-organizations] .ws-by{display:flex;flex-wrap:wrap;gap:4px 10px;font-size:12px;color:var(--text)}
|
||||
html[data-organizations] .ws-by>span{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||
html[data-organizations] .ws-by .ws-av{width:14px;height:14px;border-radius:3px;font-size:7px}
|
||||
html[data-organizations] .ws-orgcards{list-style:none;margin:0;padding:0;display:grid;gap:8px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))}
|
||||
html[data-organizations] .ws-orgcard a{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:2px 10px;align-items:center;padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);box-shadow:inset 3px 0 0 var(--org);color:var(--text);text-decoration:none;font-size:13px;height:100%}
|
||||
html[data-organizations] .ws-orgcard a:hover{background:var(--raised)}
|
||||
html[data-organizations] .ws-orgcard .ws-av{grid-row:1/3}
|
||||
html[data-organizations] .ws-orgcard .nm{font-weight:700;color:var(--strong);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
html[data-organizations] .ws-orgcard .ws-dot{grid-column:2;grid-row:2;font-size:12.5px;color:var(--text)}
|
||||
html[data-organizations] .ws-orgcard .ws-dot.s-working b{color:var(--success)}
|
||||
html[data-organizations] .ws-orgcard .ws-dot.s-waiting b{color:var(--warning)}
|
||||
html[data-organizations] .ws-orgcard .ws-dot.s-error b{color:var(--danger)}
|
||||
html[data-organizations] .ws-orgcard .at{grid-column:3;grid-row:1;font:500 11.5px var(--mono);color:var(--muted)}
|
||||
html[data-organizations] .ws-orgcard .why{grid-column:1/-1;font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .ws-orgcard.off a{border-style:dashed}
|
||||
html[data-organizations] .ws-home-split{margin-top:4px}
|
||||
html[data-organizations] .ws-trail{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
|
||||
html[data-organizations] .ws-trail li{display:grid;grid-template-columns:48px 20px minmax(0,1fr);gap:10px;padding:7px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);font-size:13px;align-items:center}
|
||||
html[data-organizations] .ws-trail li:last-child{border-bottom:0}
|
||||
html[data-organizations] .ws-trail time{font-family:var(--mono);font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .ws-trail .what{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
html[data-organizations] .ws-trail .what a{color:var(--strong);text-decoration:none}
|
||||
html[data-organizations] .ws-trail .what a:hover{text-decoration:underline}
|
||||
html[data-organizations] .ws-trail .what b{font-weight:600}
|
||||
html[data-organizations] .ws-trail .src{color:var(--muted)}
|
||||
html[data-organizations] .ws-states{display:grid;gap:8px}
|
||||
html[data-organizations] .ws-srow{display:grid;grid-template-columns:112px 24px minmax(0,1fr) auto;gap:10px;align-items:center;font-size:13px}
|
||||
html[data-organizations] .ws-srow .sl{color:var(--strong);font-weight:600}
|
||||
html[data-organizations] .ws-srow .sn{font:700 13px var(--mono);color:var(--strong);text-align:right}
|
||||
html[data-organizations] .ws-srow .ws-bar{height:8px;max-width:100%}
|
||||
html[data-organizations] .ws-needs{list-style:none;margin:0;padding:0;display:grid;gap:8px}
|
||||
html[data-organizations] .ws-needs li{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:4px 10px;align-items:center;background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:9px 12px}
|
||||
html[data-organizations] .ws-needs li>.ws-av{grid-row:1/3}
|
||||
html[data-organizations] .ws-needs .q{color:var(--strong);font-weight:600;font-size:13.5px;text-decoration:none}
|
||||
html[data-organizations] .ws-needs .q:hover{text-decoration:underline}
|
||||
html[data-organizations] .ws-needs .src{grid-column:2;font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .ws-needs .ws-chip{grid-row:1/3;grid-column:3}
|
||||
html[data-organizations] .ws-more-line{margin:6px 0 0}
|
||||
html[data-organizations] .ws-sess td.who{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:0;width:100%}
|
||||
html[data-organizations] .ws-sess td.who .ws-orgref{vertical-align:middle}
|
||||
html[data-organizations] .ws-sess td.st{white-space:nowrap}
|
||||
/* Filters and tables on Projects and Inbox. */
|
||||
html[data-organizations] .ws-toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;margin-bottom:12px}
|
||||
html[data-organizations] .ws-toolbar .ws-right{align-items:center}
|
||||
html[data-organizations] .ws-toolbar .ws-seg{margin-left:0}
|
||||
html[data-organizations] .ws-fchips{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
|
||||
html[data-organizations] .ws-fchips .f{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:999px;padding:2px 10px 2px 4px;font:600 12.5px/1.5 var(--font);color:var(--text);white-space:nowrap;cursor:pointer;box-shadow:none}
|
||||
html[data-organizations] .ws-fchips .f.plain{padding-left:10px}
|
||||
html[data-organizations] .ws-fchips .f:hover{background:var(--raised)}
|
||||
html[data-organizations] .ws-fchips .f[aria-pressed="true"]{background:var(--strong);color:var(--surface);border-color:var(--strong)}
|
||||
html[data-organizations] .ws-fchips .f .ws-av{width:18px;height:18px;border-radius:999px;font-size:8.5px;letter-spacing:0}
|
||||
html[data-organizations] .ws-fchips .f .n{margin-left:2px;font:600 11px var(--mono);opacity:.8}
|
||||
html[data-organizations] .ws-sel{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 0 0 8px;font-size:13px;color:var(--strong);white-space:nowrap}
|
||||
html[data-organizations] .ws-sel .ws-label{font-size:10px}
|
||||
html[data-organizations] .ws-sel select{border:0;background:transparent;color:var(--strong);font:500 13px/1.5 var(--font);padding:3px 6px 3px 2px;min-height:0;box-shadow:none;width:auto}
|
||||
html[data-organizations] .ws-search{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 8px;color:var(--muted);font-size:13px;min-width:180px}
|
||||
html[data-organizations] .ws-search svg.i{width:14px;height:14px;flex:none}
|
||||
html[data-organizations] .ws-search input{border:0;background:transparent;color:var(--strong);font:500 13px/1.5 var(--font);padding:3px 0;min-height:0;box-shadow:none;width:100%;min-width:0}
|
||||
html[data-organizations] .ws-search:focus-within,html[data-organizations] .ws-sel:focus-within{outline:2px solid var(--focus,var(--action));outline-offset:1px}
|
||||
html[data-organizations] .ws-search input:focus,html[data-organizations] .ws-sel select:focus{outline:none}
|
||||
html[data-organizations] .ws-tbl th.sorted{color:var(--strong)}
|
||||
html[data-organizations] .ws-tbl th .ar{font-family:var(--mono);margin-left:3px;color:var(--action)}
|
||||
html[data-organizations] .ws-tbl td.num,html[data-organizations] .ws-tbl th.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
html[data-organizations] .ws-tbl tr.ws-edge td:first-child{box-shadow:inset 3px 0 0 var(--org)}
|
||||
html[data-organizations] .ws-tbl tr.ws-edge{box-shadow:none}
|
||||
html[data-organizations] .ws-tbl .nm{color:var(--strong);font-weight:600;text-decoration:none}
|
||||
html[data-organizations] .ws-tbl a.nm:hover{text-decoration:underline}
|
||||
html[data-organizations] .ws-tbl .ws-n{margin-left:0}
|
||||
html[data-organizations] .ws-grp-row th{padding:8px 10px 6px;background:color-mix(in srgb,var(--raised) 45%,var(--surface));text-transform:none;letter-spacing:0;font-size:12.5px;color:var(--strong);box-shadow:inset 3px 0 0 var(--org)}
|
||||
html[data-organizations] .ws-grp-row th>*{vertical-align:middle}
|
||||
html[data-organizations] .ws-grp-row th>span{margin-left:8px}
|
||||
html[data-organizations] .ws-tbl .ws-grp-row th>.ws-n{margin-left:8px}
|
||||
html[data-organizations] .ws-unread td{font-size:12.5px}
|
||||
html[data-organizations] .ws-unread-note{margin:-4px 0 10px}
|
||||
html[data-organizations] .ws-av.mob{display:none}
|
||||
html[data-organizations] .ws .show-m{display:none}
|
||||
/* Inbox. */
|
||||
html[data-organizations] .ws-ibx-grid{display:grid;gap:16px;grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-ibx-grid.with-insp{grid-template-columns:minmax(0,1fr) minmax(300px,360px);align-items:start}
|
||||
html[data-organizations] .ws-ibx-list>.ws-sec-h{margin-top:22px}
|
||||
html[data-organizations] .ws-ibx td.src{white-space:nowrap}
|
||||
html[data-organizations] .ws-ibx .q{color:var(--strong);font-weight:600;text-decoration:none}
|
||||
html[data-organizations] .ws-ibx .q:hover{text-decoration:underline}
|
||||
html[data-organizations] .ws-ibx .via{display:block;font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .ws-ibx tr.on{background:color-mix(in srgb,var(--action) 10%,var(--surface))}
|
||||
html[data-organizations] .ws-ibx tr.on td:first-child{box-shadow:inset 3px 0 0 var(--action)}
|
||||
html[data-organizations] .ws-msrc{align-items:center;gap:5px;font-size:12px;color:var(--muted);margin-bottom:2px}
|
||||
html[data-organizations] .ws-insp{border:1px solid var(--line);border-radius:10px;background:var(--surface);display:flex;flex-direction:column;min-width:0;position:sticky;top:12px;max-height:calc(100vh - 80px);overflow:auto}
|
||||
html[data-organizations] .ws-insp header{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--line);background:var(--surface);position:sticky;top:0;z-index:1;box-shadow:inset 3px 0 0 var(--org)}
|
||||
html[data-organizations] .ws-insp header .x{margin-left:auto;color:var(--muted);display:inline-flex}
|
||||
html[data-organizations] .ws-insp header .x svg.i{width:18px;height:18px}
|
||||
html[data-organizations] .ws-insp .body{padding:14px;display:grid;gap:14px}
|
||||
html[data-organizations] .ws-insp h3{margin:0;font-size:16px;font-weight:700;color:var(--strong)}
|
||||
html[data-organizations] .ws-insp .note{margin:0}
|
||||
html[data-organizations] .ws-auth{border:1px solid var(--line);border-left:3px solid var(--org,var(--action));border-radius:0 6px 6px 0;padding:8px 10px;background:var(--canvas);font-size:13px;color:var(--strong)}
|
||||
html[data-organizations] .ws-opts{list-style:none;margin:0;padding:0;display:grid;gap:8px}
|
||||
html[data-organizations] .ws-opts li{border:1px solid var(--line);border-radius:8px;padding:9px 10px;display:grid;gap:6px;background:var(--canvas)}
|
||||
html[data-organizations] .ws-opts .k{font:700 13px var(--mono);color:var(--strong)}
|
||||
html[data-organizations] .ws-opts .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
html[data-organizations] .ws-opts b{font-weight:600;color:var(--strong)}
|
||||
html[data-organizations] [data-ws-cmds]{display:grid;gap:6px}
|
||||
html[data-organizations] .ws-cmd{display:flex;gap:6px;align-items:center;min-width:0}
|
||||
html[data-organizations] .ws-cmd code{flex:1;min-width:0;overflow-x:auto;white-space:nowrap;background:var(--surface);border:1px solid var(--line);border-radius:4px;padding:3px 6px;font-size:12px;color:var(--strong)}
|
||||
html[data-organizations] .ws-cmd .ws-btn{padding:4px 6px}
|
||||
html[data-organizations] #copy-status:empty{display:none}
|
||||
@media (max-width:1100px){
|
||||
html[data-organizations] .ws-g5{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
html[data-organizations] .ws-ibx-grid.with-insp{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-insp{position:static;max-height:none}
|
||||
}
|
||||
|
||||
@media (pointer:coarse){html[data-organizations] .ws-nav,html[data-organizations] .s1-section.ws-nav{min-height:40px}}
|
||||
@media (max-width:759px){
|
||||
html[data-organizations] .content:has(.ws){padding:14px 16px 24px}
|
||||
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}
|
||||
html[data-organizations] .ws-g5{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-g5.keep2{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
html[data-organizations] .ws-g5.keep2>:last-child:nth-child(odd){grid-column:1/-1}
|
||||
html[data-organizations] .ws-orgcards{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .ws-srow{grid-template-columns:92px 20px minmax(0,1fr)}
|
||||
html[data-organizations] .ws-srow .ws-by{grid-column:1/-1}
|
||||
html[data-organizations] .ws-trail li{grid-template-columns:42px 18px minmax(0,1fr);gap:8px}
|
||||
html[data-organizations] .ws-trail .what{white-space:normal}
|
||||
html[data-organizations] .ws-toolbar .ws-right{margin-left:0}
|
||||
html[data-organizations] .ws-search{min-width:0;flex:1 1 100%}
|
||||
html[data-organizations] .ws .show-m,html[data-organizations] .ws-tbl .t2.show-m{display:block}
|
||||
html[data-organizations] .ws span.show-m{display:inline-flex}
|
||||
html[data-organizations] .ws span.ws-msrc.show-m{display:flex}
|
||||
html[data-organizations] .ws-av.mob{display:inline-grid;margin-right:6px;vertical-align:-3px}
|
||||
html[data-organizations] .ws-tbl .t2.mono.hide-m{display:none}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/* Phase P2 (#1873): System types on the organization dashboard (.ws-types, the chips and the
|
||||
Settings row's Open) and on a project's information page (.project-types). */
|
||||
html[data-organizations] .ws-chip{display:inline-flex;align-items:center;font-size:12px;font-weight:500;padding:1px 7px;border:1px solid var(--line);border-radius:4px;color:var(--text);white-space:nowrap}
|
||||
html[data-organizations] .ws-cfg button.go{border:0;background:none;padding:0;cursor:pointer;font:inherit;font-size:12.5px;font-weight:600;color:var(--action)}
|
||||
html[data-organizations] .ws-cfg button.go:hover{text-decoration:underline}
|
||||
html[data-organizations] .ws-types-list{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-types-list li{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:4px 12px;padding:8px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
|
||||
html[data-organizations] .ws-types-list li:last-child{border-bottom:0}
|
||||
html[data-organizations] .ws-types-list .t{display:flex;flex-direction:column;min-width:0}
|
||||
html[data-organizations] .ws-types-list .t b{font-size:13px;font-weight:600;color:var(--strong)}
|
||||
html[data-organizations] .ws-types-list .t .d{font-size:12px;color:var(--muted);overflow-wrap:anywhere}
|
||||
html[data-organizations] .ws-types-list .ws-dot{font-size:12.5px}
|
||||
html[data-organizations] .ws-types-list .ws-btn[aria-disabled="true"]{color:var(--muted);cursor:progress}
|
||||
html[data-organizations] .ws-types>.note{margin:8px 0 0}
|
||||
html[data-organizations] .ws-types-status:empty{display:none}
|
||||
html[data-organizations] .ws-types-status code{overflow-wrap:anywhere}
|
||||
html[data-organizations] .ws-types h2:focus{outline:none}
|
||||
@media (max-width:759px){
|
||||
html[data-organizations] .ws-types-list li{grid-template-columns:minmax(0,1fr) auto}
|
||||
html[data-organizations] .ws-types-list .t{grid-column:1/-1}
|
||||
}
|
||||
|
||||
.project-types{border:1px solid var(--border, var(--line, #aaa));border-radius:4px;padding:1rem;margin-top:1rem;overflow-wrap:anywhere}
|
||||
.project-types fieldset{border:0;margin:0;padding:0;display:grid;gap:.5rem}
|
||||
.project-types legend{font-weight:600;margin-bottom:.25rem}
|
||||
.project-types-option{display:flex;align-items:flex-start;gap:.5rem;min-height:44px}
|
||||
.project-types-option input{margin-top:.2rem;width:20px;height:20px;flex:none}
|
||||
.project-types-option span{display:flex;flex-direction:column;min-width:0}
|
||||
.project-types-option small{color:var(--muted, inherit)}
|
||||
.project-types button{min-height:44px;margin-top:.75rem}
|
||||
.project-types-tag{font-size:.8em;font-weight:600;padding:0 .4em;border:1px solid currentColor;border-radius:4px;color:var(--warning, inherit)}
|
||||
.project-types [role="status"]:empty{display:none}
|
||||
.project-types [role="status"]:focus{outline:none}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Phase P2 (#1873): System types, the owner's control on the organization dashboard and on a project's
|
||||
// information page. The modules, their labels and descriptions come from the system-type plug-ins
|
||||
// through /api/system-types; nothing here lists a module. Writes go to the same route, which uses the
|
||||
// organization writer (revision, owner fence, receipt) or project.system-types.set; the server and the
|
||||
// bus recheck every write, so a control shown here grants nothing.
|
||||
(() => {
|
||||
'use strict';
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const enc = encodeURIComponent;
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
|
||||
const orgPath = (org, rest) => (org ? `/api/orgs/${enc(org)}/${rest}` : `/api/${rest}`);
|
||||
const LINES = {
|
||||
'sign-in-required': 'Sign in again to see or change system types.',
|
||||
'owner-only': "System types are the organization owner's.",
|
||||
'read-only': 'This Console only reads. Changing system types needs signed-in send mode.',
|
||||
'human-required': "Only the organization's owner can change system types.",
|
||||
'organization-system-types-human-only': "Only the organization's owner can change system types.",
|
||||
'revision-conflict': 'The organization file changed after this page read it. The list was read again; try again.',
|
||||
'organization-busy': 'The organization file is being written. Try again in a moment.',
|
||||
'system-type-not-enabled': 'A chosen type is not enabled in the organization any more. The list was read again.',
|
||||
'system-type-unknown': 'A chosen type is not installed in this tree.',
|
||||
'project-not-found': 'This project was not found.',
|
||||
'not-configured': 'System types are not configured on this Console.',
|
||||
'system-type-invalid': 'An installed system type module is invalid, so none can be read.',
|
||||
'no-bus-host': 'The bus is not running.',
|
||||
'invalid-request': 'The request was refused as invalid.',
|
||||
};
|
||||
const line = code => LINES[code] || 'The bus did not confirm the outcome. The list was read again; check it before you retry.';
|
||||
|
||||
// One request; a refusal keeps its code so the page can say why.
|
||||
async function call(path, body) {
|
||||
const init = body === undefined ? { cache: 'no-store', redirect: 'manual' }
|
||||
: { method: 'POST', cache: 'no-store', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) };
|
||||
let response;
|
||||
try { response = await fetch(path, init); } catch { return { ok: false, code: 'outcome-unknown' }; }
|
||||
if (response.type === 'opaqueredirect') return { ok: false, code: 'sign-in-required' };
|
||||
const value = await response.json().catch(() => null);
|
||||
if (!response.ok) return { ok: false, code: typeof value?.error === 'string' ? value.error : 'outcome-unknown' };
|
||||
return value && typeof value === 'object' ? { ok: true, value } : { ok: false, code: 'outcome-unknown' };
|
||||
}
|
||||
const read = (org, project = null) => call(orgPath(org, project === null ? 'system-types' : `system-types/projects/${enc(project)}`));
|
||||
const labelOf = (value, id) => value.installed.find(m => m.id === id)?.label || id;
|
||||
const valid = value => value && Array.isArray(value.installed) && Array.isArray(value.enabled);
|
||||
|
||||
// ---------- organization dashboard ----------
|
||||
// Per organization: the last read, the type a write is pending on, and the last outcome line.
|
||||
const orgs = new Map();
|
||||
const slot = org => { if (!orgs.has(org)) orgs.set(org, { value: null, code: null, pending: null, outcome: null }); return orgs.get(org); };
|
||||
function remember(org, result) {
|
||||
const s = slot(org);
|
||||
if (result.ok && valid(result.value)) { s.value = result.value; s.code = null; } else { s.value = null; s.code = result.ok ? 'outcome-unknown' : result.code; }
|
||||
return s;
|
||||
}
|
||||
// The chip row under the header: the enabled modules' labels.
|
||||
function chips(org) {
|
||||
const s = slot(org), v = s.value;
|
||||
const body = !v ? `<span class="note">${esc(s.code === 'owner-only' ? line('owner-only') : 'System types unavailable')}</span>`
|
||||
: !v.installed.length ? '<span class="note">No system type modules are installed.</span>'
|
||||
: v.enabled.length ? `${v.enabled.map(id => `<span class="ws-chip">${esc(labelOf(v, id))}</span>`).join('')}<span class="note">Each project picks from these; none is required.</span>`
|
||||
: '<span class="note">None enabled. Enable them under System types below; each project picks from the enabled ones.</span>';
|
||||
return `<div class="ws-typechips ws-chips" data-system-type-chips><span class="ws-label">System types</span>${body}</div>`;
|
||||
}
|
||||
// The Settings row's value.
|
||||
function summary(org) {
|
||||
const v = slot(org).value;
|
||||
return v ? `${v.enabled.filter(id => v.installed.some(m => m.id === id)).length} of ${v.installed.length} installed enabled` : 'Could not be read';
|
||||
}
|
||||
// A side-by-side pane (U5) passes { pane: true, idPrefix }: it shows the types without the switch,
|
||||
// which stays in the organization's full view, and its heading id carries the pane's prefix.
|
||||
function section(org, { pane = false, idPrefix = '' } = {}) {
|
||||
const s = slot(org), v = s.value;
|
||||
const head = `<div class="ws-sec-h"><h2 id="${esc(idPrefix)}ws-types-h" tabindex="-1" data-system-types-heading>System types</h2><span class="note">installed modules; each project picks from the enabled ones</span></div>`;
|
||||
const status = `<p class="note ws-types-status" role="status" data-system-types-status>${s.outcome ? s.outcome : ''}</p>`;
|
||||
if (!v) return `<div class="ws-types" data-system-types>${head}<p class="ws-panel tight note">${esc(s.code === 'owner-only' ? line('owner-only') : `System types unavailable. ${line(s.code)}`)}</p>${status}</div>`;
|
||||
if (!v.installed.length) return `<div class="ws-types" data-system-types>${head}<p class="ws-panel tight note">No system type modules are installed.</p>${status}</div>`;
|
||||
const rows = v.installed.map(m => {
|
||||
const on = v.enabled.includes(m.id), busy = s.pending !== null;
|
||||
const state = `<span class="ws-dot ${on ? 's-working' : 's-idle'}"><b aria-hidden="true">${on ? '●' : '–'}</b>${on ? 'enabled' : 'off'}</span>`;
|
||||
const control = v.writable && !pane
|
||||
? `<button type="button" class="ws-btn" data-system-type-toggle="${esc(m.id)}" data-on="${on}" data-focus-key="${esc(`st:${m.id}`)}" aria-label="${esc(`${on ? 'Disable' : 'Enable'} ${m.label}`)}"${busy ? ' aria-disabled="true"' : ''}>${s.pending === m.id ? 'Saving…' : on ? 'Disable' : 'Enable'}</button>`
|
||||
: '';
|
||||
return `<li data-system-type="${esc(m.id)}"><span class="t"><b>${esc(m.label)}</b><span class="d">${esc(m.description)}</span></span>${state}${control}</li>`;
|
||||
}).join('');
|
||||
const why = pane ? "<p class=\"note\">Change system types from this organization's full view.</p>"
|
||||
: v.writable ? '<p class="note">Disabling a type keeps it in the projects that pick it; they show it as inactive until it is enabled again.</p>'
|
||||
: `<p class="note">${esc(line('read-only'))}</p>`;
|
||||
return `<div class="ws-types" data-system-types>${head}<ul class="ws-types-list">${rows}</ul>${why}${status}</div>`;
|
||||
}
|
||||
// The dashboard asks for this before it paints; the result stays for chips(), summary() and section().
|
||||
const load = async org => remember(org, await read(org));
|
||||
// After a write, the three parts are repainted in place; the 10 s dashboard repaint reads again anyway.
|
||||
function repaint(org) {
|
||||
if (window.MosaicOrg?.id !== org) return;
|
||||
const view = [...document.querySelectorAll('[data-org-dashboard]')].find(v => !v.closest('[data-pane]')); if (!view) return;
|
||||
const a = document.activeElement, key = view.contains(a) ? a.dataset?.focusKey || null : null;
|
||||
const swap = (selector, html) => { const el = view.querySelector(selector); if (el) el.outerHTML = html; };
|
||||
swap('[data-system-type-chips]', chips(org));
|
||||
swap('[data-system-types]', section(org));
|
||||
const value = view.querySelector('[data-org-setting="System types"] .v'); if (value) { value.textContent = summary(org); value.title = summary(org); }
|
||||
if (key) view.querySelector(`[data-focus-key="${CSS.escape(key)}"]`)?.focus({ preventScroll: true });
|
||||
}
|
||||
async function toggle(button) {
|
||||
const org = window.MosaicOrg?.id, s = org ? slot(org) : null, type = button.dataset.systemTypeToggle;
|
||||
if (!s?.value?.writable || s.pending !== null || typeof s.value.revision !== 'string') return;
|
||||
const on = s.value.enabled.includes(type);
|
||||
const enabled = on ? s.value.enabled.filter(id => id !== type) : [...s.value.enabled, type];
|
||||
s.pending = type; s.outcome = null; repaint(org);
|
||||
const result = await call(orgPath(org, 'system-types'), { revision: s.value.revision, enabled });
|
||||
s.pending = null;
|
||||
const label = labelOf(s.value, type);
|
||||
if (result.ok && typeof result.value.transaction === 'string') {
|
||||
s.outcome = `${esc(label)} ${on ? 'disabled' : 'enabled'}. Receipt <code>${esc(result.value.transaction)}</code>.`;
|
||||
} else s.outcome = esc(result.ok ? line('outcome-unknown') : line(result.code));
|
||||
await load(org);
|
||||
repaint(org);
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const button = e.target.closest?.('[data-system-type-toggle]');
|
||||
// A pending write marks the buttons aria-disabled, not disabled, so focus stays on the one pressed.
|
||||
if (button) { e.preventDefault(); if (button.getAttribute('aria-disabled') !== 'true' && !button.closest('[data-pane]')) toggle(button); return; }
|
||||
const open = e.target.closest?.('[data-system-types-open]');
|
||||
if (open) { e.preventDefault(); const h = (open.closest('[data-org-dashboard]') || document).querySelector('[data-system-types-heading]'); h?.scrollIntoView({ block: 'start' }); h?.focus({ preventScroll: true }); }
|
||||
});
|
||||
|
||||
// ---------- project information page ----------
|
||||
let active = null;
|
||||
document.addEventListener('mosaic-signed-out', () => { active?.dispose(); active = null; });
|
||||
// The chat view mounts again on every repaint and keeps the section; leaving the page marks it stale,
|
||||
// so coming back (say, after the dashboard changed the enabled set) reads again.
|
||||
window.addEventListener('hashchange', () => { if (active) active.stale = true; });
|
||||
function mount({ container, projectId, archived = false }) {
|
||||
if (!(container instanceof Element)) return;
|
||||
const org = window.MosaicOrg?.id ?? null;
|
||||
if (active && active.org === org && active.projectId === projectId) {
|
||||
container.replaceChildren(active.section);
|
||||
if (active.archived !== archived) { active.archived = archived; active.paint(); }
|
||||
if (active.stale) { active.stale = false; active.refresh(); }
|
||||
return;
|
||||
}
|
||||
active?.dispose();
|
||||
const section = document.createElement('section');
|
||||
section.className = 'project-types';
|
||||
section.setAttribute('data-project-system-types', '');
|
||||
container.replaceChildren(section);
|
||||
let value = null, code = null, outcome = '', saving = false, disposed = false, generation = 0;
|
||||
const state = { org, projectId, section, archived, stale: false, paint, refresh: () => refresh(), dispose: () => { disposed = true; generation++; section.replaceChildren(); } };
|
||||
active = state;
|
||||
function paint() {
|
||||
if (disposed) return;
|
||||
const head = '<h2 tabindex="-1">System types</h2>';
|
||||
const status = `<p role="status" tabindex="-1" data-project-system-types-status>${outcome}</p>`;
|
||||
if (!ID.test(projectId ?? '')) { section.innerHTML = `${head}<p>System types unavailable.</p>`; return; }
|
||||
if (!value) { section.innerHTML = `${head}<p>${esc(code ? `System types unavailable. ${line(code)}` : 'Reading system types…')}</p>${status}`; return; }
|
||||
const sel = value.project, enabled = value.installed.filter(m => value.enabled.includes(m.id));
|
||||
const inactive = sel.inactive.length
|
||||
? `<div data-project-system-types-inactive><p>Inactive, because the organization has disabled them:</p><ul>${sel.inactive.map(id => `<li data-inactive-type="${esc(id)}">${esc(labelOf(value, id))} <span class="project-types-tag">inactive</span></li>`).join('')}</ul>${value.writable && !state.archived ? '<p class="muted">Saving keeps only enabled types, so it removes these from the project.</p>' : ''}</div>`
|
||||
: '';
|
||||
const intro = '<p>This project picks from the system types its organization has enabled. None is required.</p>';
|
||||
const canWrite = value.writable && !state.archived;
|
||||
if (!enabled.length) {
|
||||
// With nothing enabled the only change left is clearing what the project still selects.
|
||||
const clear = canWrite && sel.inactive.length
|
||||
? `<form data-project-system-types-form><button type="submit" class="btn"${saving ? ' disabled' : ''}>${saving ? 'Saving…' : 'Clear the inactive types'}</button></form>` : '';
|
||||
section.innerHTML = `${head}${intro}<p>The organization has no system types enabled. The owner enables them on the organization dashboard.</p>${inactive}${clear}${status}`;
|
||||
return;
|
||||
}
|
||||
const boxes = enabled.map(m => `<label class="project-types-option"><input type="checkbox" name="system-type" value="${esc(m.id)}"${sel.active.includes(m.id) ? ' checked' : ''}${canWrite && !saving ? '' : ' disabled'}><span><strong>${esc(m.label)}</strong><small>${esc(m.description)}</small></span></label>`).join('');
|
||||
const why = state.archived ? '<p class="muted">This project is archived; its selection is shown read only.</p>' : value.writable ? '' : `<p class="muted">${esc(line('read-only'))}</p>`;
|
||||
section.innerHTML = `${head}${intro}<form data-project-system-types-form><fieldset><legend>Selected system types</legend>${boxes}</fieldset>${inactive}${canWrite ? `<button type="submit" class="btn"${saving ? ' disabled' : ''}>${saving ? 'Saving…' : 'Save system types'}</button>` : ''}</form>${why}${status}`;
|
||||
}
|
||||
async function refresh() {
|
||||
const turn = ++generation;
|
||||
const result = await read(org, projectId);
|
||||
if (disposed || turn !== generation) return;
|
||||
if (result.ok && valid(result.value) && result.value.project) { value = result.value; code = null; } else { value = null; code = result.ok ? 'outcome-unknown' : result.code; }
|
||||
paint();
|
||||
}
|
||||
section.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
if (saving || !value?.writable || state.archived) return;
|
||||
const chosen = [...section.querySelectorAll('input[name="system-type"]:checked')].map(i => i.value);
|
||||
saving = true; outcome = ''; paint();
|
||||
const result = await call(orgPath(org, `system-types/projects/${enc(projectId)}`), { systemTypes: chosen });
|
||||
if (disposed) return;
|
||||
saving = false;
|
||||
outcome = result.ok ? esc(result.value.selected.length ? `Saved. Selected: ${result.value.selected.map(id => labelOf(value, id)).join(', ')}.` : 'Saved. No system types selected.') : esc(line(result.code));
|
||||
await refresh();
|
||||
section.querySelector('[data-project-system-types-status]')?.focus({ preventScroll: true });
|
||||
});
|
||||
paint();
|
||||
refresh();
|
||||
}
|
||||
|
||||
window.MosaicSystemTypes = Object.freeze({ load, chips, summary, section, mount });
|
||||
})();
|
||||
@@ -0,0 +1,97 @@
|
||||
/* U4b (#1873): the task board across organizations, ported from the U0 sample 06 (workspace.css
|
||||
EXT 7 and the board rules). Organization mode only; the org colour comes from .ws-o (shell.css). */
|
||||
html[data-organizations] .tb .ws-vh .ws-seg{margin-left:0}
|
||||
html[data-organizations] .tb-trk{display:inline-flex;align-items:center;gap:4px;font:600 10.5px var(--mono);letter-spacing:.02em;padding:0 5px;border-radius:3px;border:1px solid var(--line);color:var(--muted);white-space:nowrap}
|
||||
html[data-organizations] .tb-trk.vik{border-style:dashed;color:var(--accent);border-color:color-mix(in srgb,var(--accent) 60%,var(--line))}
|
||||
html[data-organizations] .tb-trackers{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center;font-size:12.5px;color:var(--text);padding:8px 12px;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface);margin-bottom:12px}
|
||||
html[data-organizations] .tb-trackers>span{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||||
html[data-organizations] .tb-trackers .note{font-size:12.5px}
|
||||
html[data-organizations] .tb-tracker .ws-dot{font-weight:600}
|
||||
html[data-organizations] .tb-reason{color:var(--muted)}
|
||||
html[data-organizations] .tb-notice{margin:0 0 10px;font-size:13px;color:var(--muted)}
|
||||
html[data-organizations] .tb-notice:empty{display:none}
|
||||
html[data-organizations] .tb-notice.is-error{color:var(--danger);font-weight:600}
|
||||
|
||||
/* Filter bar. */
|
||||
html[data-organizations] .tb-toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;margin-bottom:12px}
|
||||
html[data-organizations] .tb-right{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
html[data-organizations] .tb-chips{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
|
||||
html[data-organizations] .tb-f{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:999px;padding:2px 10px 2px 4px;font:600 12.5px/1.5 var(--font);color:var(--text);white-space:nowrap;cursor:pointer;box-shadow:none}
|
||||
html[data-organizations] .tb-f.plain{padding-left:10px}
|
||||
html[data-organizations] .tb-f[aria-pressed=true]{background:var(--strong);color:var(--surface);border-color:var(--strong)}
|
||||
html[data-organizations] .tb-f .ws-av{width:18px;height:18px;border-radius:999px;font-size:8.5px;letter-spacing:0}
|
||||
html[data-organizations] .tb-f .n{margin-left:2px;color:inherit;opacity:.8}
|
||||
html[data-organizations] .tb-sel{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 0 0 8px;font-size:13px;color:var(--strong);white-space:nowrap}
|
||||
html[data-organizations] .tb-sel .ws-label{font-size:10px}
|
||||
html[data-organizations] .tb-sel select{border:0;background:none;color:var(--strong);font:500 13px/1.5 var(--font);padding:3px 6px;min-height:0;max-width:16em;box-shadow:none}
|
||||
html[data-organizations] .tb-search{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 0 0 8px;color:var(--muted);min-width:180px}
|
||||
html[data-organizations] .tb-search svg.i,html[data-organizations] .tb-who svg.i{width:13px;height:13px}
|
||||
html[data-organizations] .tb-search input{border:0;background:none;color:var(--strong);font:13px/1.5 var(--font);padding:3px 8px 3px 0;min-height:0;width:100%;min-width:0;box-shadow:none}
|
||||
html[data-organizations] .tb-sel:focus-within,html[data-organizations] .tb-search:focus-within{outline:2px solid var(--action);outline-offset:1px}
|
||||
html[data-organizations] .tb-sel select:focus,html[data-organizations] .tb-search input:focus{outline:none}
|
||||
html[data-organizations] .tb-count{margin:0 0 8px;font-size:13px;color:var(--muted)}
|
||||
|
||||
/* Kanban. */
|
||||
html[data-organizations] .tb-board{display:grid;grid-template-columns:repeat(var(--tb-cols,5),minmax(0,1fr));gap:12px;align-items:start}
|
||||
html[data-organizations] .tb-col{background:color-mix(in srgb,var(--raised) 55%,var(--canvas));border:1px solid var(--line);border-radius:var(--r-lg);padding:8px;display:grid;gap:8px;min-width:0}
|
||||
html[data-organizations] .tb-col.is-over{outline:2px dashed var(--action);outline-offset:-2px}
|
||||
html[data-organizations] .tb-col-h{display:flex;align-items:center;gap:8px;padding:2px 4px}
|
||||
html[data-organizations] .tb-col-h h3{margin:0;font-size:13px;font-weight:700;color:var(--strong)}
|
||||
html[data-organizations] .tb-col-h .n{font-size:12px;font-weight:600;color:var(--muted)}
|
||||
html[data-organizations] .tb-col-h .mix{margin-left:auto;display:flex;gap:2px}
|
||||
html[data-organizations] .tb-colnote,html[data-organizations] .tb-empty{margin:0;padding:2px 4px;font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .tb-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-table);padding:8px 10px 8px 12px;display:grid;gap:5px;min-width:0;box-shadow:inset 3px 0 0 var(--org)}
|
||||
html[data-organizations] .tb-card[draggable=true]{cursor:grab}
|
||||
html[data-organizations] .tb-card.is-dragging{opacity:.5}
|
||||
html[data-organizations] .tb-card .top{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .tb-card .top .id{font:500 11.5px var(--mono);color:var(--muted)}
|
||||
html[data-organizations] .tb-card .top .tb-trk{margin-left:auto}
|
||||
html[data-organizations] .tb-card .tt{color:var(--strong);font-weight:600;font-size:13px;line-height:1.35;min-width:0}
|
||||
html[data-organizations] .tb-card .bot{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);flex-wrap:wrap}
|
||||
html[data-organizations] .tb-card.dim .tt{color:var(--text)}
|
||||
html[data-organizations] .tb-open{all:unset;cursor:pointer;overflow-wrap:anywhere}
|
||||
html[data-organizations] .tb-open:hover{text-decoration:underline}
|
||||
html[data-organizations] .tb-open:focus-visible{outline:2px solid var(--action);outline-offset:2px;border-radius:2px}
|
||||
html[data-organizations] .tb-who{display:inline-flex;align-items:center;gap:4px}
|
||||
html[data-organizations] .tb-chip{display:inline-flex;align-items:center;font-size:11.5px;font-weight:600;padding:0 6px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap}
|
||||
html[data-organizations] .tb-chip.warn{background:color-mix(in srgb,var(--warning) 16%,var(--surface));color:var(--warning)}
|
||||
|
||||
/* List. */
|
||||
html[data-organizations] .tb-row td:first-child{box-shadow:inset 3px 0 0 var(--org)}
|
||||
html[data-organizations] .tb-row.dim td{color:var(--muted)}
|
||||
html[data-organizations] .tb-list .tb-open.t1{display:inline;color:var(--strong)}
|
||||
html[data-organizations] .tb-list .t2.show-m{display:none}
|
||||
html[data-organizations] .tb-move{font:500 13px/1.4 var(--font);color:var(--strong);background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:2px 4px;min-height:0}
|
||||
|
||||
/* The create and edit dialog (on <body>, outside the repainted view). */
|
||||
html[data-organizations] .tb-dialog{border:1px solid var(--line);border-radius:12px;background:var(--surface);color:var(--text);padding:0;width:min(520px,calc(100vw - 32px));max-height:calc(100vh - 48px);box-shadow:0 12px 40px rgba(0,0,0,.25)}
|
||||
html[data-organizations] .tb-dialog::backdrop{background:rgba(0,0,0,.35)}
|
||||
html[data-organizations] .tb-form{display:grid;gap:10px;padding:18px 20px;font-size:14px}
|
||||
html[data-organizations] .tb-form h2{margin:0;font-size:17px;font-weight:700;color:var(--strong);overflow-wrap:anywhere}
|
||||
html[data-organizations] .tb-form label{display:grid;gap:4px;font-size:12.5px;font-weight:600;color:var(--strong)}
|
||||
html[data-organizations] .tb-form input:not([type=checkbox]),html[data-organizations] .tb-form select{font:14px/1.4 var(--font);color:var(--strong);background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:5px 8px;min-width:0}
|
||||
html[data-organizations] .tb-row2{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
|
||||
html[data-organizations] .tb-roles{border:1px solid var(--line);border-radius:var(--r);padding:6px 10px 8px;margin:0;display:flex;flex-wrap:wrap;gap:4px 14px}
|
||||
html[data-organizations] .tb-roles legend{font-size:12.5px;font-weight:600;color:var(--strong);padding:0 4px}
|
||||
html[data-organizations] .tb-roles label{display:inline-flex;align-items:center;gap:6px;font-weight:500;color:var(--text)}
|
||||
html[data-organizations] .tb-field-note,html[data-organizations] .tb-facts{margin:0;font-size:12.5px;color:var(--muted)}
|
||||
html[data-organizations] .tb-facts{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
|
||||
html[data-organizations] .tb-dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:4px 14px;margin:0;font-size:13px}
|
||||
html[data-organizations] .tb-dl dt{color:var(--muted)}
|
||||
html[data-organizations] .tb-dl dd{margin:0;color:var(--strong)}
|
||||
html[data-organizations] .tb-form-status{margin:0;font-size:13px;color:var(--danger);font-weight:600}
|
||||
html[data-organizations] .tb-form-status:empty{display:none}
|
||||
html[data-organizations] .tb-actions{display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end;align-items:center}
|
||||
html[data-organizations] .tb-gap{flex:1}
|
||||
html[data-organizations] .tb-form .ws-btn.primary{background:var(--action);color:var(--onAction);border-color:var(--action)}
|
||||
html[data-organizations] .tb-form .ws-btn.primary:hover:not(:disabled){background:color-mix(in srgb,var(--action) 88%,var(--strong))}
|
||||
html[data-organizations] .tb-form .ws-btn.danger{color:var(--danger)}
|
||||
|
||||
@media (max-width:759px){
|
||||
html[data-organizations] .tb-board{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .tb-right{margin-left:0;flex:1 1 100%}
|
||||
html[data-organizations] .tb-search{min-width:0;flex:1}
|
||||
html[data-organizations] .tb-list .t2.show-m{display:block}
|
||||
html[data-organizations] .tb-row2{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
|
||||
html[data-organizations] .tb-sel select{max-width:11em}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
// U4b (#1873, PRD 1.20): one task board across every organization (sample 06).
|
||||
//
|
||||
// Reads: GET /api/workspace?sections=tasks,projects, where each org's tasks come from its own tracker
|
||||
// module (the built-in store or Vikunja), merged and tagged with the org. The project, state, assignee
|
||||
// and label filters go to the server, which applies them before its per-org cap, so a filter reaches
|
||||
// every task an org holds; the org chips, the search, Nobody and No project narrow in the page. An org whose bus is down,
|
||||
// slow or invalid keeps its U1 state on the Trackers line and the others still render.
|
||||
// Writes: POST /api/workspace/tasks, one org per request, through that org's bus and its person
|
||||
// verbs (tasks.create, tasks.edit, tasks.archive). The bus runs its own human check; a refusal is
|
||||
// shown as the bus answered it. Vikunja tasks are read only here: the bus refuses them
|
||||
// (tracker-not-store), so the board offers no control for them.
|
||||
// bus.js calls view() for #/tasks in organization mode and repaints it on its own 10 s poll.
|
||||
(() => {
|
||||
'use strict';
|
||||
if (!document.documentElement.hasAttribute('data-organizations')) return;
|
||||
const data = window.MosaicWorkspaceData;
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const LAYOUT = 'mosaic-tasks-layout-v1';
|
||||
const load = key => { try { return localStorage.getItem(key); } catch { return null; } };
|
||||
const save = (key, value) => { try { localStorage.setItem(key, value); } catch {} };
|
||||
const STATES = [['todo', 'To do'], ['in-progress', 'In progress'], ['in-review', 'Review'], ['blocked', 'Blocked'], ['done', 'Done']];
|
||||
const STATE_LABEL = Object.fromEntries(STATES);
|
||||
const UNREAD = 'unread';
|
||||
const REQUIREMENT = '^REQ-[A-Z]+-[1-9][0-9]*$';
|
||||
const ORG_STATE = { down: ['s-offline', '○', 'bus down'], timeout: ['s-waiting', '◐', 'no answer in time'], invalid: ['s-error', '▲', 'invalid answer'], starting: ['s-waiting', '◐', 'starting'] };
|
||||
const phone = () => window.matchMedia('(max-width:759px)').matches;
|
||||
|
||||
// The layout is remembered per viewer and per width: a phone opens on List until its viewer picks
|
||||
// Kanban, a wide screen on Kanban until its viewer picks List.
|
||||
const layouts = (() => { try { const v = JSON.parse(load(LAYOUT) || '{}'); return v && typeof v === 'object' ? v : {}; } catch { return {}; } })();
|
||||
const layoutNow = () => { const v = layouts[phone() ? 'phone' : 'wide']; return v === 'kanban' || v === 'list' ? v : phone() ? 'list' : 'kanban'; };
|
||||
const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '' };
|
||||
// `opened` is the task as the edit dialog showed it: Save diffs against it and sends its digest, so a
|
||||
// poll that reads a newer task while the dialog is open turns a stale save into a task-conflict.
|
||||
let snap = null, canWrite = false, notice = null, dragging = null, dragEnded = null, opened = null;
|
||||
|
||||
const mark = () => window.MosaicWorkspace?.orgMark;
|
||||
const orgOf = id => snap?.orgs.find(o => o.id === id) || { id, label: id, theme: null };
|
||||
const av = (id, size = '') => mark()?.av(orgOf(id), size) ?? '';
|
||||
const colours = id => mark()?.colours(orgOf(id)) ?? '';
|
||||
const icon = id => window.MosaicWorkspace?.icon(id) ?? '';
|
||||
const key = t => `${t.org}|${t.ref}`;
|
||||
// Ids keep their tracker's form: #N for the built-in store, V-N for Vikunja.
|
||||
const shortId = ref => { let m = /^task:(\d+)$/.exec(ref); if (m) return `#${m[1]}`; m = /^vikunja:\d+\/(\d+)$/.exec(ref); return m ? `V-${m[1]}` : ref; };
|
||||
const trk = tracker => (tracker === 'vikunja' ? '<span class="tb-trk vik">Vikunja</span>' : '<span class="tb-trk">built-in</span>');
|
||||
const stateOf = t => (STATE_LABEL[t.state] ? t.state : UNREAD);
|
||||
// An org can answer while its tasks read fails (errors.tasks): its tasks are unavailable, not zero.
|
||||
const tasksRead = o => o?.state === 'ok' && !o.errors?.tasks;
|
||||
const orgWritable = o => canWrite && tasksRead(o) && o.tracker === 'built-in';
|
||||
const taskWritable = t => t.writable === true && typeof t.digest === 'string' && orgWritable(orgOf(t.org));
|
||||
const projectName = t => t.project?.name || t.project?.key || null;
|
||||
const dueText = iso => {
|
||||
const d = new Date(iso);
|
||||
if (!Number.isFinite(d.getTime())) return null;
|
||||
const opts = { day: 'numeric', month: 'short', timeZone: 'UTC', ...(d.getUTCFullYear() !== new Date().getUTCFullYear() ? { year: 'numeric' } : {}) };
|
||||
return d.toLocaleDateString('en-GB', opts);
|
||||
};
|
||||
const overdue = t => t.dueDate && t.state !== 'done' && Date.parse(t.dueDate) < Date.now();
|
||||
|
||||
// ---------- reads ----------
|
||||
const PROJECT_SELECTOR = /^[a-z0-9][a-z0-9._-]{0,63}:[A-Za-z0-9._-]{1,128}$/, ROLE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
function readQuery() {
|
||||
const q = new URLSearchParams({ sections: 'tasks,projects' });
|
||||
if (PROJECT_SELECTOR.test(filter.project)) q.set('project', filter.project);
|
||||
if (filter.state) q.set('task-state', filter.state === UNREAD ? 'open' : filter.state);
|
||||
if (ROLE.test(filter.assignee)) q.set('assignee', filter.assignee);
|
||||
if (/^[1-9]\d{0,14}$/.test(filter.label)) q.set('label', filter.label);
|
||||
return q.toString();
|
||||
}
|
||||
async function read() {
|
||||
const [response, list] = await Promise.all([
|
||||
fetch(`/api/workspace?${readQuery()}`, { cache: 'no-store', redirect: 'manual' }),
|
||||
data.organizations().then(b => (Array.isArray(b) ? b : null)).catch(() => null),
|
||||
mark()?.ready().catch(() => null),
|
||||
window.MosaicConsole?.ready,
|
||||
]);
|
||||
canWrite = window.MosaicConsole?.config?.orgSetup === true;
|
||||
if (response.type === 'opaqueredirect' || !response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
throw Object.assign(new Error('read failed'), { status: response.status, message: typeof body?.message === 'string' ? body.message : null });
|
||||
}
|
||||
const body = await response.json();
|
||||
if (!Array.isArray(body?.orgs) || !Array.isArray(body.tasks) || !Array.isArray(body.projects)) throw new Error('read failed');
|
||||
const named = new Map((list || []).map(o => [o.id, o]));
|
||||
const orgs = body.orgs.map(o => ({ ...o, id: o.org, label: named.get(o.org)?.label || o.org, theme: named.get(o.org)?.theme ?? o.theme ?? null, roles: Array.isArray(o.roles) ? o.roles : [] }));
|
||||
return { orgs, tasks: body.tasks, projects: body.projects, summary: body.summary, readAt: body.readAt };
|
||||
}
|
||||
// Only the latest read lands: a filter change and the poll can both be reading.
|
||||
let readSeq = 0;
|
||||
const reload = async () => { const n = ++readSeq, next = await read(); if (n !== readSeq) return false; snap = next; prune(); return true; };
|
||||
async function refilter() { paint(); try { if (await reload()) paint(); } catch { /* the next poll shows the failed read */ } }
|
||||
// A filter naming an org or project that is gone falls back to all.
|
||||
function prune() {
|
||||
if (filter.org && !snap.orgs.some(o => o.id === filter.org)) filter.org = '';
|
||||
if (filter.project && filter.project !== '-' && !snap.projects.some(p => `${p.org}:${p.id}` === filter.project)) filter.project = '';
|
||||
}
|
||||
|
||||
// ---------- filters ----------
|
||||
function filtered({ q = true } = {}) {
|
||||
const needle = filter.q.trim().toLowerCase();
|
||||
return snap.tasks.filter(t => (!filter.org || t.org === filter.org)
|
||||
&& (!filter.project || (filter.project === '-' ? !t.project : `${t.org}:${t.project?.id}` === filter.project))
|
||||
&& (!filter.assignee || (filter.assignee === '-' ? !t.assignees.length : t.assignees.includes(filter.assignee)))
|
||||
&& (!filter.state || stateOf(t) === filter.state)
|
||||
&& (!filter.label || t.labels.map(String).includes(filter.label))
|
||||
&& (!q || !needle || `${t.title ?? ''} ${shortId(t.ref)} ${projectName(t) ?? ''}`.toLowerCase().includes(needle)));
|
||||
}
|
||||
const select = (name, label, options, focus) => `<label class="tb-sel"><span class="ws-label">${esc(label)}</span><select data-tb-filter="${name}" data-focus-key="${focus}">${options}</select></label>`;
|
||||
const opt = (value, text, current) => `<option value="${esc(value)}"${value === current ? ' selected' : ''}>${esc(text)}</option>`;
|
||||
function toolbar() {
|
||||
const counts = new Map(snap.orgs.map(o => [o.id, 0]));
|
||||
for (const t of snap.tasks) counts.set(t.org, (counts.get(t.org) ?? 0) + 1);
|
||||
const chip = (id, inner, label) => `<button type="button" class="tb-f${id ? '' : ' plain'}" data-tb-org="${esc(id)}" aria-pressed="${filter.org === id}" data-focus-key="tb-org:${esc(id)}"${label ? ` aria-label="${esc(label)}"` : ''}>${inner}</button>`;
|
||||
const chips = [chip('', `All <span class="n">${snap.tasks.length}</span>`)].concat(snap.orgs.map(o => chip(o.id,
|
||||
`${av(o.id)}${esc(o.label)}<span class="n">${tasksRead(o) ? counts.get(o.id) : 'unavailable'}</span>`,
|
||||
`${o.label}, ${tasksRead(o) ? `${counts.get(o.id)} tasks` : 'tasks unavailable'}`))).join('');
|
||||
const scope = snap.orgs.filter(o => !filter.org || o.id === filter.org);
|
||||
const projectOpts = opt('', `All ${snap.projects.filter(p => !filter.org || p.org === filter.org).length}`, filter.project) + opt('-', 'No project', filter.project)
|
||||
+ scope.map(o => { const ps = snap.projects.filter(p => p.org === o.id); return ps.length ? `<optgroup label="${esc(o.label)}">${ps.map(p => opt(`${p.org}:${p.id}`, p.name || p.key || p.id, filter.project)).join('')}</optgroup>` : ''; }).join('');
|
||||
const roles = [...new Set(scope.flatMap(o => o.roles).concat(snap.tasks.filter(t => !filter.org || t.org === filter.org).flatMap(t => t.assignees)))].sort();
|
||||
if (filter.assignee && filter.assignee !== '-' && !roles.includes(filter.assignee)) roles.push(filter.assignee);
|
||||
const assigneeOpts = opt('', 'Anyone', filter.assignee) + opt('-', 'Nobody', filter.assignee) + roles.map(r => opt(r, r, filter.assignee)).join('');
|
||||
const stateOpts = opt('', 'Any', filter.state) + STATES.map(([v, l]) => opt(v, l, filter.state)).join('') + (snap.tasks.some(t => stateOf(t) === UNREAD) ? opt(UNREAD, 'State not read', filter.state) : '');
|
||||
const labels = [...new Set(snap.tasks.flatMap(t => t.labels.map(String)))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
const labelSel = labels.length ? select('label', 'Label', opt('', 'Any', filter.label) + labels.map(l => opt(l, l, filter.label)).join(''), 'tb-label') : '';
|
||||
return `<div class="tb-toolbar">
|
||||
<div class="tb-chips" role="group" aria-label="Organization">${chips}</div>
|
||||
${select('project', 'Project', projectOpts, 'tb-project')}${select('assignee', 'Assignee', assigneeOpts, 'tb-assignee')}${select('state', 'State', stateOpts, 'tb-state')}${labelSel}
|
||||
<div class="tb-right"><label class="tb-search">${icon('search')}<input type="search" placeholder="Find a task" aria-label="Find a task" data-tb-q data-focus-key="tb-q" value="${esc(filter.q)}" autocomplete="off"></label></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---------- results ----------
|
||||
const who = t => (t.assignees.length ? `<span class="tb-who">${icon('people')}${esc(t.assignees.join(', '))}</span>` : '');
|
||||
const prio = t => (Number.isSafeInteger(t.priority) && t.priority > 0 ? `<span class="tb-chip${t.priority >= 4 ? ' warn' : ''}" title="Priority ${t.priority} of 5">P${t.priority}</span>` : '');
|
||||
const due = t => { const d = t.dueDate && dueText(t.dueDate); return d ? `<span class="tb-chip${overdue(t) ? ' warn' : ''}" title="Due ${esc(t.dueDate.slice(0, 10))} (UTC)">${overdue(t) ? 'overdue' : 'due'} ${esc(d)}</span>` : ''; };
|
||||
const title = t => esc(t.title || 'Untitled');
|
||||
function card(t) {
|
||||
const w = taskWritable(t), k = esc(key(t));
|
||||
return `<article class="tb-card ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${k}" data-org="${esc(t.org)}"${w ? ' draggable="true"' : ''}>
|
||||
<div class="top">${av(t.org, 'xs')}<span class="ws-sr">${esc(orgOf(t.org).label)}</span><span class="id">${esc(shortId(t.ref))}</span>${trk(t.tracker)}</div>
|
||||
<div class="tt"><button type="button" class="tb-open" data-tb-open="${k}" data-focus-key="open:${k}">${title(t)}</button></div>
|
||||
<div class="bot"><span>${projectName(t) ? esc(projectName(t)) : '<span class="muted">No project</span>'}</span>${who(t)}${prio(t)}${due(t)}</div>
|
||||
</article>`;
|
||||
}
|
||||
function kanban(rows) {
|
||||
const cols = STATES.concat(rows.some(t => stateOf(t) === UNREAD) ? [[UNREAD, 'State not read']] : []);
|
||||
return `<div class="tb-board" style="--tb-cols:${cols.length}" data-tb-kanban>${cols.map(([v, l]) => {
|
||||
const inCol = rows.filter(t => stateOf(t) === v), orgs = [...new Set(inCol.map(t => t.org))];
|
||||
const drop = v !== UNREAD ? ` data-tb-col="${v}"` : '';
|
||||
return `<section class="tb-col"${drop} aria-label="${esc(l)}"><div class="tb-col-h"><h3>${esc(l)}</h3><span class="n">${inCol.length}</span>${orgs.length ? `<span class="mix" title="Organizations in this column">${orgs.map(o => av(o, 'xs')).join('')}</span>` : ''}</div>
|
||||
${v === UNREAD ? '<p class="tb-colnote">Vikunja does not tell the bus which bucket an open task is in.</p>' : ''}${inCol.map(card).join('') || '<p class="tb-empty">No tasks</p>'}</section>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
const order = t => { const i = STATES.findIndex(([v]) => v === t.state); return i < 0 ? STATES.length : i; };
|
||||
function list(rows) {
|
||||
const sorted = [...rows].sort((a, b) => order(a) - order(b) || a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref, undefined, { numeric: true }));
|
||||
const stateCell = t => {
|
||||
if (!taskWritable(t)) return esc(STATE_LABEL[t.state] || 'Not read');
|
||||
return `<select class="tb-move" data-tb-move="${esc(key(t))}" data-focus-key="move:${esc(key(t))}" aria-label="${esc(`State of ${shortId(t.ref)}, ${t.title || 'Untitled'}`)}">${STATES.map(([v, l]) => opt(v, l, t.state)).join('')}</select>`;
|
||||
};
|
||||
return `<div class="ws-tbl tb-list" data-tb-list><table><thead><tr><th scope="col">Org</th><th scope="col">Id</th><th scope="col">Task</th><th scope="col" class="hide-m">Project</th><th scope="col">State</th><th scope="col" class="hide-m">Assignee</th><th scope="col" class="hide-m">Priority</th><th scope="col" class="hide-m">Due</th></tr></thead><tbody>
|
||||
${sorted.map(t => `<tr class="tb-row ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${esc(key(t))}" data-org="${esc(t.org)}">
|
||||
<td>${av(t.org)}<span class="ws-sr">${esc(orgOf(t.org).label)}</span></td>
|
||||
<td class="mono">${esc(shortId(t.ref))}<span class="t2">${t.tracker === 'vikunja' ? 'Vikunja' : 'built-in'}</span></td>
|
||||
<td><button type="button" class="tb-open t1" data-tb-open="${esc(key(t))}" data-focus-key="lopen:${esc(key(t))}">${title(t)}</button><span class="t2 show-m">${esc(projectName(t) || 'No project')}</span></td>
|
||||
<td class="hide-m">${projectName(t) ? esc(projectName(t)) : '<span class="muted">none</span>'}</td>
|
||||
<td>${stateCell(t)}</td>
|
||||
<td class="hide-m">${t.assignees.length ? esc(t.assignees.join(', ')) : '<span class="muted">nobody</span>'}</td>
|
||||
<td class="hide-m">${prio(t) || '<span class="muted">none</span>'}</td>
|
||||
<td class="hide-m">${due(t) || '<span class="muted">none</span>'}</td>
|
||||
</tr>`).join('')}</tbody></table></div>`;
|
||||
}
|
||||
function results() {
|
||||
const rows = filtered(), all = snap.tasks.length;
|
||||
// Project, state, assignee and label filter on the server, so the board never holds the unfiltered
|
||||
// total; with one of those on, the line says how many match rather than inventing an "of N".
|
||||
const onServer = new URLSearchParams(readQuery()).size > 1, n = rows.length;
|
||||
const line = onServer ? `<p class="tb-count">Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.</p>`
|
||||
: n !== all ? `<p class="tb-count">Showing ${n} of ${all} tasks.</p>` : '';
|
||||
// An empty result is only exhaustive when every organization's tasks were read.
|
||||
const unread = snap.orgs.filter(o => !tasksRead(o));
|
||||
const none = what => (unread.length ? `${what} in the organizations that answered. Tasks from ${unread.map(o => o.label).join(', ')} could not be read.` : `${what}.`);
|
||||
const body = !snap.orgs.length ? '<p class="ws-panel tight note">No organizations yet.</p>'
|
||||
: !snap.orgs.some(tasksRead) ? '<p class="ws-panel tight note">No organization answered with its tasks, so no tasks could be read.</p>'
|
||||
: !all && !onServer ? `<p class="ws-panel tight note">${esc(none('No tasks yet'))}</p>`
|
||||
: !rows.length ? `<p class="ws-panel tight note">${esc(none('No tasks match these filters'))}</p>`
|
||||
: layoutNow() === 'list' ? list(rows) : kanban(rows);
|
||||
return `<div data-tb-results>${line}${body}</div>`;
|
||||
}
|
||||
|
||||
// ---------- page ----------
|
||||
function trackers() {
|
||||
const items = snap.orgs.map(o => {
|
||||
const s = ORG_STATE[o.state] ?? (o.state === 'ok' && o.errors?.tasks ? ['s-error', '▲', 'tasks not read'] : null);
|
||||
const why = ORG_STATE[o.state] ? o.reason : o.errors?.tasks;
|
||||
const st = s ? ` <span class="ws-dot ${s[0]}"><b aria-hidden="true">${s[1]}</b>${esc(s[2])}</span>${why ? `<span class="tb-reason">${esc(why)}</span>` : ''}` : '';
|
||||
const cut = o.tasksHeld ? `<span class="tb-reason">shows the 500 most recently changed of ${esc(o.tasksHeld)} matching tasks</span>` : '';
|
||||
return `<span class="tb-tracker${s ? ' is-down' : ''}" data-tb-tracker="${esc(o.id)}" data-state="${esc(o.state)}">${av(o.id)}${esc(o.label)} ${o.tracker ? trk(o.tracker) : ''}${st}${cut}</span>`;
|
||||
}).join('');
|
||||
return `<div class="tb-trackers"><span class="ws-label">Trackers</span>${items}<span class="note">Ids keep their tracker's form: V-12 is Vikunja, #41 is built in.</span></div>`;
|
||||
}
|
||||
function head() {
|
||||
const lay = layoutNow();
|
||||
const writableOrgs = snap ? snap.orgs.filter(orgWritable) : [];
|
||||
const why = !canWrite ? 'This Console takes no task changes.' : !writableOrgs.length ? 'No organization with a built-in task store answered.' : '';
|
||||
const seg = `<span class="ws-seg" role="group" aria-label="Layout"><button type="button" data-tb-layout="kanban" aria-pressed="${lay === 'kanban'}" data-focus-key="tb-layout-kanban">Kanban</button><button type="button" data-tb-layout="list" aria-pressed="${lay === 'list'}" data-focus-key="tb-layout-list">List</button></span>`;
|
||||
const add = `<button type="button" class="ws-btn" data-tb-new data-focus-key="tb-new"${why ? ` disabled title="${esc(why)}"` : ''}>${icon('plus')}New task</button>`;
|
||||
let sub = '';
|
||||
if (snap) {
|
||||
const n = snap.tasks.length, m = snap.orgs.filter(tasksRead).length, of = snap.summary?.of ?? snap.orgs.length;
|
||||
sub = `${n} ${n === 1 ? 'task' : 'tasks'} from ${m} of ${of} ${of === 1 ? 'organization' : 'organizations'}. Each organization keeps its own tasks; a change made here goes only to that organization's bus.`;
|
||||
if (snap.orgs.some(o => o.tracker === 'vikunja')) sub += ' Vikunja tasks are read only here.';
|
||||
if (!canWrite) sub += ' This Console takes no task changes, so the board is read only.';
|
||||
}
|
||||
return `<div class="ws-vh"><h1 tabindex="-1">Tasks</h1><div class="ws-right">${seg}${add}</div>${sub ? `<p class="sub">${sub}</p>` : ''}</div>`;
|
||||
}
|
||||
const noticeLine = () => (notice ? `<p class="tb-notice${notice.error ? ' is-error' : ''}" role="${notice.error ? 'alert' : 'status'}" data-tb-notice>${esc(notice.text)}</p>` : '<p class="tb-notice" role="status" data-tb-notice></p>');
|
||||
function markup() {
|
||||
return `<section class="ws tb" data-tasks-board data-layout="${layoutNow()}">${head()}${noticeLine()}${trackers()}${toolbar()}${results()}</section>`;
|
||||
}
|
||||
const failed = err => `<section class="ws tb" data-tasks-board><div class="ws-vh"><h1 tabindex="-1">Tasks</h1><p class="sub">The task board could not be read${err?.message && err.message !== 'read failed' ? `: ${esc(err.message)}` : ''}. It tries again on the next refresh.</p></div></section>`;
|
||||
|
||||
// Kept across a repaint: focus by data-focus-key, the search caret and the sideways scroll of the
|
||||
// board and the list. bus.js restores focus by key; the caret and scroll are put back here.
|
||||
function snapshotView(root) {
|
||||
const a = document.activeElement;
|
||||
return {
|
||||
focus: root && root.contains(a) ? a.dataset?.focusKey || null : null,
|
||||
caret: root && root.contains(a) && a.matches?.('[data-tb-q]') ? [a.selectionStart, a.selectionEnd] : null,
|
||||
scroll: root ? [...root.querySelectorAll('.tb-board,.tb-list')].map(e => [e.matches('.tb-board') ? 'board' : 'list', e.scrollLeft]) : [],
|
||||
};
|
||||
}
|
||||
// With focus false (after bus.js's own repaint, which puts focus back by key) the caret is put back
|
||||
// only if focus is still on the search box it was taken from.
|
||||
function putBack(root, kept, { focus = true } = {}) {
|
||||
if (!root || !kept) return;
|
||||
for (const [which, left] of kept.scroll) { const e = root.querySelector(which === 'board' ? '.tb-board' : '.tb-list'); if (e) e.scrollLeft = left; }
|
||||
const el = kept.focus ? root.querySelector(`[data-focus-key="${CSS.escape(kept.focus)}"]`) : null;
|
||||
if (focus && el && document.activeElement !== el) el.focus({ preventScroll: true });
|
||||
if (el && document.activeElement === el && kept.caret && el.matches('[data-tb-q]')) el.setSelectionRange(kept.caret[0], kept.caret[1]);
|
||||
}
|
||||
// Repaint in place, for the board's own changes (filters, layout, a write).
|
||||
function paint(part = 'all') {
|
||||
const root = document.querySelector('[data-tasks-board]');
|
||||
if (!root || !snap) return;
|
||||
const kept = snapshotView(root);
|
||||
if (part === 'results') root.querySelector('[data-tb-results]').outerHTML = results();
|
||||
else root.outerHTML = markup();
|
||||
putBack(document.querySelector('[data-tasks-board]'), kept);
|
||||
}
|
||||
|
||||
// bus.js's view for #/tasks. A drag in progress holds the poll's repaint until it ends.
|
||||
async function view() {
|
||||
if (dragging) await Promise.race([dragEnded, new Promise(r => setTimeout(r, 30000))]);
|
||||
try { await reload(); } catch (err) { snap = null; return failed(err); }
|
||||
// Taken after the read, just before bus.js replaces the view, so focus, caret or scroll moved while
|
||||
// the read was in flight is what is kept. bus.js puts focus back by key; this puts back the rest.
|
||||
const kept = snapshotView(document.querySelector('[data-tasks-board]'));
|
||||
setTimeout(() => putBack(document.querySelector('[data-tasks-board]'), kept, { focus: false }), 0);
|
||||
return markup();
|
||||
}
|
||||
|
||||
// ---------- writes ----------
|
||||
const UNKNOWN = Object.freeze({ ok: false, error: 'outcome-unknown', unknown: true, message: 'The Console\'s answer could not be read. The change may or may not have been written; the board shows it on its next read.' });
|
||||
async function write(body) {
|
||||
let response, answer = null;
|
||||
try {
|
||||
response = await fetch('/api/workspace/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });
|
||||
answer = await response.json().catch(() => null);
|
||||
} catch { return UNKNOWN; }
|
||||
if (response.type === 'opaqueredirect') return { ok: false, error: 'signed-out', message: 'The Console asked to sign in again. Nothing was written.' };
|
||||
if (response.ok && answer?.ok) return answer;
|
||||
// A refusal names its code. An answer that cannot be read, or one that says the outcome is open,
|
||||
// is not a refusal: the change may have reached the bus.
|
||||
if (typeof answer?.error !== 'string' || answer.sent === 'unknown') return { ...UNKNOWN, message: answer?.sent === 'unknown' && answer.message ? answer.message : UNKNOWN.message };
|
||||
return { ok: false, error: answer.error, message: answer.message || `The Console refused the change (${response.status}). Nothing was written.` };
|
||||
}
|
||||
const find = k => snap?.tasks.find(t => key(t) === k) || null;
|
||||
async function move(k, state) {
|
||||
const t = find(k);
|
||||
if (!t || !taskWritable(t) || t.state === state) return;
|
||||
notice = { text: `Moving ${shortId(t.ref)} to ${STATE_LABEL[state]}…` }; paint();
|
||||
const r = await write({ op: 'edit', org: t.org, task_ref: t.ref, expect: t.digest, state });
|
||||
notice = r.ok ? { text: `${shortId(t.ref)} moved to ${STATE_LABEL[state]} in ${orgOf(t.org).label}.` } : { error: true, text: r.unknown ? `${shortId(t.ref)}: ${r.message}` : `${shortId(t.ref)} was not moved. ${r.message}` };
|
||||
try { await reload(); } catch {}
|
||||
paint();
|
||||
}
|
||||
|
||||
// ---------- dialog (create and edit) ----------
|
||||
function dialogEl() {
|
||||
let d = document.getElementById('tb-dialog');
|
||||
if (!d) {
|
||||
d = document.createElement('dialog');
|
||||
d.id = 'tb-dialog'; d.className = 'tb-dialog'; d.setAttribute('aria-labelledby', 'tb-dialog-h');
|
||||
document.body.append(d);
|
||||
d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
|
||||
}
|
||||
return d;
|
||||
}
|
||||
const dateValue = iso => (typeof iso === 'string' && /^\d{4}-\d\d-\d\d/.test(iso) ? iso.slice(0, 10) : '');
|
||||
const roleBoxes = (roles, picked) => (roles.length
|
||||
? `<fieldset class="tb-roles"><legend>Assignees</legend>${roles.map(r => `<label><input type="checkbox" name="assignees" value="${esc(r)}"${picked.includes(r) ? ' checked' : ''}>${esc(r)}</label>`).join('')}</fieldset>`
|
||||
: '<p class="tb-field-note">Assignees: this organization reports no roles.</p>');
|
||||
const prioSelect = p => `<label>Priority<select name="priority">${[0, 1, 2, 3, 4, 5].map(n => `<option value="${n}"${n === (p ?? 0) ? ' selected' : ''}>${n ? `P${n}` : 'None'}</option>`).join('')}</select></label>`;
|
||||
const stateSelect = s => `<label>State<select name="state">${STATES.map(([v, l]) => `<option value="${v}"${v === s ? ' selected' : ''}>${l}</option>`).join('')}</select></label>`;
|
||||
function projectOptions(org, current) {
|
||||
return `<option value="">No project</option>${snap.projects.filter(p => p.org === org).map(p => `<option value="${esc(p.id)}"${p.id === current ? ' selected' : ''}>${esc(p.name || p.key || p.id)}</option>`).join('')}`;
|
||||
}
|
||||
function createForm(orgId) {
|
||||
const orgs = snap.orgs.filter(orgWritable), org = orgs.find(o => o.id === orgId) || orgs[0];
|
||||
return `<form method="dialog" class="tb-form" data-tb-form="create">
|
||||
<h2 id="tb-dialog-h">New task</h2>
|
||||
<label>Organization<select name="org" data-tb-create-org>${orgs.map(o => `<option value="${esc(o.id)}"${o.id === org.id ? ' selected' : ''}>${esc(o.label)}</option>`).join('')}</select></label>
|
||||
<label>Project<select name="group">${projectOptions(org.id, filter.project.startsWith(`${org.id}:`) ? filter.project.slice(org.id.length + 1) : '')}</select></label>
|
||||
<label>Title<input name="title" required maxlength="250" autocomplete="off"></label>
|
||||
<label>Requirement<input name="requirement" required pattern="${REQUIREMENT}" placeholder="REQ-WEB-1" autocomplete="off" aria-describedby="tb-req-note"></label>
|
||||
<p class="tb-field-note" id="tb-req-note">The requirement this task serves, written as REQ-AREA-N.</p>
|
||||
<div class="tb-row2">${stateSelect('todo')}${prioSelect(0)}<label>Due date<input type="date" name="due_date"></label></div>
|
||||
<div data-tb-roles>${roleBoxes(org.roles, [])}</div>
|
||||
<p class="tb-form-status" role="alert" data-tb-form-status></p>
|
||||
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Create task</button></div>
|
||||
</form>`;
|
||||
}
|
||||
function editForm(t) {
|
||||
const o = orgOf(t.org), w = taskWritable(t);
|
||||
const why = t.tracker === 'vikunja' ? 'This task is in Vikunja. The board reads it but does not change it; change it in Vikunja.'
|
||||
: !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.' : !w ? 'The bus did not give this task a version to check a change against.' : '';
|
||||
const facts = `<p class="tb-facts">${av(t.org, 'xs')}${esc(o.label)} · <span class="mono">${esc(shortId(t.ref))}</span> ${trk(t.tracker)}${t.requirement ? ` · <span class="mono">${esc(t.requirement)}</span>` : ''}${projectName(t) ? ` · ${esc(projectName(t))}` : ''}</p>`;
|
||||
if (!w) {
|
||||
return `<div class="tb-form" data-tb-form="view"><h2 id="tb-dialog-h">${title(t)}</h2>${facts}
|
||||
<dl class="tb-dl"><dt>State</dt><dd>${esc(STATE_LABEL[t.state] || 'Not read')}</dd><dt>Assignees</dt><dd>${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}</dd><dt>Priority</dt><dd>${prio(t) || 'none'}</dd><dt>Due</dt><dd>${due(t) || 'none'}</dd></dl>
|
||||
<p class="tb-field-note">${esc(why)}</p>
|
||||
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
|
||||
}
|
||||
return `<form method="dialog" class="tb-form" data-tb-form="edit" data-tb-key="${esc(key(t))}">
|
||||
<h2 id="tb-dialog-h">Edit ${esc(shortId(t.ref))}</h2>${facts}
|
||||
<label>Title<input name="title" required maxlength="250" value="${esc(t.title ?? '')}" autocomplete="off"></label>
|
||||
<div class="tb-row2">${stateSelect(t.state)}${prioSelect(t.priority)}<label>Due date<input type="date" name="due_date" value="${dateValue(t.dueDate)}"></label></div>
|
||||
${roleBoxes([...new Set([...o.roles, ...t.assignees])].sort(), t.assignees)}
|
||||
<p class="tb-form-status" role="alert" data-tb-form-status></p>
|
||||
<div class="tb-actions"><button type="button" class="ws-btn danger" data-tb-archive>Archive</button><span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Save</button></div>
|
||||
</form>`;
|
||||
}
|
||||
function openDialog(html, back, task = null) {
|
||||
const d = dialogEl();
|
||||
d.innerHTML = html; d.dataset.return = back || ''; opened = task ? structuredClone(task) : null;
|
||||
if (!d.open) d.showModal();
|
||||
d.querySelector('input:not([type=checkbox]),select,button')?.focus();
|
||||
}
|
||||
const formStatus = (form, text) => { const s = form.querySelector('[data-tb-form-status]'); if (s) s.textContent = text; };
|
||||
const due0 = v => (v ? `${v}T00:00:00.000Z` : null);
|
||||
const picked = form => [...form.querySelectorAll('input[name=assignees]:checked')].map(i => i.value).sort();
|
||||
function editBody(form, t) {
|
||||
const f = new FormData(form), body = { op: 'edit', org: t.org, task_ref: t.ref, expect: t.digest };
|
||||
const titleV = String(f.get('title') ?? ''), state = String(f.get('state')), priority = Number(f.get('priority')), dueV = due0(String(f.get('due_date') || '')), who = picked(form);
|
||||
if (titleV !== (t.title ?? '')) body.title = titleV;
|
||||
if (state !== t.state) body.state = state;
|
||||
if (priority !== (t.priority ?? 0)) body.priority = priority;
|
||||
if (dueV !== (t.dueDate ? `${dateValue(t.dueDate)}T00:00:00.000Z` : null)) body.due_date = dueV;
|
||||
if (who.join('\n') !== [...t.assignees].sort().join('\n')) body.assignees = who;
|
||||
return body;
|
||||
}
|
||||
// The competing write may have moved the task out of the board's filters, so the conflict read
|
||||
// asks for its one organization unfiltered. The task was just changed, so the cap keeps it.
|
||||
async function current(t) {
|
||||
try {
|
||||
const r = await fetch(`/api/workspace?${new URLSearchParams({ sections: 'tasks', org: t.org })}`, { cache: 'no-store', redirect: 'manual' });
|
||||
if (r.type === 'opaqueredirect' || !r.ok) return null;
|
||||
const b = await r.json();
|
||||
return (Array.isArray(b?.tasks) && b.tasks.find(x => x.org === t.org && x.ref === t.ref)) || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function submit(form) {
|
||||
const kind = form.dataset.tbForm, btns = form.querySelectorAll('button');
|
||||
if (!form.reportValidity()) return;
|
||||
let body, t = null;
|
||||
if (kind === 'create') {
|
||||
const f = new FormData(form);
|
||||
body = { op: 'create', org: String(f.get('org')), title: String(f.get('title')), requirement: String(f.get('requirement')), state: String(f.get('state')), priority: Number(f.get('priority')), assignees: picked(form) };
|
||||
const group = String(f.get('group') || ''); if (group) body.group = group;
|
||||
const d = due0(String(f.get('due_date') || '')); if (d) body.due_date = d;
|
||||
} else {
|
||||
t = opened && key(opened) === form.dataset.tbKey ? opened : null;
|
||||
if (!t) { formStatus(form, 'The task is no longer on the board. Nothing was written.'); return; }
|
||||
body = editBody(form, t);
|
||||
if (Object.keys(body).length === 4) { formStatus(form, 'Nothing changed.'); return; }
|
||||
}
|
||||
btns.forEach(b => { b.disabled = true; }); formStatus(form, 'Sending…');
|
||||
const r = await write(body);
|
||||
btns.forEach(b => { b.disabled = false; });
|
||||
if (!r.ok) {
|
||||
formStatus(form, r.message);
|
||||
// The task changed under the form: the form shows the current task and keeps the refusal.
|
||||
if (r.error === 'task-conflict' && t) {
|
||||
try { await reload(); } catch {}
|
||||
const now = find(key(t)) || await current(t);
|
||||
if (now) { const msg = r.message; openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, msg); }
|
||||
paint();
|
||||
} else if (r.unknown) { try { await reload(); } catch {} paint(); }
|
||||
return;
|
||||
}
|
||||
const label = orgOf(body.org).label;
|
||||
notice = { text: kind === 'create' ? `${shortId(r.task_ref || '')} created in ${label}.` : `${shortId(t.ref)} saved in ${label}.` };
|
||||
dialogEl().close();
|
||||
try { await reload(); } catch {}
|
||||
paint();
|
||||
}
|
||||
async function archive(form) {
|
||||
const t = opened && key(opened) === form.dataset.tbKey ? opened : null;
|
||||
if (!t) return;
|
||||
if (!form.querySelector('[data-tb-archive]').hasAttribute('data-armed')) {
|
||||
form.querySelector('[data-tb-archive]').setAttribute('data-armed', '');
|
||||
form.querySelector('[data-tb-archive]').textContent = 'Archive: press again to confirm';
|
||||
return;
|
||||
}
|
||||
const r = await write({ op: 'archive', org: t.org, task_ref: t.ref, expect: t.digest });
|
||||
if (!r.ok) { formStatus(form, r.message); if (r.unknown) { try { await reload(); } catch {} paint(); } return; }
|
||||
notice = { text: `${shortId(t.ref)} archived in ${orgOf(t.org).label}.` };
|
||||
dialogEl().close();
|
||||
try { await reload(); } catch {}
|
||||
paint();
|
||||
}
|
||||
|
||||
// ---------- events ----------
|
||||
const inBoard = e => e.target.closest?.('[data-tasks-board]');
|
||||
document.addEventListener('click', e => {
|
||||
const d = e.target.closest?.('#tb-dialog');
|
||||
if (d) {
|
||||
if (e.target.closest('[data-tb-cancel]')) { e.preventDefault(); d.close(); }
|
||||
else if (e.target.closest('[data-tb-archive]')) { e.preventDefault(); archive(e.target.closest('form')); }
|
||||
return;
|
||||
}
|
||||
if (!inBoard(e) || !snap) return;
|
||||
const lay = e.target.closest('[data-tb-layout]');
|
||||
if (lay) { layouts[phone() ? 'phone' : 'wide'] = lay.dataset.tbLayout; save(LAYOUT, JSON.stringify(layouts)); paint(); return; }
|
||||
const chip = e.target.closest('[data-tb-org]');
|
||||
if (chip) {
|
||||
filter.org = chip.dataset.tbOrg;
|
||||
if (filter.project && filter.project !== '-' && filter.org && !filter.project.startsWith(`${filter.org}:`)) { filter.project = ''; refilter(); } else paint();
|
||||
return;
|
||||
}
|
||||
const open = e.target.closest('[data-tb-open]');
|
||||
if (open) { const t = find(open.dataset.tbOpen); if (t) openDialog(editForm(t), open.dataset.focusKey, t); return; }
|
||||
const add = e.target.closest('[data-tb-new]');
|
||||
if (add && !add.disabled) openDialog(createForm(filter.org), 'tb-new');
|
||||
});
|
||||
document.addEventListener('change', e => {
|
||||
if (e.target.matches?.('[data-tb-create-org]')) {
|
||||
const form = e.target.closest('form'), org = orgOf(e.target.value);
|
||||
form.querySelector('select[name=group]').innerHTML = projectOptions(org.id, '');
|
||||
form.querySelector('[data-tb-roles]').innerHTML = roleBoxes(org.roles, []);
|
||||
return;
|
||||
}
|
||||
if (!inBoard(e) || !snap) return;
|
||||
const f = e.target.dataset?.tbFilter;
|
||||
if (f) { filter[f] = e.target.value; refilter(); return; }
|
||||
if (e.target.matches('[data-tb-move]')) move(e.target.dataset.tbMove, e.target.value);
|
||||
});
|
||||
document.addEventListener('input', e => {
|
||||
if (!e.target.matches?.('[data-tb-q]') || !inBoard(e) || !snap) return;
|
||||
filter.q = e.target.value; paint('results');
|
||||
});
|
||||
document.addEventListener('submit', e => {
|
||||
const form = e.target.closest?.('#tb-dialog form');
|
||||
if (!form) return;
|
||||
e.preventDefault(); submit(form);
|
||||
});
|
||||
// Drag and drop between columns: an enhancement over the list's State control and the edit form.
|
||||
document.addEventListener('dragstart', e => {
|
||||
const c = e.target.closest?.('[data-tasks-board] .tb-card[draggable=true]');
|
||||
if (!c) return;
|
||||
dragging = c.dataset.tbTask; dragEnded = new Promise(r => { c.addEventListener('dragend', r, { once: true }); });
|
||||
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragging);
|
||||
c.classList.add('is-dragging');
|
||||
});
|
||||
document.addEventListener('dragend', () => { dragging = null; document.querySelectorAll('.tb-col.is-over').forEach(c => c.classList.remove('is-over')); });
|
||||
document.addEventListener('dragover', e => {
|
||||
const col = dragging && e.target.closest?.('[data-tasks-board] [data-tb-col]');
|
||||
if (!col) return;
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
|
||||
document.querySelectorAll('.tb-col.is-over').forEach(c => { if (c !== col) c.classList.remove('is-over'); });
|
||||
col.classList.add('is-over');
|
||||
});
|
||||
document.addEventListener('drop', e => {
|
||||
const col = e.target.closest?.('[data-tasks-board] [data-tb-col]');
|
||||
if (!col || !dragging) return;
|
||||
e.preventDefault();
|
||||
const k = dragging; dragging = null;
|
||||
move(k, col.dataset.tbCol);
|
||||
});
|
||||
// The layout default follows the width when the viewer has not picked one for it.
|
||||
window.matchMedia('(max-width:759px)').addEventListener('change', () => paint());
|
||||
|
||||
window.MosaicTasksBoard = Object.freeze({ view });
|
||||
})();
|
||||
@@ -0,0 +1,36 @@
|
||||
// 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' });
|
||||
// The status travels with the failure, so a caller can tell a refusal (403) from a bus that is down.
|
||||
if (!response.ok || response.type === 'opaqueredirect') { const error = new Error('Read unavailable'); error.status = response.type === 'opaqueredirect' ? 0 : response.status; throw error; }
|
||||
return response.json();
|
||||
};
|
||||
const orgPath = (id, path) => `/api/orgs/${encodeURIComponent(id)}/${path}`;
|
||||
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)}`));
|
||||
// U1's cross-organization read (GET /api/workspace). Only the keys it accepts are sent, each once:
|
||||
// an unknown or repeated key is a 400 there.
|
||||
const WORKSPACE_KEYS = ['sections', 'org', 'status', 'state', 'name', 'sort', 'order', 'project'];
|
||||
const workspace = (params = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
for (const key of WORKSPACE_KEYS) { const v = params[key]; if (v !== undefined && v !== null && v !== '') q.set(key, Array.isArray(v) ? v.join(',') : String(v)); }
|
||||
const qs = q.toString();
|
||||
return read(`/api/workspace${qs ? `?${qs}` : ''}`);
|
||||
};
|
||||
// The three agent levels (root, organization, project) for every organization on the list, owner only.
|
||||
const agentLevels = () => read('/api/agents/levels');
|
||||
window.MosaicWorkspaceData = Object.freeze({ organizations, themes, projects, threads, instances, agents, claims, queue, business, settings, projectFiles, workspace, agentLevels });
|
||||
})();
|
||||
@@ -0,0 +1,752 @@
|
||||
// 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, and the
|
||||
// System types chips, row and section are system-types.js's (Phase P2), with its own reads and writes.
|
||||
(() => {
|
||||
'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(); if (route().split('?')[0] === '/projects') document.dispatchEvent(new CustomEvent('mosaic-workspace-render')); }
|
||||
});
|
||||
window.addEventListener('hashchange', () => { if (orgs) paint(); });
|
||||
document.addEventListener('mosaic-org-list', e => refresh(e.detail?.rows));
|
||||
|
||||
// ---------- 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, types } = 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', types ? types.summary(org.id) : 'Could not be read', 'org file', types ? '@system-types' : 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 || page.startsWith('@') ? null : page.startsWith('#') ? page : `#/settings/${page}`;
|
||||
// '@system-types' is the System types section further down this page, not a route.
|
||||
const go = page === '@system-types' ? `<button type="button" class="go" data-system-types-open data-focus-key="${esc(`set:${name}`)}" aria-label="Open ${esc(name)}">Open</button>`
|
||||
: 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>`;
|
||||
}
|
||||
|
||||
// A side-by-side pane passes its own organization and { pane: true, idPrefix }: the pane has no bus
|
||||
// restart (that stays in the organization's full view) and its ids carry the pane's prefix.
|
||||
async function dashboard(id = window.MosaicOrg.id, opts = {}) {
|
||||
const pane = opts.pane === true, why = `${opts.idPrefix || ''}ws-reload-why`;
|
||||
// Only an organization the membership list names is read; anything else reads nothing more.
|
||||
const [listRead] = await Promise.all([data.organizations().catch(() => null), ensureThemes()]);
|
||||
const list = Array.isArray(listRead) ? listRead : null;
|
||||
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 types = window.MosaicSystemTypes || null;
|
||||
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), types ? types.load(id) : null]);
|
||||
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 = pane ? '' : window.MosaicConsole?.busButton?.('Restart bus') || '';
|
||||
const offTitle = pane ? "Restart the bus from this organization's full view" : "Restarting the bus is the owner's trailed action, on a Console started with send and the action trail";
|
||||
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${icon('restart', 'ws-busicon')}${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="${esc(offTitle)}">${icon('restart')}Restart bus</button></span>`;
|
||||
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="${esc(why)}" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
|
||||
const [bcls, bglyph] = state(org);
|
||||
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="${esc(why)}">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
|
||||
|
||||
const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid;
|
||||
const typechips = types ? types.chips(id) : '<div class="ws-typechips ws-chips"><span class="ws-label">System types</span><span class="note">System types unavailable</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, types })}
|
||||
${types ? types.section(id, { pane, idPrefix: opts.idPrefix || '' }) : ''}
|
||||
</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
|
||||
// Repaints that section alone when the choice changes, one renderer per organization: a side-by-side
|
||||
// pane can show another organization's agents next to the page's own.
|
||||
let agentsAvailable = {};
|
||||
|
||||
function levelsTable(ctx, projectId) {
|
||||
const { selOrg, lv, mine, levels, modelOf, names, others } = ctx;
|
||||
const union = projectId && lv?.state === 'ok' ? lv.projects[projectId] : null;
|
||||
if (union && union.state !== 'ok') return `<p class="ws-panel tight note">The agents for ${esc(names(projectId))} could not be resolved. ${esc(union.reason)}</p>`;
|
||||
const set = union || (lv?.state === 'ok' ? lv.base : null);
|
||||
const inst = mine?.inst ?? null;
|
||||
const rows = [];
|
||||
if (levels?.root?.state === 'ok' && levels.root.assistant) {
|
||||
// The model the root file sets is shown as configured; the assistant's running state is not reported.
|
||||
const configured = levels.root.agents.find(a => a.name === levels.root.assistant)?.model ?? null;
|
||||
rows.push({ name: levels.root.assistant, level: 'root', definition: 'assistant', model: configured, scope: { kind: 'cross-org' }, overrides: [], narrowed: [], assistant: true });
|
||||
}
|
||||
if (set) {
|
||||
rows.push(...set.agents);
|
||||
// A bus instance the organization file no longer names still shows, as the bus reports it.
|
||||
const known = new Set([...set.agents.map(a => a.name), ...set.excluded.map(e => e.name)]);
|
||||
for (const name of Object.keys(inst || {})) if (!known.has(name)) rows.push({ name, level: 'org', definition: inst[name]?.definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] });
|
||||
} else if (inst) {
|
||||
rows.push(...Object.entries(inst).map(([name, v]) => ({ name, level: 'org', definition: v?.definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] })));
|
||||
}
|
||||
if (!set && inst === null) return '<p class="ws-panel tight note">Agents unavailable. The bus did not answer the instance read.</p>';
|
||||
if (!rows.length) return '<p class="ws-panel tight note">No agents configured</p>';
|
||||
const where = a => (a.level === 'root' ? 'Your root configuration' : a.level === 'org' ? `${label(selOrg)} file` : `${names(a.project)} project`);
|
||||
const sub = a => [a.definition && a.definition !== a.name ? a.definition : '', a.overrides?.length ? `replaces the ${a.overrides.map(o => (o.level === 'org' ? 'organization' : o.level)).join(' and ')} ${a.overrides.length > 1 ? 'definitions' : 'definition'}` : '', a.narrowed?.length ? `narrowed here: ${a.narrowed.join(', ')}` : ''].filter(Boolean).join(' · ');
|
||||
const status = a => {
|
||||
if (a.assistant) return dot('s-unknown', '?', 'status not reported', true);
|
||||
if (a.level !== 'org') return dot(...NOT_RUNNING, true);
|
||||
if (inst === null) return '<span class="muted">status unavailable</span>';
|
||||
return dot(...(inst[a.name] ? activity(a.name, inst[a.name], mine.agents) : NOT_RUNNING), true);
|
||||
};
|
||||
const model = a => {
|
||||
if (a.level === 'org') return modelCell(a.name, modelOf(a.name) ?? a.model);
|
||||
return a.model ? esc(a.model) : '<span class="muted">not set</span>';
|
||||
};
|
||||
const body = rows.map(a => { const s = sub(a); return `<tr data-agent="${esc(a.name)}"><td class="t1">${esc(a.name)}${s ? `<span class="t2">${esc(s)}</span>` : ''}</td><td><span class="ws-lvl ${LEVEL_CLS[a.level]}${a.level === 'org' ? ' ws-o" style="' + colours(selOrg) : ''}">${a.level}</span></td><td class="hide-m">${esc(where(a))}</td><td class="hide-m">${a.scope?.kind === 'cross-org' ? 'Shared across all organizations' : 'This organization only'}</td><td>${status(a)}</td><td class="hide-m mono">${model(a)}</td></tr>`; }).join('');
|
||||
// What this project does not get: other projects' own agents, its exclusions and other organizations.
|
||||
const away = [];
|
||||
if (union) {
|
||||
const here = new Set(union.agents.map(a => a.name));
|
||||
for (const [pid, entries] of Object.entries(lv.defined || {})) if (pid !== projectId) for (const e of entries) if (e.form === 'define' && !here.has(e.name)) away.push(`${e.name} (${names(pid)} only)`);
|
||||
}
|
||||
if (set) {
|
||||
for (const e of set.excluded) away.push(`${e.name} (${e.by === 'project' ? 'excluded in this project' : `turned off for ${label(selOrg)}`})`);
|
||||
}
|
||||
const otherOrgs = others.map(o => label(o));
|
||||
const tail = otherOrgs.length ? `every ${otherOrgs.length > 1 ? `${otherOrgs.slice(0, -1).join(', ')} or ${otherOrgs.at(-1)}` : otherOrgs[0]} agent` : '';
|
||||
const parts = [...away, tail].filter(Boolean);
|
||||
const note = parts.length ? `<p class="note ws-away">Not available here: ${esc(parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}` : parts[0])}.</p>` : '';
|
||||
return `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Level</th><th scope="col" class="hide-m">Defined in</th><th scope="col" class="hide-m">Memory</th><th scope="col">Status</th><th scope="col" class="hide-m">Model</th></tr></thead><tbody>${body}</tbody></table></div>${note}`;
|
||||
}
|
||||
|
||||
async function agentGrid(note = '', selected = window.MosaicOrg?.id) {
|
||||
const [list, levels] = await Promise.all([data.organizations().catch(() => null), data.agentLevels().catch(() => null), ensureThemes()]);
|
||||
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Root and project agents are read from their files and are not launched yet.${note ? ` ${note}` : ''}</p></div>`;
|
||||
if (!Array.isArray(list)) return `<section class="ws ws-agents" data-org-agents>${head}<p class="ws-panel tight note">Organizations unavailable.</p></section>`;
|
||||
const valid = list.filter(org => !org.invalid);
|
||||
const admitted = valid.some(org => org.id === selected);
|
||||
const [results, business] = await Promise.all([
|
||||
Promise.all(valid.map(org => Promise.all([
|
||||
data.instances(org.id).then(instancesOf).catch(() => null), data.agents(org.id).then(rowsOf).catch(() => null),
|
||||
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 = { ...agentsAvailable, [selected]: projectId => levelsTable(ctx, projectId) };
|
||||
const chooser = projectIds.length ? `<label class="ws-psel">${av(selOrg, 'xs')}<span class="ws-sr">Project</span><select data-agents-project="${esc(selected)}" data-focus-key="agents-project">${projectIds.map(id => `<option value="${esc(id)}"${id === pick ? ' selected' : ''}>${esc(ctx.names(id))}</option>`).join('')}</select></label>` : '';
|
||||
const title = projectIds.length ? 'Available in a project' : `Agents in ${esc(label(selOrg))}`;
|
||||
const table = !mine ? '<p class="ws-panel tight note">The selected organization is not on the list.</p>' : levelsTable(ctx, pick);
|
||||
const rules = `<div class="ws-panel tight ws-rules"><p><span class="ws-lvl root">root</span>You define root agents in agents.json in your configuration directory. The chief cannot change them.</p><p><span class="ws-lvl org">org</span>Agents come from the organization file. Add, change or remove them under Settings, Agents.</p><p><span class="ws-lvl proj">project</span>A project in the organization file can add an agent, narrow an inherited agent's limits or exclude one. It never widens them.</p><p><span class="ws-lvl proj">override</span>The most specific level wins. A row says which definition it replaces.</p><p class="muted">An agent's harness, model and thinking are set under Settings, Routing.</p></div>`;
|
||||
return `<section class="ws ws-agents" data-org-agents>${head}${band}${levelsGrid}
|
||||
<div class="ws-split ws-split-agents"><section><div class="ws-sec-h"><h2>${title}</h2>${chooser}</div><div data-agents-available>${table}</div></section><section>${secH('Who changes what')}${rules}</section></div>
|
||||
</section>`;
|
||||
}
|
||||
document.addEventListener('change', e => {
|
||||
const select = e.target.closest?.('[data-agents-project]');
|
||||
const org = select?.dataset.agentsProject, draw = org && Object.hasOwn(agentsAvailable, org) ? agentsAvailable[org] : null;
|
||||
const box = draw && select.closest('[data-org-agents]')?.querySelector('[data-agents-available]');
|
||||
if (!box) return;
|
||||
agentsPick = { ...agentsPick, [org]: select.value };
|
||||
box.innerHTML = draw(select.value);
|
||||
});
|
||||
|
||||
// ---------- across organizations (U3: samples 02, 04 and 05) ----------
|
||||
// One read, GET /api/workspace (U1), backs the three pages. Each organization carries a state and
|
||||
// the time it was read: one that is down, starting or slow is named with that state, and the rest
|
||||
// still render. Filters live in the page's hash query, so a poll repaints the same view and a
|
||||
// link reproduces it. Numbers come only from the read; a field no read gives is one honest line.
|
||||
let pollMs = 10000;
|
||||
const took = body => { const n = Number(body?.pollMs); if (Number.isFinite(n) && n >= 1000) pollMs = n; return body; };
|
||||
const selHref = path => `#/orgs/${enc(window.MosaicOrg?.id || '')}${path}`;
|
||||
const ORG_STATE = { ok: ['s-working', '●', 'running'], starting: ['s-waiting', '◐', 'starting'], down: ['s-offline', '○', 'down'], timeout: ['s-error', '▲', 'not answering'], invalid: ['s-error', '▲', 'invalid'] };
|
||||
const orgState = o => ORG_STATE[o?.state] || ['s-unknown', '?', 'not reported'];
|
||||
const msOf = iso => { const t = Date.parse(iso || ''); return Number.isFinite(t) ? t : null; };
|
||||
const hm = iso => { const t = msOf(iso); return t === null ? '' : new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); };
|
||||
// Today's times as HH:MM, older ones as a short date.
|
||||
const when = iso => { const t = msOf(iso); if (t === null) return ''; const d = new Date(t); return d.toDateString() === new Date().toDateString() ? hm(iso) : d.toLocaleDateString([], { month: 'short', day: 'numeric' }); };
|
||||
const span = iso => { const t = msOf(iso); if (t === null) return ''; const m = Math.max(0, Math.floor((Date.now() - t) / 60000)); return m < 1 ? 'under a minute' : m < 60 ? `${m} min` : m < 2880 ? `${Math.floor(m / 60)} h` : `${Math.floor(m / 1440)} days`; };
|
||||
const ago = iso => { const s = span(iso); return !s ? '' : s === 'under a minute' ? 'just now' : `${s} ago`; };
|
||||
const num = v => (Number.isFinite(v) ? v : 0);
|
||||
const arr = v => (Array.isArray(v) ? v : []);
|
||||
// The organizations the read names, with their colours, marks and states.
|
||||
function orgsOf(body) {
|
||||
const rows = arr(body?.orgs).filter(o => o && typeof o.org === 'string');
|
||||
// The read names an organization by id; the sidebar's list carries its display label.
|
||||
const known = id => (Array.isArray(orgs) ? orgs.find(x => x?.id === id) : null);
|
||||
const byId = new Map(rows.map(o => [o.org, { id: o.org, label: known(o.org)?.label || o.label || o.org, theme: o.theme ?? known(o.org)?.theme ?? null }]));
|
||||
const meta = id => byId.get(id) || { id, label: id };
|
||||
return {
|
||||
rows, meta, name: id => label(meta(id)), style: id => colours(meta(id)),
|
||||
ref: (id, cls = '') => `<span class="ws-orgref${cls}">${av(meta(id))}${esc(label(meta(id)))}</span>`,
|
||||
unread: rows.filter(o => o.state !== 'ok'),
|
||||
};
|
||||
}
|
||||
const stateWords = o => `${esc((Array.isArray(orgs) ? orgs.find(x => x?.id === o.org)?.label : null) || o.label || o.org)} ${orgState(o)[2]}${o.at ? `, read ${hm(o.at)}` : ''}`;
|
||||
const andList = items => (items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`);
|
||||
// An organization that answered can still fail one read (errors in GET /api/workspace). The page
|
||||
// names that organization and the reason rather than count the read as zero.
|
||||
const READ_WHY = { 'channel-disabled': 'the chat channel is off', 'outcome-unknown': 'no answer', timeout: 'no answer in time' };
|
||||
const missOf = (r, names) => { const n = r?.state === 'ok' && r.errors ? names.find(x => r.errors[x]) : null; return n ? READ_WHY[r.errors[n]] || String(r.errors[n]) : null; };
|
||||
const missed = (o, names) => o.rows.filter(r => missOf(r, names)).map(r => `${esc(o.name(r.org))} (${esc(missOf(r, names))})`);
|
||||
const missLine = (o, names, key, lead = 'Not read in') => { const m = missed(o, names); return m.length ? `<p class="note ws-miss" data-ws-miss="${key}">${lead} ${andList(m)}.</p>` : ''; };
|
||||
// The organizations that answered and made every read a figure is built from. Only these count
|
||||
// toward it: a failed read reaches the merge as an empty list, so its zero is not a figure.
|
||||
const readers = (o, names) => new Set(o.rows.filter(r => r.state === 'ok' && !missOf(r, names)).map(r => r.org));
|
||||
// Some organizations exist, and none of them could make these reads.
|
||||
const noneRead = (o, names) => o.rows.length > 0 && !readers(o, names).size;
|
||||
function failedPage(cls, attr, title, err) {
|
||||
const why = err?.status === 403 ? 'This Console refused the read across organizations.' : err?.status === 0 ? 'You are signed out. Reload the page to sign in again.' : err?.status === 400 ? 'The read refused these filters. Clear them and try again.' : 'The read across organizations did not answer. The page tries again on its next refresh.';
|
||||
return `<section class="ws ${cls}" ${attr} data-ws-failed><div class="ws-vh"><h1 tabindex="-1">${title}</h1><p class="sub">${why}</p></div></section>`;
|
||||
}
|
||||
// The Inbox count in the navigation follows the cross-organization read wherever it was made.
|
||||
const tellInbox = rows => { if (Array.isArray(rows)) document.dispatchEvent(new CustomEvent('mosaic-workspace-inbox', { detail: { open: rows.length, blocking: rows.filter(d => d?.blocking).length } })); };
|
||||
// A stacked bar and its per-organization figures, one segment per organization.
|
||||
const bar = (o, parts, style = '') => `<div class="ws-bar"${style ? ` style="${style}"` : ''} aria-hidden="true">${parts.filter(p => p.n > 0).map(p => `<i class="ws-o" style="${o.style(p.id)};flex:${p.n}"></i>`).join('')}</div>`;
|
||||
const by = (o, parts, fmt = n => n) => `<div class="ws-by">${parts.map(p => `<span>${av(o.meta(p.id))}<span class="ws-sr">${esc(o.name(p.id))}</span>${esc(fmt(p.n))}</span>`).join('')}</div>`;
|
||||
const CLASS_CHIP = { gated: 'gated', 'within-role': 'within', routine: 'routine', 'cross-role': 'cross' };
|
||||
const classChip = c => (c ? `<span class="ws-chip ${CLASS_CHIP[c] || ''}">${esc(c)}</span>` : '');
|
||||
const itemKey = d => `${d.org}:${d.id}`;
|
||||
const roleOf = d => d.session?.role || d.raisedBy || '';
|
||||
const fromWords = (o, d, withOrg = true) => [withOrg ? o.name(d.org) : '', d.project?.name || 'No project', roleOf(d)].filter(Boolean).map(esc).join(' · ');
|
||||
|
||||
// ---- main dashboard (sample 02) ----
|
||||
async function home() {
|
||||
let body;
|
||||
try { [body] = await Promise.all([data.workspace({ sections: ['projects', 'sessions', 'inbox', 'tasks', 'metrics'] }), ensureThemes()]); took(body); }
|
||||
catch (err) { return failedPage('ws-home', 'data-ws-home', 'Dashboard', err); }
|
||||
const o = orgsOf(body), m = body.metrics, t = m?.totals, per = arr(m?.orgs);
|
||||
const inbox = arr(body.inbox), sessions = arr(body.sessions), projectRows = arr(body.projects), tasks = arr(body.tasks);
|
||||
if (!noneRead(o, ['inbox'])) tellInbox(body.inbox);
|
||||
const sessN = s => num(s?.working) + num(s?.waiting) + num(s?.idle) + num(s?.live);
|
||||
// The reads each tile's figures come from (workspace-read.mjs orgRows).
|
||||
const SRC = { needs: ['inbox'], sessions: ['agents', 'claims'], tasks: ['tasks'], projects: ['projects'] };
|
||||
// Per-organization figures name only the organizations that made the tile's reads.
|
||||
const parts = (key, f) => { const ok = readers(o, SRC[key]); return per.filter(x => ok.has(x.org)).map(x => ({ id: x.org, n: num(f(x)) })); };
|
||||
const answered = o.rows.filter(r => r.state === 'ok').length;
|
||||
const tile = (key, name, big, small, ps, delta) => {
|
||||
const m = missed(o, SRC[key]);
|
||||
if (!readers(o, SRC[key]).size) return `<div class="ws-tile quiet" data-ws-tile="${key}" data-ws-miss="${key}"><span class="ws-label">${name}</span><span class="ws-empty-t">Not read</span><span class="ws-delta">${andList(m)}</span></div>`;
|
||||
return `<div class="ws-tile" data-ws-tile="${key}"><span class="ws-label">${name}</span><span class="ws-big">${big}${small ? `<small>${small}</small>` : ''}</span>${ps ? bar(o, ps) + by(o, ps) : ''}<span class="ws-delta">${delta}</span>${m.length ? `<span class="ws-delta ws-miss" data-ws-miss="${key}">Not read in ${andList(m)}</span>` : ''}</div>`;
|
||||
};
|
||||
const tiles = t && answered ? `<div class="ws-g5 keep2" data-ws-tiles>
|
||||
${tile('needs', 'Needs you', num(t.needsYou), t.needsYou === 1 ? 'item' : 'items', parts('needs', x => x.needsYou), t.oldestNeedsYouAt ? `oldest ${span(t.oldestNeedsYouAt)}` : 'nothing waiting')}
|
||||
${tile('sessions', 'Sessions', sessN(t.sessions), `${num(t.sessions?.working)} working`, parts('sessions', x => sessN(x.sessions)), `${num(t.sessions?.waiting)} waiting on you · ${num(t.sessions?.idle)} idle${num(t.sessions?.live) ? ` · ${num(t.sessions.live)} live ${t.sessions.live === 1 ? 'thread' : 'threads'}` : ''}`)}
|
||||
${tile('tasks', 'Open tasks', num(t.openTasks), '', parts('tasks', x => x.openTasks), `<b>${num(t.doneThisWeek)}</b> done this week`)}
|
||||
${tile('projects', 'Projects', num(t.projects?.active) + num(t.projects?.archived), `${num(t.projects?.active)} active`, parts('projects', x => num(x.projects?.active) + num(x.projects?.archived)), `${num(t.projects?.archived)} archived`)}
|
||||
<div class="ws-tile quiet" data-ws-tile="spend"><span class="ws-label">Model spend</span><span class="ws-empty-t">No spend figures yet</span><span class="ws-delta">No read prices model use today.</span></div>
|
||||
</div>` : '<p class="ws-panel tight note" data-ws-tiles>No organization answered, so there are no totals yet.</p>';
|
||||
|
||||
const cards = o.rows.map(r => {
|
||||
const [cls, glyph, word] = orgState(r), id = r.org;
|
||||
const extra = r.state === 'ok' ? '' : `<span class="why">${esc(r.reason || '')}${r.boot?.at ? `${r.reason ? ' · ' : ''}last start ${esc(when(r.boot.at))}` : ''}</span>`;
|
||||
return `<li class="ws-orgcard ws-o${r.state === 'ok' ? '' : ' off'}" style="${o.style(id)}" data-ws-orgcard="${esc(id)}" data-state="${esc(r.state)}"><a href="${esc(orgHref(id))}/organization" data-focus-key="${esc(`orgcard:${id}`)}">${av(o.meta(id), 'md')}<span class="nm">${esc(o.name(id))}</span>${dot(cls, glyph, word, true)}<span class="at">read ${esc(hm(r.at))}</span>${extra}</a></li>`;
|
||||
}).join('');
|
||||
|
||||
const recent = projectRows.filter(p => p.lastActivity).slice(0, 6);
|
||||
const STATE_WORD = { working: 'has a session working', waiting: 'is waiting on you', idle: 'has an idle session', none: 'no live session' };
|
||||
const trail = recent.length ? `<ol class="ws-trail" data-ws-recent>${recent.map(p => `<li class="ws-o" style="${o.style(p.org)}"><time datetime="${esc(p.lastActivity)}">${esc(when(p.lastActivity))}</time>${av(o.meta(p.org))}<span class="what"><a href="${esc(orgHref(p.org))}/projects/${enc(p.id)}" data-focus-key="${esc(`recent:${p.org}:${p.id}`)}"><b>${esc(p.name || p.key || p.id)}</b></a> ${STATE_WORD[p.state] || ''}${p.needsYou ? `, ${plural(p.needsYou, 'item')} for you` : ''} <span class="src">· ${esc(o.name(p.org))}</span></span></li>`).join('')}</ol>` : `<p class="ws-panel tight note" data-ws-recent>${noneRead(o, ['projects']) ? 'Projects not read' : 'No project activity yet'}</p>`;
|
||||
|
||||
const open = tasks.filter(x => !x.done);
|
||||
const buckets = new Map();
|
||||
for (const x of open) { const b = typeof x.bucket === 'string' && x.bucket ? x.bucket : ''; if (!buckets.has(b)) buckets.set(b, new Map()); const g = buckets.get(b); g.set(x.org, (g.get(x.org) || 0) + 1); }
|
||||
const srows = [...buckets.entries()].map(([b, g]) => ({ name: b ? titleCase(b) : 'No bucket', g, n: [...g.values()].reduce((a, c) => a + c, 0) })).sort((a, b) => b.n - a.n);
|
||||
const taskOrgs = readers(o, ['tasks']);
|
||||
if (t && taskOrgs.size) srows.push({ name: 'Done this week', g: new Map(per.filter(x => taskOrgs.has(x.org)).map(x => [x.org, num(x.doneThisWeek)])), n: num(t.doneThisWeek) });
|
||||
const max = Math.max(1, ...srows.map(s => s.n));
|
||||
const states = srows.length ? `<div class="ws-panel tight ws-states" data-ws-states>${srows.map(s => { const ps = [...s.g.entries()].filter(([, n]) => n > 0).map(([id, n]) => ({ id, n })); return `<div class="ws-srow"><span class="sl">${esc(s.name)}</span><span class="sn">${s.n}</span>${bar(o, ps, `width:${Math.max(4, Math.round((s.n / max) * 90))}%`)}${by(o, ps)}</div>`; }).join('')}</div>` : `<p class="ws-panel tight note" data-ws-states>${noneRead(o, ['tasks']) ? 'Tasks not read' : 'No open tasks'}</p>`;
|
||||
|
||||
const needs = inbox.slice(0, 5);
|
||||
const needsList = needs.length ? `<ul class="ws-needs" data-ws-needs>${needs.map(d => `<li class="ws-edge ws-o" style="${o.style(d.org)}">${av(o.meta(d.org), 'md')}<a class="q" href="${esc(selHref(`/inbox?d=${enc(itemKey(d))}`))}" data-focus-key="${esc(`need:${itemKey(d)}`)}">${esc(d.question || `Decision ${d.id}`)}</a><span class="src">${fromWords(o, d)}</span>${classChip(d.class)}</li>`).join('')}</ul>${inbox.length > needs.length ? `<p class="note ws-more-line">${inbox.length - needs.length} more in the inbox</p>` : ''}` : `<p class="ws-panel tight note" data-ws-needs>${noneRead(o, ['inbox']) ? 'Decisions not read' : 'Nothing needs you'}</p>`;
|
||||
|
||||
const now = sessions.filter(s => s.kind === 'thread' || s.state === 'working' || s.state === 'waiting');
|
||||
const idle = sessions.length - now.length;
|
||||
const who = s => (s.kind === 'thread' ? s.instance || s.thread || 'thread' : s.role || 'agent');
|
||||
const what = s => s.project?.name || s.task?.title || '';
|
||||
const st = s => { const [cls, glyph, word] = s.kind === 'thread' ? ['s-working', '●', 'live'] : ACTIVITY[s.state] || ['s-unknown', '?', s.state || 'not reported']; return `${dot(cls, glyph, word === 'waiting on you' ? 'waiting' : word, true)}${s.state === 'waiting' ? ' <span class="muted">on you</span>' : s.since ? ` <span class="muted">${esc(span(s.since))}</span>` : ''}`; };
|
||||
const sessTable = now.length ? `<div class="ws-tbl"><table class="ws-sess" data-ws-sessions><thead><tr><th scope="col">Agent and project</th><th scope="col">Status</th></tr></thead><tbody>${now.slice(0, 8).map(s => `<tr><td class="who"><span class="ws-orgref">${av(o.meta(s.org), 'xs')}<span class="ws-sr">${esc(o.name(s.org))}: </span>${esc(who(s))}</span>${what(s) ? `<span class="muted"> · ${esc(what(s))}</span>` : ''}</td><td class="st">${st(s)}</td></tr>`).join('')}</tbody></table></div>${now.length > 8 ? `<p class="note ws-more-line">${now.length - 8} more on Agents</p>` : ''}` : `<p class="ws-panel tight note" data-ws-sessions>${noneRead(o, ['agents', 'claims']) ? 'Sessions not read' : `No session working right now${idle ? `; ${plural(idle, 'idle session')}` : ''}`}</p>`;
|
||||
|
||||
const total = t ? num(t.projects?.active) + num(t.projects?.archived) : null;
|
||||
const allRead = !o.unread.length;
|
||||
const sub = !o.rows.length ? 'You belong to no organization yet.'
|
||||
: allRead ? `All ${plural(o.rows.length, 'organization')}${missed(o, ['projects']).length ? '' : ` and ${plural(total ?? 0, 'project')}`}. Totals add up every organization you belong to; each tile breaks them down by organization.`
|
||||
: `${body.summary?.read ?? o.rows.length - o.unread.length} of ${plural(o.rows.length, 'organization')} answered. Totals add up the ones that answered; ${andList(o.unread.map(stateWords))}.`;
|
||||
return `<article class="ws ws-home" data-ws-home>
|
||||
<div class="ws-vh"><h1 tabindex="-1">Dashboard</h1><p class="sub">${sub}</p></div>
|
||||
${tiles}
|
||||
${secH('Organizations', `read ${esc(hm(body.readAt))}`)}
|
||||
<ul class="ws-orgcards" data-ws-orgs>${cards || '<li class="ws-line">No organizations yet</li>'}</ul>
|
||||
<div class="ws-split ws-home-split">
|
||||
<section aria-label="Projects and tasks">
|
||||
${secH('Recent project activity', 'latest change per project; there is no activity feed yet', `<a href="${esc(selHref('/projects'))}" data-focus-key="home-projects">All projects</a>`)}
|
||||
${trail}${missLine(o, ['projects'], 'recent')}
|
||||
${secH('Tasks by state', 'open tasks, every tracker', `<a href="${esc(selHref('/tasks'))}" data-focus-key="home-tasks">Open tasks</a>`)}
|
||||
${states}${missLine(o, ['tasks'], 'states')}
|
||||
</section>
|
||||
<section aria-label="Needs you and sessions">
|
||||
${secH('Needs you', '', `<a href="${esc(selHref('/inbox'))}" data-focus-key="home-inbox">Inbox</a>`)}
|
||||
${needsList}${missLine(o, ['inbox'], 'needs')}
|
||||
${secH('Sessions now', '', `<a href="${esc(selHref('/agents'))}" data-focus-key="home-agents">Agents</a>`)}
|
||||
${sessTable}${missLine(o, ['agents', 'claims'], 'sessions')}
|
||||
</section>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
// ---- projects across organizations (sample 04, REQ-WS-5) ----
|
||||
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const listParam = (q, key, ok) => { const v = [...new Set(String(q?.get?.(key) || '').split(',').map(s => s.trim()).filter(ok))]; return v.length ? v : null; };
|
||||
// The hash query, cleaned to what the API accepts, so a hand-edited link never reads as "all".
|
||||
function projectQuery(q) {
|
||||
const sort = ['activity', 'name', 'org', 'status'].includes(q?.get?.('sort')) ? q.get('sort') : 'activity';
|
||||
const order = ['asc', 'desc'].includes(q?.get?.('order')) ? q.get('order') : sort === 'activity' ? 'desc' : 'asc';
|
||||
const name = String(q?.get?.('name') || '').trim().slice(0, 200);
|
||||
return {
|
||||
org: listParam(q, 'org', id => ORG_ID.test(id)), status: listParam(q, 'status', s => ['active', 'archived'].includes(s)) || ['active'],
|
||||
state: listParam(q, 'state', s => ['working', 'waiting', 'idle', 'none'].includes(s)), name: name || null, sort, order,
|
||||
};
|
||||
}
|
||||
const SORT_WORD = { activity: 'latest activity', name: 'name', org: 'organization', status: 'status' };
|
||||
const STATUS_OPTS = [['active', 'Active'], ['archived', 'Archived'], ['active,archived', 'Active and archived']];
|
||||
const STATE_OPTS = [['', 'Any session state'], ['working', 'Working'], ['waiting', 'Waiting on you'], ['idle', 'Idle'], ['none', 'No session']];
|
||||
function orgChips(o, counts, chosen, total, src = []) {
|
||||
const all = !chosen;
|
||||
return `<div class="ws-fchips" role="group" aria-label="Organization"><button type="button" class="f plain" data-ws-org="" data-focus-key="chip:all" aria-pressed="${all}">All <span class="n">${total}</span></button>${o.rows.map(r => `<button type="button" class="f ws-o" style="${o.style(r.org)}" data-ws-org="${esc(r.org)}" data-focus-key="${esc(`chip:${r.org}`)}" aria-pressed="${!!chosen?.includes(r.org)}">${av(o.meta(r.org))}${esc(o.name(r.org))}<span class="n">${r.state === 'ok' && !missOf(r, src) ? counts.get(r.org) || 0 : `<span class="ws-sr">, </span>${r.state === 'ok' ? 'not read' : esc(orgState(r)[2])}`}</span></button>`).join('')}</div>`;
|
||||
}
|
||||
async function projectsPage(q) {
|
||||
const f = projectQuery(q);
|
||||
const params = { sections: ['projects'], org: f.org, status: f.status, state: f.state, name: f.name, sort: f.sort, order: f.order };
|
||||
let body, all;
|
||||
try {
|
||||
[body, all] = await Promise.all([data.workspace(params), f.org ? data.workspace({ ...params, org: null }) : null, ensureThemes()]);
|
||||
took(body);
|
||||
} catch (err) { return failedPage('ws-projects', 'data-ws-projects', 'Projects', err); }
|
||||
const every = orgsOf(all || body), o = orgsOf(body);
|
||||
const counts = new Map();
|
||||
for (const p of arr((all || body).projects)) counts.set(p.org, (counts.get(p.org) || 0) + 1);
|
||||
const rows = arr(body.projects);
|
||||
const flat = grouping === 'flat';
|
||||
const cols = flat ? 8 : 7;
|
||||
const aria = key => (f.sort === key ? ` aria-sort="${f.order === 'desc' ? 'descending' : 'ascending'}" class="sorted` : ' class="');
|
||||
const ar = key => (f.sort === key ? `<span class="ar" aria-hidden="true">${f.order === 'desc' ? '↓' : '↑'}</span>` : '');
|
||||
const head = `<tr>${flat ? `<th scope="col"${aria('org')} hide-m">Organization${ar('org')}</th>` : ''}<th scope="col"${aria('name')}">Project${ar('name')}</th><th scope="col"${aria('status')}">Status${ar('status')}</th><th scope="col" class="hide-m">Sessions</th><th scope="col" class="num hide-m">Open tasks</th><th scope="col" class="num">Needs you</th><th scope="col"${aria('activity')} hide-m">Last activity${ar('activity')}</th><th scope="col" class="hide-m">Git</th></tr>`;
|
||||
const SESS = [['working', 's-working', '●', 'working'], ['waiting', 's-waiting', '◐', 'waiting'], ['idle', 's-idle', '–', 'idle']];
|
||||
// A row's figures come from more reads than its project list (workspace-read.mjs orgRows). A
|
||||
// figure whose reads failed in the row's organization says "not read", never 0 or none.
|
||||
const DEP = { sessions: ['agents', 'claims', 'threads', 'tasks'], tasks: ['tasks'], needs: ['inbox', 'tasks'], activity: ['tasks', 'inbox', 'agents', 'claims', 'threads'] };
|
||||
const notRead = (p, key) => missOf(o.rows.find(r => r.org === p.org), DEP[key]);
|
||||
const nr = why => `<span class="muted" title="${esc(why)}">not read</span>`;
|
||||
const sessCell = p => { const s = p.sessions || {}, have = SESS.filter(([k]) => num(s[k]) > 0); if (!have.length) return '<span class="muted">none</span>'; const [k, cls, glyph, word] = have[0]; return `${dot(cls, glyph, `${num(s[k])} ${word}`, true)}${have.length > 1 ? `<span class="t2">${have.slice(1).map(([k2, , , w2]) => `${num(s[k2])} ${w2}`).join(' · ')}</span>` : ''}`; };
|
||||
const row = p => {
|
||||
const href = `${orgHref(p.org)}/projects/${enc(p.id)}`, active = p.status === 'active';
|
||||
return `<tr class="ws-edge ws-o" style="${o.style(p.org)}" data-ws-project="${esc(`${p.org}:${p.id}`)}">
|
||||
${flat ? `<td class="hide-m">${o.ref(p.org)}</td>` : ''}
|
||||
<td><a class="t1 nm" href="${esc(href)}" data-focus-key="${esc(`prow:${p.org}:${p.id}`)}">${av(o.meta(p.org), 'xs mob')}${esc(p.name || p.key || p.id)}<span class="ws-sr"> in ${esc(o.name(p.org))}</span></a>${p.key ? `<span class="t2 mono hide-m">${esc(p.key)}</span>` : ''}<span class="t2 show-m">${p.lastActivity ? esc(ago(p.lastActivity)) : 'No activity yet'}</span></td>
|
||||
<td>${active ? dot('s-ok', '●', 'active', true) : dot('s-idle', '–', 'archived', true)}</td>
|
||||
<td class="hide-m">${notRead(p, 'sessions') ? nr(notRead(p, 'sessions')) : sessCell(p)}</td>
|
||||
<td class="num hide-m">${notRead(p, 'tasks') ? nr(notRead(p, 'tasks')) : num(p.openTasks)}</td>
|
||||
<td class="num">${notRead(p, 'needs') ? nr(notRead(p, 'needs')) : p.needsYou ? `<span class="ws-n hot">${num(p.needsYou)}</span>` : '<span class="muted">0</span>'}</td>
|
||||
<td class="hide-m">${p.lastActivity ? `<time datetime="${esc(p.lastActivity)}">${esc(ago(p.lastActivity))}</time>${notRead(p, 'activity') ? '<span class="t2">some reads missing</span>' : ''}` : notRead(p, 'activity') ? nr(notRead(p, 'activity')) : '<span class="muted">none yet</span>'}</td>
|
||||
<td class="hide-m">${p.git ? `<span class="ws-chip outline">${esc(p.git)}</span>` : '<span class="muted">none</span>'}</td>
|
||||
</tr>`;
|
||||
};
|
||||
const unreadRow = r => `<tr class="ws-unread"><td colspan="${cols}">${dot(...orgState(r), true)} <span class="muted">Not read${r.at ? ` at ${esc(hm(r.at))}` : ''}${r.reason ? `: ${esc(r.reason)}` : ''}</span></td></tr>`;
|
||||
let tbody;
|
||||
if (flat) tbody = rows.length ? rows.map(row).join('') : `<tr><td colspan="${cols}" class="muted">${noneRead(o, ['projects']) ? 'Projects not read' : f.org || f.state || f.name || f.status.join() !== 'active' ? 'No projects match these filters' : 'No projects yet'}</td></tr>`;
|
||||
else {
|
||||
const groups = (f.sort === 'org' && f.order === 'desc' ? [...o.rows].reverse() : o.rows);
|
||||
tbody = groups.map(r => {
|
||||
const mine = rows.filter(p => p.org === r.org);
|
||||
const h = `<tr class="ws-grp-row ws-o" style="${o.style(r.org)}" data-ws-group-org="${esc(r.org)}"><th colspan="${cols}" scope="colgroup">${av(o.meta(r.org))}<span>${esc(o.name(r.org))}</span>${r.state === 'ok' && !missOf(r, ['projects']) ? `<span class="ws-n">${mine.length}</span>` : ''}</th></tr>`;
|
||||
const why = missOf(r, ['projects']);
|
||||
return h + (r.state !== 'ok' ? unreadRow(r) : why ? `<tr class="ws-unread"><td colspan="${cols}">${dot('s-unknown', '?', 'not read', true)} <span class="muted">Projects not read: ${esc(why)}</span></td></tr>` : mine.length ? mine.map(row).join('') : `<tr><td colspan="${cols}" class="muted">No projects match</td></tr>`);
|
||||
}).join('') || `<tr><td colspan="${cols}" class="muted">No organizations yet</td></tr>`;
|
||||
}
|
||||
const unreadNote = flat && o.unread.length ? `<p class="note ws-unread-note" data-ws-unread>Not in this list: ${andList(o.unread.map(stateWords))}.</p>` : '';
|
||||
// Projects that were read can still lack figures from the other reads; the cells say "not read".
|
||||
const missNote = (flat ? missLine(o, ['projects'], 'projects') : '') + missLine({ ...o, rows: o.rows.filter(r => !missOf(r, ['projects'])) }, DEP.activity, 'figures', 'Some figures not read in');
|
||||
const shown = new Set(rows.map(p => p.org)).size;
|
||||
const sub = `${plural(rows.length, 'project')} in ${plural(shown, 'organization')}, sorted by ${SORT_WORD[f.sort]}${f.sort === 'activity' ? (f.order === 'desc' ? ', newest first' : ', oldest first') : f.order === 'desc' ? ', descending' : ''}. Each row names its organization by mark and colour edge.`;
|
||||
const statusVal = f.status.join(',');
|
||||
const stateVal = f.state?.length === 1 ? f.state[0] : '';
|
||||
return `<section class="ws ws-projects" data-ws-projects>
|
||||
<div class="ws-vh"><h1 tabindex="-1">Projects</h1><div class="ws-right"><a class="ws-btn" href="${esc(selHref('/chat'))}" data-focus-key="proj-new">${icon('plus')}New project</a></div><p class="sub">${sub}</p></div>
|
||||
<div class="ws-toolbar">
|
||||
<label class="ws-search">${icon('search', 'sm')}<span class="ws-sr">Filter projects by name</span><input type="search" data-ws-name data-focus-key="proj-name" placeholder="Filter by name" maxlength="200" autocomplete="off" value="${esc(String(q?.get?.('name') || '').slice(0, 200))}"></label>
|
||||
${orgChips(every, counts, f.org, arr((all || body).projects).length, ['projects'])}
|
||||
<label class="ws-sel"><span class="ws-label">Status</span><select data-ws-status data-focus-key="proj-status">${STATUS_OPTS.map(([v, l]) => `<option value="${v}"${v === statusVal ? ' selected' : ''}>${l}</option>`).join('')}</select></label>
|
||||
<label class="ws-sel"><span class="ws-label">State</span><select data-ws-state data-focus-key="proj-state">${STATE_OPTS.map(([v, l]) => `<option value="${v}"${v === stateVal ? ' selected' : ''}>${l}</option>`).join('')}</select></label>
|
||||
<div class="ws-right"><span class="ws-label hide-m" id="ws-sort-l">Sort</span><span class="ws-seg" role="group" aria-labelledby="ws-sort-l">${['activity', 'name', 'org', 'status'].map(s => `<button type="button" data-ws-sort="${s}" data-focus-key="sort:${s}" aria-pressed="${f.sort === s}"${f.sort === s ? ` title="Reverse the order"` : ''}>${titleCase(s === 'org' ? 'org' : s)}${f.sort === s ? `<span class="ws-sr">, ${f.order === 'desc' ? 'descending' : 'ascending'}</span>` : ''}</button>`).join('')}</span>
|
||||
<span class="ws-seg" role="group" aria-label="Project layout"><button type="button" data-ws-group="org" data-focus-key="pgroup-org" aria-pressed="${!flat}">By org</button><button type="button" data-ws-group="flat" data-focus-key="pgroup-flat" aria-pressed="${flat}">Flat</button></span></div>
|
||||
</div>
|
||||
${unreadNote}${missNote}
|
||||
<div class="ws-tbl"><table class="ws-ptable" data-ws-table data-grouping="${flat ? 'flat' : 'org'}"><caption class="ws-sr">Projects across organizations</caption><thead>${head}</thead><tbody>${tbody}</tbody></table></div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// ---- inbox across organizations (sample 05) ----
|
||||
async function inboxPage(q) {
|
||||
const org = listParam(q, 'org', id => ORG_ID.test(id));
|
||||
const want = String(q?.get?.('d') || '');
|
||||
let body, all;
|
||||
try {
|
||||
[body, all] = await Promise.all([data.workspace({ sections: ['inbox'], org }), org ? data.workspace({ sections: ['inbox'] }) : null, ensureThemes()]);
|
||||
took(body);
|
||||
} catch (err) { return failedPage('ws-inbox', 'data-ws-inbox', 'Inbox', err); }
|
||||
const every = orgsOf(all || body), o = orgsOf(body);
|
||||
const everyRows = arr((all || body).inbox);
|
||||
if (!noneRead(every, ['inbox'])) tellInbox(everyRows);
|
||||
const counts = new Map();
|
||||
for (const d of everyRows) counts.set(d.org, (counts.get(d.org) || 0) + 1);
|
||||
const rows = arr(body.inbox);
|
||||
const picked = want === 'none' ? null : rows.find(d => itemKey(d) === want) || (want ? null : rows[0] || null);
|
||||
const qs = d => { const p = new URLSearchParams(); if (org) p.set('org', org.join(',')); p.set('d', d ? itemKey(d) : 'none'); return `/inbox?${p}`; };
|
||||
const row = d => {
|
||||
const on = picked && itemKey(d) === itemKey(picked);
|
||||
return `<tr class="ws-row ws-edge ws-o${on ? ' on' : ''}" style="${o.style(d.org)}" data-ws-item="${esc(itemKey(d))}">
|
||||
<td class="src hide-m">${o.ref(d.org)}<span class="via">${fromWords(o, d, false)}</span></td>
|
||||
<td><span class="ws-msrc show-m">${av(o.meta(d.org), 'xs')}${fromWords(o, d)}</span><a class="q" href="${esc(selHref(qs(d)))}" data-ws-select="${esc(itemKey(d))}" data-focus-key="${esc(`item:${itemKey(d)}`)}"${on ? ' aria-current="true"' : ''}>${esc(d.question || `Decision ${d.id}`)}</a><span class="via">decision <span class="mono">${esc(d.id)}</span>${d.taskRef ? ` · task ${esc(d.taskRef)}` : ''}</span><span class="show-m">${classChip(d.class)}${d.blocking ? ' <span class="ws-chip block">blocking</span>' : ''}</span></td>
|
||||
<td class="hide-m">${classChip(d.class)}${d.blocking ? ' <span class="ws-chip block">blocking</span>' : ''}</td>
|
||||
<td class="num">${d.at ? `<time datetime="${esc(d.at)}">${esc(span(d.at))}</time>` : '<span class="muted">not reported</span>'}</td>
|
||||
</tr>`;
|
||||
};
|
||||
const unread = (o.unread.length ? `<p class="note ws-unread-note" data-ws-unread>Not in this list: ${andList(o.unread.map(stateWords))}.</p>` : '') + missLine(o, ['inbox'], 'inbox');
|
||||
const table = rows.length ? `<div class="ws-tbl"><table class="ws-ibx" data-ws-items><caption class="ws-sr">Items that need you</caption><thead><tr><th scope="col" class="hide-m">Source</th><th scope="col">Needs</th><th scope="col" class="hide-m">Class</th><th scope="col" class="num">Waiting</th></tr></thead><tbody>${rows.map(row).join('')}</tbody></table></div>` : `<p class="ws-panel tight note" data-ws-items>${noneRead(o, ['inbox']) ? 'Decisions not read' : org ? 'Nothing needs you in these organizations' : 'Nothing needs you'}</p>`;
|
||||
let insp = '';
|
||||
if (picked) {
|
||||
const d = picked, id = esc(d.id);
|
||||
const decideHref = `${orgHref(d.org)}/inbox/${enc(d.id)}`;
|
||||
const opts = arr(d.options);
|
||||
const optList = opts.length ? `<ul class="ws-opts">${opts.map(x => `<li><div class="row"><span class="k">${esc(x.key)}</span><b>${esc(x.text || '')}</b></div><div class="row"><a class="ws-btn" href="${esc(decideHref)}" data-focus-key="${esc(`answer:${itemKey(d)}:${x.key}`)}">${d.console ? `Answer ${esc(x.key)}…` : 'Open the decision'}<span class="ws-sr"> on ${esc(o.name(d.org))}'s bus</span></a></div></li>`).join('')}</ul>` : `<p class="note">No options were given. <a href="${esc(decideHref)}" data-focus-key="${esc(`answer:${itemKey(d)}`)}">Open the decision</a></p>`;
|
||||
const cmds = arr(d.decide).filter(c => typeof c?.command === 'string' && c.command);
|
||||
const term = cmds.length ? `<div data-ws-cmds><span class="ws-label">Answer from a terminal</span>${cmds.map(c => `<div class="ws-cmd"><code>${esc(c.command)}</code><button type="button" class="ws-btn" data-copy="${esc(c.command)}" data-focus-key="${esc(`copy:${itemKey(d)}:${c.key}`)}" aria-label="Copy the command for option ${esc(c.key)}" title="Copy">${icon('copy')}</button></div>`).join('')}<p class="note">The inbox mixes organizations, so the copied command always names the organization's bus.</p><p class="small muted" id="copy-status" role="status"></p></div>` : '<p class="note" data-ws-cmds>No terminal command for this item: its id or options cannot be passed to a shell safely.</p>';
|
||||
const by = roleOf(d);
|
||||
insp = `<aside class="ws-insp ws-o" style="${o.style(d.org)}" aria-label="Decision ${id}" data-ws-insp="${esc(itemKey(d))}">
|
||||
<header>${av(o.meta(d.org))}<span class="ws-label">Decision <span class="mono">${id}</span></span>${classChip(d.class)}<a class="x" href="${esc(selHref(qs(null)))}" data-ws-select="none" data-focus-key="insp-close" aria-label="Close the decision">${icon('close')}</a></header>
|
||||
<div class="body">
|
||||
<h3>${esc(d.question || `Decision ${d.id}`)}</h3>
|
||||
<div class="ws-auth">${esc(o.name(d.org))} · ${esc(d.project?.name || 'No project')}${by ? ` · raised by <b>${esc(by)}</b>` : ''}${d.at ? ` at ${esc(hm(d.at))}` : ''}.${d.class === 'gated' ? ' Only you can answer a gated decision.' : ''}</div>
|
||||
${d.task?.title || d.taskRef ? `<p class="note">Task ${esc(d.taskRef || '')}${d.task?.title ? ` · ${esc(d.task.title)}` : ''}</p>` : ''}
|
||||
${optList}
|
||||
${term}
|
||||
<div><span class="ws-label">History</span><p class="note">Not built yet.</p></div>
|
||||
</div>
|
||||
</aside>`;
|
||||
}
|
||||
const orgsWith = new Set(rows.map(d => d.org)).size;
|
||||
const sub = rows.length ? `${plural(rows.length, 'item')} need${rows.length === 1 ? 's' : ''} you, from ${plural(orgsWith, 'organization')}. Each row names where it came from. An item leaves only when it is answered.` : `${noneRead(o, ['inbox']) ? 'No organization\'s inbox could be read.' : missed(o, ['inbox']).length || o.unread.length ? 'Nothing needs you in the organizations that were read.' : 'Nothing needs you right now.'} An item leaves only when it is answered.`;
|
||||
return `<section class="ws ws-inbox" data-ws-inbox>
|
||||
<div class="ws-vh"><h1 tabindex="-1">Inbox</h1><p class="sub">${sub}</p></div>
|
||||
<div class="ws-toolbar">
|
||||
${orgChips(every, counts, org, everyRows.length, ['inbox'])}
|
||||
<div class="ws-right"><span class="ws-seg" role="group" aria-label="State" aria-describedby="ws-tabs-why"><button type="button" aria-pressed="true" data-focus-key="tab-needs">Needs you</button><button type="button" aria-pressed="false" disabled title="Not built yet">Seen</button><button type="button" aria-pressed="false" disabled title="Not built yet">Answered</button></span><span class="note" id="ws-tabs-why">Seen and Answered are not built yet.</span></div>
|
||||
</div>
|
||||
${unread}
|
||||
<div class="ws-ibx-grid${insp ? ' with-insp' : ''}"><div class="ws-ibx-list">${table}
|
||||
${secH('Answered today', 'kept for 24 hours, then in the trail')}
|
||||
<p class="ws-panel tight note" data-ws-answered>Answered and expired items are not built yet.</p></div>
|
||||
${insp}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// ---- controls: filters write the hash query and repaint in place ----
|
||||
const pathOf = () => route().split('?')[0];
|
||||
const queryOf = () => new URLSearchParams(route().split('?')[1] || '');
|
||||
function setQuery(change, render = true) {
|
||||
const q = queryOf();
|
||||
for (const [k, v] of Object.entries(change)) { if (v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length)) q.delete(k); else q.set(k, Array.isArray(v) ? v.join(',') : String(v)); }
|
||||
const s = q.toString();
|
||||
history.replaceState(history.state, '', `${selHref(pathOf())}${s ? `?${s}` : ''}`);
|
||||
if (render) document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
|
||||
}
|
||||
function setGrouping(g) {
|
||||
grouping = g === 'flat' ? 'flat' : 'org'; save(GROUPING, grouping); paint();
|
||||
if (pathOf() === '/projects') document.dispatchEvent(new CustomEvent('mosaic-workspace-render'));
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const page = e.target.closest?.('[data-ws-projects],[data-ws-inbox]');
|
||||
if (!page) return;
|
||||
const chip = e.target.closest('[data-ws-org]');
|
||||
if (chip) {
|
||||
const id = chip.dataset.wsOrg, now = listParam(queryOf(), 'org', x => ORG_ID.test(x)) || [];
|
||||
const next = !id ? [] : now.includes(id) ? now.filter(x => x !== id) : [...now, id];
|
||||
setQuery(page.hasAttribute('data-ws-inbox') ? { org: next, d: null } : { org: next });
|
||||
return;
|
||||
}
|
||||
const sort = e.target.closest('[data-ws-sort]');
|
||||
if (sort) {
|
||||
const f = projectQuery(queryOf()), s = sort.dataset.wsSort;
|
||||
setQuery(s === f.sort ? { sort: s, order: f.order === 'desc' ? 'asc' : 'desc' } : { sort: s, order: null });
|
||||
return;
|
||||
}
|
||||
const grp = e.target.closest('[data-ws-group]');
|
||||
if (grp) { setGrouping(grp.dataset.wsGroup); return; }
|
||||
const pick = e.target.closest('[data-ws-select]');
|
||||
if (pick && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) { e.preventDefault(); setQuery({ d: pick.dataset.wsSelect }); }
|
||||
});
|
||||
document.addEventListener('change', e => {
|
||||
if (e.target.matches?.('[data-ws-projects] [data-ws-status]')) setQuery({ status: e.target.value === 'active' ? null : e.target.value });
|
||||
else if (e.target.matches?.('[data-ws-projects] [data-ws-state]')) setQuery({ state: e.target.value || null });
|
||||
});
|
||||
// Typing writes the hash at once, so a poll repaint keeps what was typed; the read waits a beat.
|
||||
let typing;
|
||||
document.addEventListener('input', e => {
|
||||
if (!e.target.matches?.('[data-ws-projects] [data-ws-name]')) return;
|
||||
setQuery({ name: e.target.value.slice(0, 200) || null }, false);
|
||||
clearTimeout(typing); typing = setTimeout(() => document.dispatchEvent(new CustomEvent('mosaic-workspace-render')), 250);
|
||||
});
|
||||
|
||||
// U4b: the org mark (colour and initials) for the task board (tasks-board.js).
|
||||
const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials });
|
||||
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
|
||||
refresh();
|
||||
})();
|
||||
@@ -18,9 +18,12 @@ import { themesHTTP } from './themes-http.mjs';
|
||||
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
|
||||
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
|
||||
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
|
||||
import { SYSTEM_TYPES_ROUTE, systemTypesHTTP } from './system-types-http.mjs';
|
||||
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
|
||||
import { consoleRestartHTTP } from './console-restart-http.mjs';
|
||||
import { workspaceHTTP } from './workspace-read.mjs';
|
||||
import { agentLevelsHTTP } from './agent-levels-read.mjs';
|
||||
import { TASK_WRITE_ROUTE, workspaceTasksHTTP } from './workspace-tasks.mjs';
|
||||
import { actor } from './action-trail.mjs';
|
||||
import { devHostName } from '../../cli/src/dev-local-owner.mjs';
|
||||
|
||||
@@ -58,12 +61,12 @@ const files = new Map([
|
||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
|
||||
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js', 'tasks-board.js', 'panes.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
|
||||
['/files.css', ['files.css', 'text/css; charset=utf-8']],
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css', 'tasks-board.css', 'panes.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
['/display.js', ['display.js', 'text/javascript; charset=utf-8']],
|
||||
['/inbox.js', ['inbox.js', 'text/javascript; charset=utf-8']],
|
||||
['/inbox.css', ['inbox.css', 'text/css; charset=utf-8']],
|
||||
@@ -74,6 +77,9 @@ const files = new Map([
|
||||
['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
|
||||
['/icons.svg', ['icons.svg', 'image/svg+xml']],
|
||||
['/project-remote.css', ['project-remote.css', 'text/css; charset=utf-8']],
|
||||
// Phase P2 (#1873): the owner's System types control (dashboard section and project selection).
|
||||
['/system-types.js', ['system-types.js', 'text/javascript; charset=utf-8']],
|
||||
['/system-types.css', ['system-types.css', 'text/css; charset=utf-8']],
|
||||
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
|
||||
...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]),
|
||||
]);
|
||||
@@ -505,7 +511,9 @@ const AGENT_ROUTES = {
|
||||
};
|
||||
// F3: the writes the owner makes on a selected organization's bus from Settings (dev org Console).
|
||||
const ORG_OWNER_WRITE = path => path === '/api/settings' || path === '/api/instances' || path === '/api/routes'
|
||||
|| Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/');
|
||||
|| Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/')
|
||||
// Phase P2 (#1873): the System types enable switch and a project's selection; the route keeps its own checks.
|
||||
|| SYSTEM_TYPES_ROUTE.test(path);
|
||||
async function agentSend(req, res, { route, agentWrite, preview, log, note = null }) {
|
||||
let target = null;
|
||||
// The instance or name is quoted and cut short so a crafted value can't forge a log line. A reason is never logged.
|
||||
@@ -771,7 +779,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, systemTypesWrite = 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.
|
||||
@@ -794,6 +802,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// #797 / #1839 Task 3: the request intake reads need --sso, the writes also --send; never the tester.
|
||||
if ((requestRead !== null || requestWrite !== null) && (tester || sso === null || (requestRead !== null && typeof requestRead !== 'function') || (requestWrite !== null && (send === null || typeof requestWrite !== 'function'))))
|
||||
throw new Error('Console requests need --sso (and --send for the writes) on the main listener');
|
||||
// Phase P2 (#1873): the owner's System types writes need --sso and --send, never the tester.
|
||||
if (systemTypesWrite !== null && (tester || sso === null || send === null || typeof systemTypesWrite !== 'function')) throw new Error('System types writes need --sso and --send on the main listener');
|
||||
const confirmations = createConfirmations();
|
||||
const registered = routeTable();
|
||||
for (const registerRoutes of routes) registerRoutes(registered);
|
||||
@@ -808,11 +818,15 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
registered.add('POST', '/api/console/restart', (req, res, context) => consoleRestartHTTP(req, res, { ...context, restart, trail, boot, log }));
|
||||
// U1 (#1873): one read across every organization the owner belongs to (workspace-read.mjs).
|
||||
registered.add('GET', '/api/workspace', (req, res, context) => workspaceHTTP(req, res, { ...context, organizations, log, ...(workspaceTimeoutMs === undefined ? {} : { timeoutMs: workspaceTimeoutMs }) }));
|
||||
// #1873 A1: the agent levels behind the Agents grid. Read-only; owner only, like the workspace.
|
||||
registered.add('GET', '/api/agents/levels', (req, res, context) => agentLevelsHTTP(req, res, { ...context, organizations, levels: agentLevels, log, ...(workspaceTimeoutMs === undefined ? {} : { timeoutMs: workspaceTimeoutMs }) }));
|
||||
// U4b (#1873): the task board's writes, each to the one organization it names (workspace-tasks.mjs).
|
||||
registered.add('POST', TASK_WRITE_ROUTE, (req, res, context) => workspaceTasksHTTP(req, res, { ...context, organizations, trail, log }));
|
||||
const upstream = boardURL(board);
|
||||
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
|
||||
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
|
||||
if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso');
|
||||
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, upstream, files };
|
||||
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files };
|
||||
const hub = federationView(federation);
|
||||
// An object with none of the three is refused too: asking for the preview never serves without it.
|
||||
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
|
||||
@@ -826,7 +840,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
let boundAddress = null, boundPort = null;
|
||||
const server = createServer(async (req, res) => {
|
||||
// A granted user's request rebinds these below; every other request keeps the owner's.
|
||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, upstream, files } = owner, granted = false, chatGrants = [];
|
||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [];
|
||||
res.setHeader('x-content-type-options', 'nosniff');
|
||||
res.setHeader('referrer-policy', 'no-referrer');
|
||||
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
|
||||
@@ -896,7 +910,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// alone stays open on a loopback Host for the status and release gates (Q-C).
|
||||
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
|
||||
// No CORS. JSON content type and same-origin checks keep browser forms out.
|
||||
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path);
|
||||
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), systemTypesPath = SYSTEM_TYPES_ROUTE.test(path);
|
||||
if (req.headers.origin && req.headers.origin !== (named ? (sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`) : `http://${req.headers.host}`))
|
||||
return decisionPath ? decisionRefuse(res, 'cross-origin') : requestPath ? requestRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
|
||||
let signer = preview, ssoSession = null;
|
||||
@@ -908,7 +922,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!session && path !== '/healthz') {
|
||||
if (decisionPath) return decisionRefuse(res, 'sign-in-required');
|
||||
if (requestPath) return requestRefuse(res, 'sign-in-required');
|
||||
if (remotePath) return json(res, 401, { error: 'sign-in-required' });
|
||||
if (remotePath || systemTypesPath) return json(res, 401, { error: 'sign-in-required' });
|
||||
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
|
||||
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
|
||||
return res.end();
|
||||
@@ -922,7 +936,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
|
||||
if (!organizations && userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
|
||||
if (!organizations) ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
|
||||
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null;
|
||||
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null; systemTypesWrite = null;
|
||||
chatGrants = sso.chatGrants?.(session) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -953,7 +967,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
({ bus = null, reads = 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, upstream = null } = selected);
|
||||
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, upstream = null } = selected);
|
||||
}
|
||||
// F3: the owner's settings writes reach the selected organization's bus too (limits, routing,
|
||||
// agents, assistant, instances, model proxy). The routes below keep their own checks.
|
||||
@@ -1010,7 +1024,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
return projectRemoteHTTP(req, res, { path, read: requestRead, write: send ? requestWrite : null,
|
||||
session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin });
|
||||
}
|
||||
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
|
||||
// Phase P2 (#1873): the owner's System types read and writes on the selected organization.
|
||||
if (systemTypesPath) {
|
||||
if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' });
|
||||
return systemTypesHTTP(req, res, { path, read: requestRead, write: send ? systemTypesWrite : null,
|
||||
session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin });
|
||||
}
|
||||
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/system-types.js', '/system-types.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
|
||||
// Row 194 (#1692, D4.1): the tester also launches and terminates.
|
||||
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
|
||||
return json(res, 403, { error: 'read-only', sent: false });
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Phase P2 (#1873): the owner's System types routes on a selected organization. Owner Console
|
||||
// transports only; no caller-supplied business, actor or subject reaches the bus.
|
||||
//
|
||||
// GET /api/system-types installed modules (label, description) and the enabled set,
|
||||
// plus the plugin revision when writes are on
|
||||
// POST /api/system-types {revision, enabled}: plugins.set {'system-type': enabled},
|
||||
// the organization writer's owner fence, revision and receipt
|
||||
// GET /api/system-types/projects/<id> the same, with the project's selection, active and inactive
|
||||
// POST /api/system-types/projects/<id> {systemTypes}: project.system-types.set
|
||||
//
|
||||
// `read` is the owner's Console call (consoleCall): it reaches only `system-types.console`, which the
|
||||
// broker answers to the business human's console cap. `write` is the owner's human transport limited
|
||||
// to SYSTEM_TYPE_VERBS, present only for the owner's own session under --send.
|
||||
export const SYSTEM_TYPES_ROUTE = /^\/api\/system-types(?:\/projects\/([^/]+))?$/;
|
||||
export const SYSTEM_TYPE_VERBS = Object.freeze(['plugins', 'plugins.set', 'project.system-types.set']);
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
|
||||
// A module name, as plugins/src/kinds.mjs NAME.
|
||||
const NAME = /^[a-z][a-z0-9-]{0,63}$/;
|
||||
const REVISION = /^[a-f0-9]{64}$/;
|
||||
const TRANSACTION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const MAX_TYPES = 64;
|
||||
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
|
||||
const codes = {
|
||||
'invalid-request': 400, 'invalid-plugins': 400, 'unknown-plugin': 400, 'system-type-unknown': 400,
|
||||
'project-not-found': 404, 'revision-conflict': 409, 'organization-busy': 409, 'system-type-not-enabled': 409,
|
||||
'human-required': 403, 'organization-system-types-human-only': 403, 'read-only': 403,
|
||||
'not-configured': 503, 'system-type-invalid': 503, 'no-bus-host': 503,
|
||||
};
|
||||
function json(res, status, value) {
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
const names = list => Array.isArray(list) && list.length <= MAX_TYPES && list.every(x => typeof x === 'string' && NAME.test(x)) && new Set(list).size === list.length;
|
||||
const text = value => typeof value === 'string' && value.trim() !== '' && value.length <= 400 && !/[\r\n]/.test(value);
|
||||
function consoleShape(result, project) {
|
||||
if (!result || !Array.isArray(result.installed) || !names(result.enabled)) return null;
|
||||
const installed = [];
|
||||
for (const m of result.installed) {
|
||||
if (!m || !NAME.test(m.id ?? '') || !text(m.label) || !text(m.description)) return null;
|
||||
installed.push({ id: m.id, label: m.label, description: m.description });
|
||||
}
|
||||
if (new Set(installed.map(m => m.id)).size !== installed.length) return null;
|
||||
let selection = null;
|
||||
if (project !== null) {
|
||||
const p = result.project;
|
||||
if (!p || p.project !== project || !names(p.selected) || !names(p.active) || !names(p.inactive)) return null;
|
||||
selection = { project, selected: p.selected, active: p.active, inactive: p.inactive };
|
||||
} else if (result.project !== null) return null;
|
||||
return { installed, enabled: result.enabled, project: selection };
|
||||
}
|
||||
const selectionShape = (r, project) => r && r.project === project && names(r.selected) && names(r.active) && names(r.inactive)
|
||||
? { project, selected: r.selected, active: r.active, inactive: r.inactive } : null;
|
||||
|
||||
async function body(req, keys) {
|
||||
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return null;
|
||||
const chunks = []; let size = 0;
|
||||
for await (const chunk of req) { size += chunk.length; if (size > 4096) return 'too-large'; chunks.push(chunk); }
|
||||
let value;
|
||||
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return null; }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length || !keys.every(k => Object.hasOwn(value, k))) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function systemTypesHTTP(req, res, { path, read, write, session, live, origin }) {
|
||||
const refuse = (status, error) => json(res, status, { error });
|
||||
const fail = error => { const code = codes[error?.code] ? error.code : 'outcome-unknown'; return refuse(codes[code] ?? 503, code); };
|
||||
if (!['GET', 'POST'].includes(req.method)) return refuse(405, 'method-not-allowed');
|
||||
if (!session) return refuse(401, 'sign-in-required');
|
||||
if (!read) return refuse(503, 'not-configured');
|
||||
if (req.method === 'POST' && !write) return refuse(403, 'read-only');
|
||||
const site = one(req, 'sec-fetch-site'), from = one(req, 'origin');
|
||||
if (req.method === 'POST' ? from !== origin || site !== 'same-origin'
|
||||
: (req.headersDistinct.origin && from !== origin) || (req.headersDistinct['sec-fetch-site'] && !['same-origin', 'none'].includes(site)))
|
||||
return refuse(403, 'cross-origin');
|
||||
let project = null;
|
||||
const match = SYSTEM_TYPES_ROUTE.exec(path);
|
||||
if (match?.[1] !== undefined) {
|
||||
try { project = decodeURIComponent(match[1]); } catch { return refuse(404, 'project-not-found'); }
|
||||
if (!ID.test(project)) return refuse(404, 'project-not-found');
|
||||
}
|
||||
let args = null;
|
||||
if (req.method === 'POST') {
|
||||
const value = await body(req, project === null ? ['revision', 'enabled'] : ['systemTypes']);
|
||||
if (value === 'too-large') return refuse(413, 'invalid-request');
|
||||
if (!value) return refuse(400, 'invalid-request');
|
||||
if (project === null ? typeof value.revision !== 'string' || !REVISION.test(value.revision) || !names(value.enabled) : !names(value.systemTypes))
|
||||
return refuse(400, 'invalid-request');
|
||||
args = value;
|
||||
}
|
||||
if (live() !== session) return refuse(401, 'sign-in-required');
|
||||
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
|
||||
// The read comes first on every request: a write names only modules this organization has installed.
|
||||
let current;
|
||||
try { current = consoleShape(await read('system-types.console', project === null ? {} : { project }, { subject }), project); }
|
||||
catch (error) { return fail(error); }
|
||||
if (!current) return refuse(503, 'outcome-unknown');
|
||||
const installed = new Set(current.installed.map(m => m.id));
|
||||
|
||||
if (req.method === 'GET') {
|
||||
let revision = null;
|
||||
if (write && project === null) {
|
||||
// The enabled set and its revision come from one plugins read. The page sends back a whole
|
||||
// replacement list, so a list older than its revision would undo a change made in between.
|
||||
let plugins;
|
||||
try { plugins = await write('plugins', {}); } catch (error) { return fail(error); }
|
||||
revision = plugins?.revision ?? null;
|
||||
const enabled = plugins?.plugins?.['system-type'] ?? [];
|
||||
if (!REVISION.test(revision ?? '') || !names(enabled)) return refuse(503, 'outcome-unknown');
|
||||
current.enabled = enabled;
|
||||
}
|
||||
if (live() !== session) return refuse(401, 'sign-in-required');
|
||||
return json(res, 200, { ...current, writable: Boolean(write), ...(project === null && write ? { revision } : {}) });
|
||||
}
|
||||
|
||||
// The session is checked again after the read: a sign-out while it ran dispatches no write.
|
||||
if (live() !== session) return refuse(401, 'sign-in-required');
|
||||
|
||||
if (project === null) {
|
||||
if (args.enabled.some(id => !installed.has(id))) return refuse(400, 'system-type-unknown');
|
||||
let result;
|
||||
try { result = await write('plugins.set', { revision: args.revision, plugins: { 'system-type': args.enabled } }); }
|
||||
catch (error) { return fail(error); }
|
||||
// A write admitted at spawn may already have committed: its receipt is returned even if the
|
||||
// session ended meanwhile, never reported as not done.
|
||||
const enabled = result?.plugins?.['system-type'] ?? [];
|
||||
if (!result || !REVISION.test(result.revision ?? '') || !TRANSACTION.test(result.transaction ?? '') || !names(enabled)
|
||||
|| JSON.stringify(enabled) !== JSON.stringify(args.enabled))
|
||||
return refuse(503, 'outcome-unknown');
|
||||
return json(res, 200, { enabled, revision: result.revision, transaction: result.transaction });
|
||||
}
|
||||
|
||||
// The broker refuses a type the organization has not enabled (system-type-not-enabled); a type that
|
||||
// is not installed is refused here first.
|
||||
if (args.systemTypes.some(id => !installed.has(id))) return refuse(400, 'system-type-unknown');
|
||||
let result;
|
||||
try { result = selectionShape(await write('project.system-types.set', { project, systemTypes: args.systemTypes }), project); }
|
||||
catch (error) { return fail(error); }
|
||||
if (!result || JSON.stringify(result.selected) !== JSON.stringify(args.systemTypes)) return refuse(503, 'outcome-unknown');
|
||||
return json(res, 200, result);
|
||||
}
|
||||
@@ -23,6 +23,16 @@ const PROJECT_SELECTOR = /^([a-z0-9][a-z0-9._-]{0,63}):([A-Za-z0-9._-]{1,128})$/
|
||||
const SHELL_SAFE = /^[A-Za-z0-9._:-]{1,128}$/;
|
||||
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
||||
const PER_ORG_CONCURRENCY = 3;
|
||||
// U4b (#1873): the task board's states, the built-in store's (broker STORE_STATES), in board order.
|
||||
export const TASK_STATES = Object.freeze(['todo', 'in-progress', 'in-review', 'blocked', 'done']);
|
||||
// U4b: at most this many tasks per organization reach one answer. The bus's tasks read has no
|
||||
// cursor for the merged form, so the cap bounds the answer and the board's DOM. It applies last:
|
||||
// projects, metrics, sessions and the inbox count every task, and the filters run before it, so a
|
||||
// filter reaches every task the org holds. An org past it keeps its most recently changed tasks
|
||||
// and says how many matched (`tasksHeld`); the board says the list is cut.
|
||||
export const TASKS_PER_ORG = 500;
|
||||
const ROLE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
// The broker reads each section needs. Each name is one human-side read the org pages already make.
|
||||
const NEEDS = {
|
||||
@@ -57,7 +67,7 @@ function oneOf(name, value, allowed) {
|
||||
// Strict: an unknown or repeated key is refused, so a misspelled filter never reads as "all".
|
||||
export function parseWorkspaceQuery(search = '') {
|
||||
const params = new URLSearchParams(search), seen = new Set();
|
||||
const q = { sections: new Set(WORKSPACE_SECTIONS), orgs: null, status: new Set(['active']), state: null, name: null, sort: 'activity', order: null, project: null };
|
||||
const q = { sections: new Set(WORKSPACE_SECTIONS), orgs: null, status: new Set(['active']), state: null, name: null, sort: 'activity', order: null, project: null, taskState: null, assignee: null, label: null };
|
||||
for (const [key, value] of params) {
|
||||
if (seen.has(key)) bad(`${key} is given twice`);
|
||||
seen.add(key);
|
||||
@@ -77,6 +87,17 @@ export function parseWorkspaceQuery(search = '') {
|
||||
const m = PROJECT_SELECTOR.exec(value);
|
||||
if (!m) bad('project takes <org>:<project id or key>');
|
||||
q.project = { org: m[1], key: m[2] };
|
||||
// U4b: the task filters. A Vikunja task whose bucket the bus does not name has state null and
|
||||
// matches only `open`.
|
||||
} else if (key === 'task-state') q.taskState = oneOf('task-state', value, [...TASK_STATES, 'open']);
|
||||
else if (key === 'assignee') {
|
||||
const names = list(value);
|
||||
if (!names.length || names.some(n => !ROLE_NAME.test(n))) bad('assignee takes role names, comma separated');
|
||||
q.assignee = new Set(names);
|
||||
} else if (key === 'label') {
|
||||
const ids = list(value).map(Number);
|
||||
if (!ids.length || ids.some(n => !Number.isSafeInteger(n) || n < 1)) bad('label takes label ids, comma separated');
|
||||
q.label = new Set(ids);
|
||||
} else bad(`unknown query key ${key}`);
|
||||
}
|
||||
q.order ??= q.sort === 'activity' ? 'desc' : 'asc';
|
||||
@@ -172,7 +193,8 @@ function validRead(name, value) {
|
||||
if (name === 'claims') return strings(v, ['thread', 'instance', 'holder_run', 'harness', 'at']);
|
||||
if (name === 'inbox') return string(v.id) && strings(v, ['question', 'class', 'raised_by_role', 'task_ref', 'at']) && optional(v.options, a => records(a) && a.every(o => strings(o, ['key', 'text'])));
|
||||
if (name === 'agents') return strings(v, ['role', 'holder_run', 'harness', 'state', 'at']) && optional(v.working, w => record(w) && strings(w, ['since'])) && ['inputNeeded', 'stranded'].every(k => optional(v[k], records)) && (v.inputNeeded ?? []).every(d => strings(d, ['task_ref', 'at']));
|
||||
if (name === 'tasks') return strings(v, ['task_ref', 'project_key', 'at']) && optional(v.fields, f => record(f) && strings(f, ['title', 'bucket']) && ['assignees', 'labels'].every(k => optional(f[k], Array.isArray)));
|
||||
// A Vikunja row's bucket is the bucket's numeric id (tasks digest.mjs taskFields); a store row's is its state.
|
||||
if (name === 'tasks') return strings(v, ['task_ref', 'project_key', 'at']) && optional(v.fields, f => record(f) && strings(f, ['title']) && optional(f.bucket, b => string(b) || Number.isSafeInteger(b)) && ['assignees', 'labels'].every(k => optional(f[k], Array.isArray)));
|
||||
if (name === 'decisions') return strings(v, ['at']) && optional(v.closed, c => record(c) && strings(c, ['op', 'at']));
|
||||
return true;
|
||||
});
|
||||
@@ -188,14 +210,25 @@ function orgRows(org, data, doc, nowMs) {
|
||||
const byId = new Map(projects.map(p => [p.id, p]));
|
||||
const ref = p => (p ? { id: p.id, key: p.repo ?? null, name: p.name ?? null } : null);
|
||||
const threadProject = new Map(arr(data.threads).map(t => [t.id, byId.get(t.project) ?? null]));
|
||||
const taskRows = arr(data.tasks).filter(t => t && typeof t.task_ref === 'string' && !t.fields?.gone);
|
||||
const taskRows = arr(data.tasks).filter(t => t && typeof t.task_ref === 'string' && !t.fields?.gone && t.archived !== true);
|
||||
const bots = new Map(Object.entries(doc?.roles ?? {}).filter(([, r]) => r?.tracker?.botId !== undefined).map(([role, r]) => [r.tracker.botId, role]));
|
||||
const tasks = taskRows.map(t => {
|
||||
const f = t.fields ?? {};
|
||||
// U4b: a built-in store row (source 'store') carries role names, its state and its group; the
|
||||
// board files a store task under the org project whose id or key is its group. A Vikunja row
|
||||
// carries bot ids, which map to roles through the org file, and a numeric bucket the bus does
|
||||
// not name, so its state is only known when it is done.
|
||||
const store = t.source === 'store';
|
||||
const state = store ? (TASK_STATES.includes(f.state) ? f.state : null) : (f.done ? 'done' : null);
|
||||
const group = store && typeof f.group === 'string' ? f.group : null;
|
||||
const project = store ? (group === null ? null : byId.get(group) ?? byKey.get(group) ?? null) : (typeof t.project_key === 'string' ? byKey.get(t.project_key) : null);
|
||||
return {
|
||||
org: id, ref: t.task_ref, tracker: org.tracker, title: f.title ?? null, done: Boolean(f.done), bucket: f.bucket ?? null,
|
||||
assignees: arr(f.assignees).filter(u => bots.has(u)).map(u => bots.get(u)).sort(), labels: arr(f.labels), priority: t.priority ?? null,
|
||||
requirement: t.requirement ?? null, project: ref(typeof t.project_key === 'string' ? byKey.get(t.project_key) : null), changedAt: t.at ?? null,
|
||||
org: id, ref: t.task_ref, tracker: store ? 'built-in' : t.source === undefined ? org.tracker : 'vikunja', title: f.title ?? null, done: Boolean(f.done), bucket: f.bucket ?? null,
|
||||
state, assignees: store ? arr(f.assignees).filter(r => typeof r === 'string').sort() : arr(f.assignees).filter(u => bots.has(u)).map(u => bots.get(u)).sort(),
|
||||
labels: arr(f.labels), priority: t.priority ?? null, dueDate: typeof f.due_date === 'string' ? f.due_date : null,
|
||||
requirement: t.requirement ?? null, project: ref(project), group, changedAt: t.at ?? null,
|
||||
// The digest a write sends back as `expect`; only a store task is written from the board.
|
||||
digest: store && typeof t.digest === 'string' && DIGEST.test(t.digest) ? t.digest : null, writable: store,
|
||||
};
|
||||
});
|
||||
const taskByRef = new Map(tasks.map(t => [t.ref, t]));
|
||||
@@ -303,8 +336,20 @@ export async function readWorkspace({ organizations, session, query = parseWorks
|
||||
if (query.sections.has('inbox')) body.inbox = merged.inbox.sort((a, b) => (a.class === 'gated' ? 0 : 1) - (b.class === 'gated' ? 0 : 1) || (a.at ?? '').localeCompare(b.at ?? ''));
|
||||
if (query.sections.has('tasks')) {
|
||||
const p = query.project;
|
||||
body.tasks = merged.tasks.filter(t => !p || (t.org === p.org && (t.project?.id === p.key || t.project?.key === p.key)))
|
||||
.sort((a, b) => a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref));
|
||||
const matched = merged.tasks.filter(t => (!p || (t.org === p.org && (t.project?.id === p.key || t.project?.key === p.key)))
|
||||
&& (!query.taskState || query.taskState.has(t.state ?? 'open'))
|
||||
&& (!query.assignee || t.assignees.some(a => query.assignee.has(a)))
|
||||
&& (!query.label || t.labels.some(l => query.label.has(l))));
|
||||
// The cap, per org, after the filters: the most recently changed tasks stay.
|
||||
const byOrg = Map.groupBy(matched, t => t.org), kept = [];
|
||||
for (const r of read) {
|
||||
const rows = byOrg.get(r.org.org) ?? [];
|
||||
if (rows.length > TASKS_PER_ORG) {
|
||||
r.org.tasksHeld = rows.length;
|
||||
kept.push(...rows.sort((a, b) => (b.changedAt ?? '').localeCompare(a.changedAt ?? '') || b.ref.localeCompare(a.ref, undefined, { numeric: true })).slice(0, TASKS_PER_ORG));
|
||||
} else kept.push(...rows);
|
||||
}
|
||||
body.tasks = kept.sort((a, b) => a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref, undefined, { numeric: true }));
|
||||
}
|
||||
if (query.sections.has('metrics')) {
|
||||
const m = merged.metrics;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// U4b (#1873, PRD 1.20): the task board's writes, POST /api/workspace/tasks.
|
||||
//
|
||||
// One write goes to one organization's bus. The body names the org; the org is resolved for this
|
||||
// session through the same admission the org pages use (organizations.mjs `resolve`), and the write
|
||||
// goes out on that org's own transport (`taskWrite`, console-bus.mjs) and nowhere else. The verbs are
|
||||
// the person's built-in store verbs (tasks.create, tasks.edit, tasks.archive): the bus runs its own
|
||||
// human check on every one, so a Console started by an agent gets human-required and writes nothing.
|
||||
//
|
||||
// The route table's hook fence has already required a signed-in session on the page's own name, the
|
||||
// exact Origin and Sec-Fetch-Site: same-origin. This handler adds the owner, --send and a strict JSON
|
||||
// body. A refusal is answered with the bus's code and a fixed message; nothing is retried.
|
||||
import { actor } from './action-trail.mjs';
|
||||
import { TASK_STATES } from './workspace-read.mjs';
|
||||
|
||||
export const TASK_WRITE_ROUTE = '/api/workspace/tasks';
|
||||
const MAX_BODY = 16384;
|
||||
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const STORE_REF = /^task:[1-9][0-9]{0,14}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const REQUIREMENT = /^REQ-[A-Z]+-[1-9][0-9]*$/;
|
||||
const ROLE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const DUE = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/;
|
||||
// Each op: its bus verb, the keys it takes and the ones it needs. The board edits title, state,
|
||||
// assignees, priority and due date; a create also names the requirement and the project (group).
|
||||
const OPS = {
|
||||
create: { verb: 'tasks.create', keys: ['title', 'requirement', 'group', 'state', 'assignees', 'priority', 'due_date'], need: ['title', 'requirement'] },
|
||||
// An edit or archive names the digest the board read, so a change made since is a task-conflict
|
||||
// and never a silent overwrite. The bus checks it; the route refuses a write without one.
|
||||
edit: { verb: 'tasks.edit', keys: ['task_ref', 'expect', 'title', 'state', 'assignees', 'priority', 'due_date'], need: ['task_ref', 'expect'] },
|
||||
archive: { verb: 'tasks.archive', keys: ['task_ref', 'expect'], need: ['task_ref', 'expect'] },
|
||||
};
|
||||
const FIELD_CHECKS = {
|
||||
task_ref: v => typeof v === 'string' && STORE_REF.test(v),
|
||||
expect: v => typeof v === 'string' && DIGEST.test(v),
|
||||
title: v => typeof v === 'string' && v.trim().length >= 1 && v.length <= 250 && !v.includes('\0'),
|
||||
requirement: v => typeof v === 'string' && REQUIREMENT.test(v),
|
||||
group: v => v === null || (typeof v === 'string' && v.length >= 1 && v.length <= 64 && !/[\u0000-\u001f\u007f]/.test(v)),
|
||||
state: v => TASK_STATES.includes(v),
|
||||
assignees: v => Array.isArray(v) && v.length <= 50 && v.every(r => typeof r === 'string' && ROLE_NAME.test(r)),
|
||||
priority: v => Number.isSafeInteger(v) && v >= 0 && v <= 5,
|
||||
due_date: v => v === null || (typeof v === 'string' && DUE.test(v) && Number.isFinite(Date.parse(v)) && new Date(v).toISOString() === v),
|
||||
};
|
||||
|
||||
// The answers a person reads on the board, by bus code. Each says whether anything was written.
|
||||
const REFUSALS = {
|
||||
'human-required': [403, 'The bus takes task changes only from a person at the Console. Nothing was written.'],
|
||||
'tracker-not-store': [409, 'This organization keeps its tasks in Vikunja. The board does not write to Vikunja; nothing was written.'],
|
||||
'task-conflict': [409, 'The task changed after the board read it. Nothing was written; the board now shows the current task.'],
|
||||
'task-archived': [409, 'The task is archived. Nothing was written.'],
|
||||
'task-already-archived': [409, 'The task was already archived. Nothing was written.'],
|
||||
'task-not-found': [404, 'The task is not on this organization\'s bus. Nothing was written.'],
|
||||
'task-position-full': [409, 'This project has no room for another task position. Nothing was written.'],
|
||||
'invalid-state': [400, 'The bus does not have that state. Nothing was written.'],
|
||||
'unknown-role': [400, 'An assignee is not a role in this organization. Nothing was written.'],
|
||||
'invalid-request': [400, 'The bus refused the change as invalid. Nothing was written.'],
|
||||
'credential-leak': [400, 'The change holds part of a stored credential, so the bus refused it. Nothing was written.'],
|
||||
'no-bus-host': [503, 'This organization\'s bus is not running. Nothing was written.'],
|
||||
'outcome-unknown': [503, 'The bus did not answer. The change may or may not have been written; the board shows it on its next read.'],
|
||||
};
|
||||
const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large', 'outcome-unknown-revoked'];
|
||||
|
||||
const json = (res, status, body) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
const one = (req, name) => (req.headersDistinct?.[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
||||
class BodyError extends Error { constructor(status, message) { super(message); this.status = status; } }
|
||||
|
||||
async function readBody(req) {
|
||||
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(one(req, 'content-type') ?? '')) throw new BodyError(415, 'JSON body required');
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY) throw new BodyError(413, 'body too large');
|
||||
chunks.push(chunk);
|
||||
}
|
||||
let value;
|
||||
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new BodyError(400, 'invalid JSON'); }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BodyError(400, 'the body is an object');
|
||||
return value;
|
||||
}
|
||||
|
||||
// Strict: an unknown key, a missing required key or a malformed value is refused before any bus.
|
||||
export function parseTaskWrite(body) {
|
||||
const { op, org, ...rest } = body;
|
||||
const spec = OPS[op];
|
||||
if (!spec) throw new BodyError(400, `op takes ${Object.keys(OPS).join(', ')}`);
|
||||
if (typeof org !== 'string' || !ORG_ID.test(org)) throw new BodyError(400, 'org takes an organization id');
|
||||
for (const key of Object.keys(rest)) if (!spec.keys.includes(key)) throw new BodyError(400, `${op} does not take ${key}`);
|
||||
for (const key of spec.need) if (!Object.hasOwn(rest, key)) throw new BodyError(400, `${op} needs ${key}`);
|
||||
for (const [key, value] of Object.entries(rest)) if (!FIELD_CHECKS[key](value)) throw new BodyError(400, `${key} is not valid`);
|
||||
if (op === 'edit' && !Object.keys(rest).some(k => !['task_ref', 'expect'].includes(k))) throw new BodyError(400, 'edit needs a field to change');
|
||||
return { op, org, verb: spec.verb, args: rest };
|
||||
}
|
||||
|
||||
// `organizations.resolve(id, session)` admits only an org this session belongs to and answers its
|
||||
// options, `taskWrite` among them. Nothing here holds another org's transport.
|
||||
export async function workspaceTasksHTTP(req, res, { session = null, owner = false, send = false, organizations = null, trail = null, log = () => {} }) {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
if (!organizations || typeof organizations.resolve !== 'function') return json(res, 404, { error: 'org-unavailable' });
|
||||
if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'the task board\'s changes are the owner\'s' });
|
||||
if (!send) return json(res, 403, { error: 'read-only', message: 'start the Console with --send to make changes' });
|
||||
let write;
|
||||
try { write = parseTaskWrite(await readBody(req)); }
|
||||
catch (e) { if (e instanceof BodyError) return json(res, e.status, { error: 'invalid-request', message: e.message, sent: false }); throw e; }
|
||||
let selected;
|
||||
try { selected = await organizations.resolve(write.org, session); }
|
||||
catch (e) {
|
||||
if (e?.code === 'org-archived') return json(res, 409, { error: 'org-archived', message: 'organization is archived', sent: false });
|
||||
return json(res, 404, { error: 'org-unavailable', sent: false });
|
||||
}
|
||||
if (!selected) return json(res, 404, { error: 'org-unavailable', sent: false });
|
||||
if (selected.busHealthy === false) return json(res, 503, { error: 'no-bus-host', message: REFUSALS['no-bus-host'][1], sent: false });
|
||||
if (typeof selected.taskWrite !== 'function') return json(res, 403, { error: 'read-only', message: 'this organization takes no task changes from this Console', sent: false });
|
||||
const note = outcome => {
|
||||
try { trail?.({ by: actor(session), org: write.org, action: write.verb, ...(write.args.task_ref ? { task: write.args.task_ref } : {}), outcome }); }
|
||||
catch { log(`${new Date().toISOString()} trail write failed for ${write.verb}`); }
|
||||
};
|
||||
let result;
|
||||
try { result = await selected.taskWrite(write.verb, write.args); }
|
||||
catch (e) {
|
||||
const code = typeof e?.code === 'string' && /^[a-z-]{1,64}$/.test(e.code) ? e.code : 'outcome-unknown';
|
||||
const open = OPEN.includes(code);
|
||||
const [status, message] = REFUSALS[open ? 'outcome-unknown' : code] ?? [502, `The bus refused the change (${code}). Nothing was written.`];
|
||||
log(`${new Date().toISOString()} task board: ${write.verb} on ${write.org} refused ${code}`);
|
||||
note(open ? 'unknown' : `refused ${code}`);
|
||||
return json(res, status, { error: open ? 'outcome-unknown' : code, message, sent: open ? 'unknown' : false });
|
||||
}
|
||||
note('written');
|
||||
const task = result && typeof result === 'object' && typeof result.task_ref === 'string' ? result.task_ref : write.args.task_ref ?? null;
|
||||
return json(res, 200, { ok: true, org: write.org, op: write.op, task_ref: task, digest: typeof result?.digest === 'string' ? result.digest : null });
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// #1873 Phase A slice A1: GET /api/agents/levels, the read behind the Agents grid's level and source
|
||||
// columns. Owner only, GET only; a bad root file, organization or project hides only itself, and
|
||||
// no body carries a host path.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { agentLevelsHTTP } from '../src/agent-levels-read.mjs';
|
||||
import { checkRootAgents, loadRootAgents } from '../../organization/src/index.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const rolesDir = fileURLToPath(new URL('../../../roles', import.meta.url));
|
||||
const CTX = { configDir: '/home/owner/.config/mosaic-test', dataRoot: '/home/owner/.mosaic-test', instance: 'test' };
|
||||
const DOCS = {
|
||||
acme: { roles: { chief: { definition: 'chief-of-staff' }, writer: { definition: 'copywriter', vars: { model: 'sonnet-5.5' } } },
|
||||
projects: { web: { root: '/srv/secret/web', agents: { checker: { definition: 'fact-checker' }, writer: { vars: { 'limits.network': 'none' } } } },
|
||||
broken: { root: '/srv/secret/broken', agents: { ghost: { exclude: true } } } } },
|
||||
bad: null,
|
||||
};
|
||||
const members = async () => [{ id: 'acme', doc: DOCS.acme }, { id: 'bad', invalid: true, reason: 'organization file is not valid JSON' }];
|
||||
const root = () => ({ state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { assistant: { definition: 'assistant' }, reviewer: { definition: 'reviewer', excludeOrgs: ['bad'] } } }, { rolesDir }) });
|
||||
|
||||
function call(options, method = 'GET') {
|
||||
const res = { status: 0, body: null, writeHead(status) { this.status = status; }, end(text) { this.body = JSON.parse(text); } };
|
||||
return agentLevelsHTTP({ method }, res, options).then(() => res);
|
||||
}
|
||||
|
||||
test('the route refuses a write, a missing read, a non-owner and an unreadable registry', async () => {
|
||||
const levels = { loadRoot: root, rolesDir, ctx: CTX };
|
||||
assert.equal((await call({ owner: true, session: {}, organizations: { members }, levels }, 'POST')).status, 405);
|
||||
assert.equal((await call({ owner: true, session: {}, organizations: null, levels })).status, 404);
|
||||
assert.equal((await call({ owner: true, session: {}, organizations: { members }, levels: null })).status, 404);
|
||||
const denied = await call({ owner: false, session: {}, organizations: { members }, levels });
|
||||
assert.equal(denied.status, 403); assert.equal(denied.body.error, 'owner-only');
|
||||
const down = await call({ owner: true, session: {}, organizations: { members: async () => { throw new Error('registry down'); } }, levels, log: () => {} });
|
||||
assert.equal(down.status, 503); assert.doesNotMatch(JSON.stringify(down.body), /registry down/);
|
||||
});
|
||||
|
||||
test('the owner reads every level; a bad project or organization hides only itself; no host path', async t => {
|
||||
const levels = { loadRoot: root, rolesDir, ctx: CTX };
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: { list: async () => [], resolve: async () => null, members }, agentLevels: levels, log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const response = await fetch(`http://127.0.0.1:${server.address().port}/api/agents/levels`);
|
||||
assert.equal(response.status, 200);
|
||||
const text = await response.text(), body = JSON.parse(text);
|
||||
assert.equal(body.root.state, 'ok'); assert.equal(body.root.assistant, 'assistant');
|
||||
assert.deepEqual(body.root.agents.map(a => [a.name, a.excludeOrgs]), [['assistant', []], ['reviewer', ['bad']]]);
|
||||
const acme = body.orgs.find(o => o.id === 'acme');
|
||||
assert.equal(acme.state, 'ok');
|
||||
assert.deepEqual(acme.base.agents.map(a => `${a.level}:${a.name}`), ['root:reviewer', 'org:chief', 'org:writer']);
|
||||
assert.deepEqual(acme.projects.web.agents.map(a => `${a.level}:${a.name}`), ['root:reviewer', 'org:chief', 'org:writer', 'project:checker']);
|
||||
assert.deepEqual(acme.projects.web.agents.find(a => a.name === 'writer'), { name: 'writer', definition: 'copywriter', level: 'org', model: 'sonnet-5.5', narrowed: ['limits.network'], overrides: [], scope: { kind: 'org', org: 'acme' } });
|
||||
assert.equal(acme.projects.broken.state, 'invalid'); assert.match(acme.projects.broken.reason, /names no root or organization agent to exclude/);
|
||||
assert.deepEqual(acme.defined.web.map(e => e.form).sort(), ['define', 'narrow']);
|
||||
assert.deepEqual(body.orgs.find(o => o.id === 'bad'), { id: 'bad', state: 'invalid', reason: 'organization file is not valid JSON' });
|
||||
// Only the model and the names of narrowed keys travel; never a root path or another var's value.
|
||||
assert.doesNotMatch(text, /\/srv\/secret|"none"|"vars"/);
|
||||
// Signed out: the hook fence refuses before the route runs.
|
||||
const anon = await startServer({ port: 0, sso: { ...createDevOwner(), session: () => null }, organizations: { list: async () => [], resolve: async () => null, members }, agentLevels: levels, log: () => {} });
|
||||
t.after(() => close(anon));
|
||||
assert.notEqual((await fetch(`http://127.0.0.1:${anon.address().port}/api/agents/levels`, { redirect: 'manual' })).status, 200);
|
||||
});
|
||||
|
||||
test('a refused root file hides the root level only, and says why without a host path', async () => {
|
||||
const levels = { loadRoot: () => loadRootAgents('/nonexistent-dir-for-test', { rolesDir }), rolesDir, ctx: CTX };
|
||||
// A missing file is no root agents, not a refusal.
|
||||
const absent = await call({ owner: true, session: {}, organizations: { members }, levels });
|
||||
assert.deepEqual(absent.body.root, { state: 'absent', agents: [], assistant: null });
|
||||
const refusing = { ...levels, loadRoot: () => { const e = new Error(`root agent file must belong to uid 1000 (${CTX.configDir}/agents.json)`); e.name = 'BusinessError'; throw e; } };
|
||||
const res = await call({ owner: true, session: {}, organizations: { members }, levels: refusing });
|
||||
assert.equal(res.status, 200); assert.equal(res.body.root.state, 'invalid');
|
||||
assert.match(res.body.root.reason, /must belong to uid 1000/); assert.doesNotMatch(res.body.root.reason, /\/home\/owner/);
|
||||
assert.equal(res.body.orgs.find(o => o.id === 'acme').state, 'ok');
|
||||
const crash = await call({ owner: true, session: {}, organizations: { members }, levels: { ...levels, loadRoot: () => { throw new Error('raw parser bytes {"x"'); } } });
|
||||
assert.equal(crash.body.root.reason, 'could not be read');
|
||||
// A malformed file read through the real loader: the reason names the file, never the parser's quote of its bytes.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'a'));
|
||||
writeFileSync(join(dir, 'agents.json'), '{"agentsVersion": 1, "agents": {"x": secret-marker-7731}}', { mode: 0o600 });
|
||||
const bad = await call({ owner: true, session: {}, organizations: { members }, levels: { ...levels, loadRoot: () => loadRootAgents(dir, { rolesDir }) } });
|
||||
assert.equal(bad.body.root.state, 'invalid');
|
||||
assert.equal(bad.body.root.reason, 'root agent file is not valid JSON (agents.json)');
|
||||
assert.doesNotMatch(JSON.stringify(bad.body), /secret-marker|Unexpected token|position/);
|
||||
// A var value the root file sets wrongly never reaches the body; the key does.
|
||||
writeFileSync(join(dir, 'agents.json'), JSON.stringify({ agentsVersion: 1, agents: { x: { definition: 'reviewer', vars: { thinking: 'sk-secret-marker-7731' } } } }));
|
||||
const badVar = await call({ owner: true, session: {}, organizations: { members }, levels: { ...levels, loadRoot: () => loadRootAgents(dir, { rolesDir }) } });
|
||||
assert.equal(badVar.body.root.state, 'invalid');
|
||||
assert.match(badVar.body.root.reason, /variable thinking 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, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it. P2 (#1873) adds system-types.js after project-remote.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', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/system-types.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { installedSystemTypes } from '../../../plugins/src/system-types.mjs';
|
||||
|
||||
// Phase P2 (#1873): a per-organization stand-in for the two transports the System types routes use,
|
||||
// shaped as the broker answers them. `requestRead` answers system-types.console with the installed
|
||||
// modules (the plug-in data itself, read from packages/plugins), the enabled set and a project's
|
||||
// selection. `systemTypesWrite` answers plugins, plugins.set (revision checked, a transaction
|
||||
// returned) and project.system-types.set (enabled types only). `owner: false` makes plugins.set
|
||||
// refuse as the organization writer's owner fence does for anyone but the human.
|
||||
export async function systemTypesFake({ enabled = [], projects = {}, owner = true } = {}) {
|
||||
const installed = await installedSystemTypes();
|
||||
const state = { enabled: [...enabled], projects: structuredClone(projects), n: 0, writes: [] };
|
||||
const revision = () => createHash('sha256').update(`system-types-${state.n}`).digest('hex');
|
||||
const fail = code => { throw Object.assign(new Error(code), { code }); };
|
||||
const selection = project => {
|
||||
const selected = state.projects[project];
|
||||
return { project, selected: [...selected], active: selected.filter(x => state.enabled.includes(x)), inactive: selected.filter(x => !state.enabled.includes(x)) };
|
||||
};
|
||||
const requestRead = async (verb, args) => {
|
||||
if (verb !== 'system-types.console') fail('invalid-request');
|
||||
if (args.project !== undefined && !Object.hasOwn(state.projects, args.project)) fail('project-not-found');
|
||||
return { installed: structuredClone(installed), enabled: [...state.enabled], project: args.project === undefined ? null : selection(args.project) };
|
||||
};
|
||||
const systemTypesWrite = async (verb, args) => {
|
||||
state.writes.push(verb);
|
||||
if (verb === 'plugins') return { business: 'fake', revision: revision(), plugins: { 'system-type': [...state.enabled] } };
|
||||
if (verb === 'plugins.set') {
|
||||
if (args.revision !== revision()) fail('revision-conflict');
|
||||
if (!owner) fail('organization-system-types-human-only');
|
||||
state.enabled = [...args.plugins['system-type']]; state.n++;
|
||||
return { business: 'fake', revision: revision(), plugins: { 'system-type': [...state.enabled] }, transaction: randomUUID() };
|
||||
}
|
||||
if (verb === 'project.system-types.set') {
|
||||
if (!Object.hasOwn(state.projects, args.project)) fail('project-not-found');
|
||||
if (args.systemTypes.some(x => !state.enabled.includes(x))) fail('system-type-not-enabled');
|
||||
state.projects[args.project] = [...args.systemTypes];
|
||||
return selection(args.project);
|
||||
}
|
||||
fail('invalid-request');
|
||||
};
|
||||
return { requestRead, systemTypesWrite, state, installed };
|
||||
}
|
||||
@@ -131,7 +131,8 @@ test('owner can start a down bus after setup recorded done and sees recovery gui
|
||||
const f = await fixture(t);
|
||||
await f.b.evaluate(`(()=>{const old=window.fetch;window.fetch=async (input,init={})=>{const u=String(input);if(u.endsWith('/setup') && (!init.method || init.method==='GET'))return new Response(JSON.stringify({notifications:{state:'done'},queue:{state:'done'},projects:[],bus:{state:'done',verb:'mosaic bus start alpha'}}),{status:200});if(u.endsWith('/setup/bus') && init.method==='POST')return new Response(JSON.stringify({error:'stale-lock',message:'stale bus host lock'}),{status:409});return old(input,init)}})()`);
|
||||
await f.open('alpha');
|
||||
assert.equal(await f.b.evaluate(`!!document.querySelector('[data-step="bus"] [data-run="bus"]:not(:disabled)')`), true);
|
||||
// The setup steps render after the editor title, once the setup and organization list reads answer.
|
||||
await f.wait(`!!document.querySelector('[data-step="bus"] [data-run="bus"]:not(:disabled)')`);
|
||||
await f.b.evaluate(`document.querySelector('[data-step="bus"] [data-run="bus"]').click()`);
|
||||
await f.wait(`document.querySelector('[data-step="bus"]').innerText.includes('stale bus host lock')`);
|
||||
assert.match(await f.b.evaluate(`document.querySelector('[data-step="bus"]').innerText`), /mosaic bus recover alpha/);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ test('an allowed LAN name loads the org page and creates a project', { timeout:
|
||||
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
const input = (selector, value) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
|
||||
const click = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`);
|
||||
await b.navigate(`http://lan.test:${port}/#/orgs/alpha/projects`);
|
||||
await b.navigate(`http://lan.test:${port}/#/orgs/alpha/chat`);
|
||||
assert.deepEqual(await b.evaluate('({ host: location.host, secure: isSecureContext, uuid: typeof crypto.randomUUID })'), { host: `lan.test:${port}`, secure: false, uuid: 'undefined' });
|
||||
await wait('window.MosaicOrg?.id === "alpha" && !!document.querySelector("#chat-repo option[value=work]")');
|
||||
await click('.project-create summary');
|
||||
|
||||
@@ -31,7 +31,7 @@ const bannerText = "document.getElementById('org-list-banner')?.textContent || '
|
||||
test('a registry error shows on a selected org page and Retry recovers', async t => {
|
||||
const f = await setup(t), file = join(f.ctx.configDir, 'org-runtime.json');
|
||||
const original = await fs.readFile(file, 'utf8'); await fs.writeFile(file, '{');
|
||||
await f.b.navigate(`${f.base}/#/orgs/alpha/`);
|
||||
await f.b.navigate(`${f.base}/#/orgs/alpha/board`);
|
||||
await wait(f.b, `(${bannerText}).includes('Organization registry could not be read') && document.querySelector('#held [data-retry]')`);
|
||||
const before = f.requests(); await fs.writeFile(file, original);
|
||||
await f.b.evaluate("document.querySelector('#held [data-retry]').click()");
|
||||
@@ -44,7 +44,7 @@ test('a broken org banner is replaced, not stacked, and clears after repair', as
|
||||
const f = await setup(t), file = join(f.ctx.configDir, 'org-runtime.json');
|
||||
const original = await fs.readFile(file, 'utf8'), reg = JSON.parse(original);
|
||||
reg.orgs.beta.digest = '0'.repeat(64); await fs.writeFile(file, JSON.stringify(reg));
|
||||
await f.b.navigate(`${f.base}/#/orgs/alpha/`);
|
||||
await f.b.navigate(`${f.base}/#/orgs/alpha/board`);
|
||||
await wait(f.b, `(${bannerText}).includes('beta: invalid') && document.querySelector('#held [data-retry]')`);
|
||||
await f.b.evaluate('window.MosaicRefreshOrganizations()');
|
||||
await wait(f.b, `(${bannerText}).includes('beta: invalid')`);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -49,7 +49,7 @@ try {
|
||||
await wait('document.querySelector("#org-setup") && !document.querySelector("#org-setup").hidden');
|
||||
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/alpha\/settings\/organizations\?org=alpha&step=setup$/);
|
||||
await b.screenshot(join(shots, '03-resume.png'));
|
||||
await b.evaluate(`location.hash='#/orgs/alpha/projects'`);
|
||||
await b.evaluate(`location.hash='#/orgs/alpha/chat'`);
|
||||
await wait('!!document.querySelector("#chat-repo option[value=work]")');
|
||||
await click('.project-create summary');
|
||||
await input('#chat-project-name', 'Work'); await input('#chat-repo', 'work');
|
||||
|
||||
@@ -118,7 +118,7 @@ test('an organization offers its request status, and a conversation whose messag
|
||||
test('an organization project overview reports no active request instead of unavailable', { timeout: 60000 }, async t => {
|
||||
const { base, diagnostics } = await consoleFor(t);
|
||||
const { b, wait } = await page(t, base, diagnostics);
|
||||
await b.navigate(`${base}#/orgs/alpha/projects`);
|
||||
await b.navigate(`${base}#/orgs/alpha/chat`);
|
||||
await wait(`document.querySelector('[data-project-status="${P}"] .project-card-status-line')`);
|
||||
assert.match(await b.evaluate(`document.querySelector('[data-project-status="${P}"]').textContent`), /^No active requests · 1 conversation/);
|
||||
// The overview renders after its reads settle, so the metrics are final once they exist.
|
||||
|
||||
@@ -123,12 +123,12 @@ test('project creation offers repositories from the scoped business read', async
|
||||
};
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/projects`);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/chat`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#chat-repo option[value=work]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('repository choice missing')})()`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('#chat-repo option[value=work]').value`), 'work');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -260,7 +269,7 @@ test('a stopped organization bus has truthful chat guidance', async t => {
|
||||
};
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, send: async () => ({}), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/projects`);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/chat`);
|
||||
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.chat-area')?.innerText.includes('The bus is stopped'))return;await new Promise(r=>setTimeout(r,30))}throw Error('stopped-bus guidance missing')})()`);
|
||||
const text = await b.evaluate(`document.querySelector('.chat-area').innerText`);
|
||||
assert.match(text, /bus is stopped.*Start the bus/i);
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
// U5 (#1873, sample 07): Side by side shows two panes, each holding one page of any organization.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
|
||||
// Three organizations: two running buses and one stopped (home). `state.flip` changes what the
|
||||
// running organizations report, so a refresh repaints both panes.
|
||||
function orgs(state = { flip: false, seen: [] }) {
|
||||
const ORGS = [
|
||||
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
|
||||
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
|
||||
];
|
||||
const PROJECTS = { ddigital: [{ id: 'spring-launch', name: 'Spring launch' }, { id: 'brand-refresh', name: 'Brand refresh' }], forge: [{ id: 'api-gateway', name: 'API gateway' }], home: [] };
|
||||
const INSTANCES = { ddigital: ['chief', 'copywriter', 'producer', 'editor', 'researcher', 'designer', 'analyst', 'scheduler'], forge: ['chief', 'builder', 'tester', 'reviewer', 'operator', 'scribe'] };
|
||||
const THREADS = { ddigital: [{ id: 't0', name: 'Kickoff notes', project: 'spring-launch', instance: 'chief' }, { id: 't1', name: 'Launch email', project: 'spring-launch', instance: 'copywriter' }], forge: [{ id: 'f0', name: 'Gateway auth', project: 'api-gateway', instance: 'chief' }] };
|
||||
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
|
||||
instances: Object.fromEntries(INSTANCES[id].map((n, i) => [n, { definition: n, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [`r${i}`], alive: [] }])) });
|
||||
const agents = id => INSTANCES[id].map((role, i) => ({ role, holder_run: `r${i}`, state: (i % 2 === 0) === state.flip ? 'working' : 'waiting' }));
|
||||
const business = id => ({ business: { id, human: 'jason', arbiters: ['chief'], projects: PROJECTS[id].map(p => p.id), vars: {}, instances: INSTANCES[id].map(n => ({ instance: n, vars: {} })), appearance: { theme: ORGS.find(o => o.id === id).theme } } });
|
||||
return {
|
||||
list: async () => ORGS,
|
||||
resolve: async id => {
|
||||
state.seen.push(id);
|
||||
const org = ORGS.find(o => o.id === id); if (!org) return null;
|
||||
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
|
||||
return {
|
||||
busHealthy: true,
|
||||
reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', launch: { limits: { total: 4 } } }), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => agents(id), 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] },
|
||||
chat: { projects: async () => PROJECTS[id], threads: async () => THREADS[id] },
|
||||
projectFiles: async () => ({ head: 'a'.repeat(40), files: [] }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function open(t, { width = 1440, height = 900, hash = '#/orgs/ddigital/side-by-side', state } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs(state), log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(width, height);
|
||||
const base = `http://127.0.0.1:${server.address().port}/`;
|
||||
await b.navigate(base + hash);
|
||||
return { b, base, wait: waitFor(b) };
|
||||
}
|
||||
const text = key => `(document.querySelector("#pane-${key} [data-pane-body]")?.innerText || "")`;
|
||||
const title = key => `(document.querySelector("#pane-${key} [data-pane-ttl]")?.textContent || "")`;
|
||||
const query = '(() => Object.fromEntries(new URLSearchParams(location.hash.split("?")[1] || "")))()';
|
||||
const pick = (b, key, value) => b.evaluate(`(() => { const s = document.querySelector("#pane-${key}-pick"); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error("no option " + ${JSON.stringify(value)}); s.dispatchEvent(new Event("change", { bubbles: true })); })()`);
|
||||
|
||||
test('the Side by side nav item opens two panes with different organizations, and the URL restores both after a reload', async t => {
|
||||
const state = { flip: false, seen: [] };
|
||||
const { b, base, wait } = await open(t, { hash: '#/orgs/ddigital/organization', state });
|
||||
await wait('document.querySelector("[data-org-dashboard]")');
|
||||
await b.evaluate('[...document.querySelectorAll("#sections > li > a")].find(a => a.textContent.trim() === "Side by side").click()');
|
||||
await wait(`document.querySelectorAll(".panes > .pane:not([hidden])").length === 2 && ${title('a')}.includes("Distinctly Digital") && ${title('b')}.includes("Forge")`);
|
||||
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /Side by side is not built yet/i);
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view h1").textContent'), 'Side by side');
|
||||
assert.equal(await b.evaluate('document.title'), 'Side by side · Mosaic Console');
|
||||
assert.deepEqual(await b.evaluate(query), { a: 'ddigital/organization', b: 'forge/organization', w: '50', show: 'a' });
|
||||
await wait(`${text('a')}.includes("Distinctly Digital") && ${text('b')}.includes("Forge")`);
|
||||
// Each pane's links name its own organization.
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#pane-b [data-pane-body] a[href^=\\"#/\\"]")].every(a => a.getAttribute("href").startsWith("#/orgs/forge/"))'), true);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#pane-a [data-pane-body] h1[aria-level=\\"2\\"]").length > 0'), true);
|
||||
// The picker lists every organization and its projects.
|
||||
const groups = await b.evaluate('[...document.querySelectorAll("#pane-b-pick optgroup")].map(g => g.label)');
|
||||
assert.deepEqual(groups, ['Distinctly Digital', 'Forge', 'Home · bus unavailable']);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#pane-b-pick option")].map(o => o.value).filter(Boolean)'),
|
||||
['ddigital/organization', 'ddigital/agents', 'ddigital/projects/spring-launch', 'ddigital/projects/brand-refresh', 'forge/organization', 'forge/agents', 'forge/projects/api-gateway', 'home/organization', 'home/agents']);
|
||||
// Pick a project in the right pane; the left keeps its page.
|
||||
await pick(b, 'b', 'forge/projects/api-gateway');
|
||||
await wait(`${title('b')}.startsWith("API gateway") && ${text('b')}.includes("API gateway") && location.hash.includes("b=forge/projects/api-gateway")`);
|
||||
assert.match(await b.evaluate(title('a')), /^Dashboard/);
|
||||
// A project tab opens in the pane, not the full view.
|
||||
await b.evaluate('[...document.querySelectorAll("#pane-b nav.project-tabs a")].find(a => /Tasks/.test(a.textContent)).click()');
|
||||
await wait(`location.hash.includes("b=forge/projects/api-gateway/tasks") && ${title('b')}.includes("Tasks")`);
|
||||
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/ddigital\/side-by-side\?/);
|
||||
// Reload: both panes come back from the URL.
|
||||
await b.call('Page.reload');
|
||||
await wait('document.readyState === "complete"');
|
||||
await wait(`${title('a')}.startsWith("Dashboard") && ${title('a')}.includes("Distinctly Digital") && ${title('b')}.includes("API gateway") && ${title('b')}.includes("Tasks")`);
|
||||
assert.deepEqual(await b.evaluate(query), { a: 'ddigital/organization', b: 'forge/projects/api-gateway/tasks', w: '50', show: 'a' });
|
||||
// A shared link opens exactly what it names, whatever this browser remembered.
|
||||
await b.navigate(`${base}#/orgs/forge/side-by-side?a=forge/agents&b=ddigital/projects/spring-launch&w=40&show=b`);
|
||||
await wait(`${title('a')}.startsWith("Agents") && ${title('a')}.includes("Forge") && ${title('b')}.startsWith("Spring launch")`);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-sbs-divider]").getAttribute("aria-valuenow")'), '40');
|
||||
// A link with a route a pane does not draw falls back to the remembered layout or the default.
|
||||
await b.navigate(`${base}#/orgs/ddigital/side-by-side?a=ddigital/settings&b=nope!/organization`);
|
||||
await wait(`document.querySelectorAll(".panes > .pane:not([hidden])").length === 2 && ${title('a')}.length > 0 && !location.hash.includes("settings")`);
|
||||
// Leaving the page stops both panes reading.
|
||||
await b.evaluate('location.hash = "#/orgs/ddigital/agents"');
|
||||
await wait('!document.querySelector("[data-side-by-side]") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Agents")');
|
||||
assert.ok(state.seen.includes('forge') && state.seen.includes('ddigital'));
|
||||
});
|
||||
|
||||
test('the divider resizes the panes by mouse and by keyboard', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0`);
|
||||
const sep = '[role=separator][data-sbs-divider]';
|
||||
assert.deepEqual(await b.evaluate(`(() => { const d = document.querySelector("${sep}"); return [d.getAttribute("aria-valuenow"), d.getAttribute("aria-valuemin"), d.getAttribute("aria-valuemax"), d.getAttribute("aria-orientation"), d.tabIndex]; })()`), ['50', '25', '75', 'vertical', 0]);
|
||||
const ratio = `(() => { const a = document.getElementById("pane-a").getBoundingClientRect().width, b = document.getElementById("pane-b").getBoundingClientRect().width; return Math.round(a / (a + b) * 100); })()`;
|
||||
// Mouse: drag the divider to 30% of the panes' width.
|
||||
const box = await b.evaluate(`(() => { const p = document.querySelector("[data-sbs-panes]").getBoundingClientRect(), d = document.querySelector("${sep}").getBoundingClientRect(); return { left: p.left, width: p.width, x: d.left + d.width / 2, y: d.top + 200 }; })()`);
|
||||
const target = box.left + box.width * 0.3;
|
||||
await b.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y });
|
||||
await b.call('Input.dispatchMouseEvent', { type: 'mousePressed', x: box.x, y: box.y, button: 'left', buttons: 1, clickCount: 1 });
|
||||
for (const step of [0.25, 0.5, 0.75, 1]) await b.call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x + (target - box.x) * step, y: box.y, button: 'left', buttons: 1 });
|
||||
await b.call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: target, y: box.y, button: 'left', buttons: 0, clickCount: 1 });
|
||||
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "30" && location.hash.includes("w=30")`);
|
||||
assert.ok(Math.abs(await b.evaluate(ratio) - 30) <= 1);
|
||||
// Keyboard: the separator has focus after the drag; arrows step by 5, Home and End reach the limits.
|
||||
assert.equal(await b.evaluate('document.activeElement.getAttribute("role")'), 'separator');
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
|
||||
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "35"`);
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "25"`);
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowLeft', code: 'ArrowLeft', windowsVirtualKeyCode: 37 });
|
||||
assert.equal(await b.evaluate(`document.querySelector("${sep}").getAttribute("aria-valuenow")`), '25');
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'End', code: 'End', windowsVirtualKeyCode: 35 });
|
||||
await b.call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'End', code: 'End', windowsVirtualKeyCode: 35 });
|
||||
await wait(`document.querySelector("${sep}").getAttribute("aria-valuenow") === "75" && location.hash.includes("w=75")`);
|
||||
assert.ok(Math.abs(await b.evaluate(ratio) - 75) <= 1);
|
||||
// Closing a pane goes back to one; the divider leaves with it. Add pane brings a second back.
|
||||
await b.evaluate('document.querySelector("#pane-a [data-pane-close]").click()');
|
||||
await wait(`document.getElementById("pane-b").hidden && document.querySelector("${sep}").hidden && !new URLSearchParams(location.hash.split("?")[1]).has("b") && ${title('a')}.includes("Forge")`);
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'pane-a-pick');
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-close]").hidden'), true);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-sbs-note]").textContent'), /^1 pane ·/);
|
||||
await b.evaluate('document.querySelector("[data-pane-add]").click()');
|
||||
await wait(`!document.getElementById("pane-b").hidden && ${title('b')}.length > 0 && new URLSearchParams(location.hash.split("?")[1]).has("b")`);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-pane-add]").disabled'), true);
|
||||
});
|
||||
|
||||
test('each pane keeps its own focus and scroll through a refresh', async t => {
|
||||
const state = { flip: false, seen: [] };
|
||||
const { b, wait } = await open(t, { height: 520, state, hash: '#/orgs/ddigital/side-by-side?a=ddigital/agents&b=forge/agents' });
|
||||
await wait(`${text('a')}.includes("copywriter") && ${text('b')}.includes("builder")`);
|
||||
const scrollable = await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollHeight - el.clientHeight)');
|
||||
assert.ok(scrollable.every(n => n > 60), `both panes scroll: ${scrollable}`);
|
||||
const focused = await b.evaluate('(() => { const a = document.querySelector("#pane-a [data-pane-body] a[href]"); a.focus(); return a.getAttribute("href"); })()');
|
||||
await b.evaluate('document.querySelector("#pane-a [data-pane-body]").scrollTop = 40; document.querySelector("#pane-b [data-pane-body]").scrollTop = 60;');
|
||||
const before = await b.evaluate(`[${text('a')}, ${text('b')}]`);
|
||||
state.flip = true;
|
||||
await b.evaluate('document.getElementById("refresh").click()');
|
||||
await wait(`${text('a')} !== ${JSON.stringify(before[0])} && ${text('b')} !== ${JSON.stringify(before[1])}`);
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-a").contains(document.activeElement) && document.activeElement.getAttribute("href")'), focused);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollTop)'), [40, 60]);
|
||||
// Focus in the right pane stays there while the left repaints.
|
||||
const right = await b.evaluate('(() => { const a = document.querySelector("#pane-b [data-pane-body] a[href]"); a.focus({ preventScroll: true }); return a.getAttribute("href"); })()');
|
||||
state.flip = false;
|
||||
await b.evaluate('document.getElementById("refresh").click()');
|
||||
await wait(`${text('a')} === ${JSON.stringify(before[0])}`);
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-b").contains(document.activeElement) && document.activeElement.getAttribute("href")'), right);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-pane-body]")].map(el => el.scrollTop)'), [40, 60]);
|
||||
});
|
||||
|
||||
test('a pane whose organization is down shows its state and the other pane still works', async t => {
|
||||
const { b, wait } = await open(t, { hash: '#/orgs/ddigital/side-by-side?a=home/organization&b=forge/organization' });
|
||||
await wait(`${title('a')}.includes("Home") && ${title('b')}.includes("Forge") && ${text('b')}.includes("Forge")`);
|
||||
await wait('/Home: bus unavailable/.test(document.querySelector("#pane-a [data-pane-line]").textContent)');
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-line]").getAttribute("role")'), 'status');
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-a [data-pane-state]").innerText.trim().length > 0'), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-b [data-pane-line]").hidden'), true);
|
||||
await pick(b, 'b', 'forge/agents');
|
||||
await wait(`${title('b')}.startsWith("Agents") && ${text('b')}.includes("builder")`);
|
||||
await pick(b, 'b', 'forge/projects/api-gateway');
|
||||
await wait(`${title('b')}.startsWith("API gateway") && ${text('b')}.includes("API gateway")`);
|
||||
assert.match(await b.evaluate(title('a')), /Home/);
|
||||
// An organization that is not on the list is one honest line; nothing is read for it.
|
||||
await b.evaluate('location.hash = "#/orgs/ddigital/side-by-side?a=ghost/organization&b=forge/agents"');
|
||||
await wait(`/ghost is not on your organization list/.test(document.querySelector("#pane-a [data-pane-line]").textContent) && ${text('b')}.includes("builder")`);
|
||||
});
|
||||
|
||||
test('on a phone one pane shows at a time, switched from the bar, with no horizontal scroll', async t => {
|
||||
const { b, wait } = await open(t, { width: 390, height: 844 });
|
||||
await wait(`${title('a')}.length > 0 && ${title('b')}.length > 0 && ${text('a')}.length > 0`);
|
||||
const visible = '[...document.querySelectorAll(".panes > .pane")].filter(p => p.getBoundingClientRect().height > 0).map(p => p.id)';
|
||||
assert.deepEqual(await b.evaluate(visible), ['pane-a']);
|
||||
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("[data-sbs-tabs]")).display'), 'none');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("[data-sbs-divider]")).display'), 'none');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("[data-pane-add]")).display'), 'none');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-sbs-tabs] button")].map(x => x.getAttribute("aria-pressed"))'), ['true', 'false']);
|
||||
assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll');
|
||||
await b.evaluate('document.querySelector("[data-sbs-tabs] [data-pane-show=b]").click()');
|
||||
await wait(`JSON.stringify(${visible}) === '["pane-b"]' && location.hash.includes("show=b")`);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-sbs-tabs] button")].map(x => x.getAttribute("aria-pressed"))'), ['false', 'true']);
|
||||
assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll on the second pane');
|
||||
await pick(b, 'b', 'ddigital/projects/spring-launch');
|
||||
await wait(`${text('b')}.includes("Spring launch")`);
|
||||
assert.ok(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), 'no horizontal scroll on a project page');
|
||||
});
|
||||
|
||||
// Review fixes (#1873 U5 review, findings 1-4). `hold(url)` makes the page's next read of that URL
|
||||
// wait until `release()`; the test then changes the pane while the read is pending.
|
||||
const hold = url => `(() => { const real = window.fetch; window.held = { url: ${JSON.stringify(url)}, pending: false };
|
||||
window.fetch = async (u, o) => { if (window.held?.url === u && !window.held.done) { window.held.pending = true; await new Promise(r => { window.held.release = () => { window.held.done = true; r(); }; }); } return real(u, o); }; })()`;
|
||||
const settled = b => b.evaluate('new Promise(r => setTimeout(r, 400))');
|
||||
|
||||
test('a pane closed and opened again rewrites new links for its current organization only', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('b')}.includes("Forge") && document.querySelector("#pane-b-pick")?.options.length > 4`);
|
||||
await b.evaluate('document.querySelector("#pane-b [data-pane-close]").click()');
|
||||
await wait('document.querySelector("#pane-b").hidden && !document.querySelector("[data-pane-add]").disabled');
|
||||
await b.evaluate('document.querySelector("[data-pane-add]").click()');
|
||||
await wait(`!document.querySelector("#pane-b").hidden && ${title('b')}.length > 0`);
|
||||
await pick(b, 'b', 'ddigital/organization');
|
||||
await wait(`${title('b')}.includes("Distinctly Digital") && location.hash.includes("b=ddigital/organization")`);
|
||||
// Content a page appends after its own reads (projects.js does this) gets the pane's organization.
|
||||
await b.evaluate(`document.querySelector("#pane-b [data-pane-body]").insertAdjacentHTML("beforeend", '<a data-late href="#/docs/example">late</a><a data-late-api href="/api/files/x">file</a>')`);
|
||||
await wait('document.querySelector("#pane-b [data-late]").getAttribute("href").startsWith("#/orgs/")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-b [data-late]").getAttribute("href")'), '#/orgs/ddigital/docs/example');
|
||||
assert.equal(await b.evaluate('document.querySelector("#pane-b [data-late-api]").getAttribute("href")'), '/api/orgs/ddigital/files/x');
|
||||
// Leaving the page stops every pane observer: later content in the old element is left alone.
|
||||
await b.evaluate('location.hash = "#/orgs/ddigital/agents"');
|
||||
await wait('!document.querySelector("[data-side-by-side]")');
|
||||
});
|
||||
|
||||
test('a slow project read never paints over the page or organization chosen since', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('b')}.includes("Forge") && document.querySelector("#pane-b-pick")?.options.length > 4`);
|
||||
// Page change while the project's thread read is pending.
|
||||
await b.evaluate(hold('/api/orgs/forge/chat/threads'));
|
||||
await pick(b, 'b', 'forge/projects/api-gateway');
|
||||
await wait('window.held.pending');
|
||||
await pick(b, 'b', 'forge/agents');
|
||||
await wait(`${title('b')}.startsWith("Agents") && ${text('b')}.includes("builder")`);
|
||||
await b.evaluate('window.held.release()');
|
||||
await settled(b);
|
||||
assert.equal(await b.evaluate(`${text('b')}.includes("API gateway")`), false, 'the old project did not replace Agents');
|
||||
assert.match(await b.evaluate(title('b')), /^Agents/);
|
||||
// Organization change while the read is pending: the old organization's project is not painted.
|
||||
await b.evaluate(hold('/api/orgs/forge/chat/threads'));
|
||||
await pick(b, 'b', 'forge/projects/api-gateway');
|
||||
await wait('window.held.pending');
|
||||
await pick(b, 'b', 'ddigital/projects/spring-launch');
|
||||
await wait(`${title('b')}.startsWith("Spring launch") && ${text('b')}.includes("Spring launch")`);
|
||||
await b.evaluate('window.held.release()');
|
||||
await settled(b);
|
||||
assert.equal(await b.evaluate(`${text('b')}.includes("API gateway")`), false, 'the old organization did not replace Spring launch');
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#pane-b [data-pane-body] a[href^=\\"#/orgs/\\"]")].every(a => a.getAttribute("href").startsWith("#/orgs/ddigital/"))'), true);
|
||||
});
|
||||
|
||||
test('the same project in both panes keeps a request choice per pane', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('b')}.includes("Forge")`);
|
||||
const { stub, P } = await import('./helpers/projects-review-fixture.mjs');
|
||||
// The fixture answers the project reads of both organizations; the organization list stays real.
|
||||
await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`));
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
|
||||
await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=forge/projects/${P}/requirements'`);
|
||||
const values = `[...document.querySelectorAll('[data-pane-body] #project-request-select')].map(e => e.value)`;
|
||||
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2`);
|
||||
assert.deepEqual(await b.evaluate(values), ['request-1', 'request-1']);
|
||||
await b.evaluate(`(() => { const e = document.querySelector('#pane-a #project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
await wait(`${values}[0] === 'request-2'`);
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await settled(b);
|
||||
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2`);
|
||||
assert.deepEqual(await b.evaluate(values), ['request-2', 'request-1']);
|
||||
});
|
||||
|
||||
test('a pane keeps its request choice and PRD disclosure when a new conversation repaints the project', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`${title('b')}.includes("Forge")`);
|
||||
const { stub, P } = await import('./helpers/projects-review-fixture.mjs');
|
||||
await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`));
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
|
||||
// The same organization and project in both panes.
|
||||
await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=ddigital/projects/${P}/requirements'`);
|
||||
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`);
|
||||
const state = `({ selected: [...document.querySelectorAll('[data-pane-body] #project-request-select')].map(e => e.value), expanded: [...document.querySelectorAll('[data-pane-body] [data-prd]')].map(e => e.classList.contains('expanded')) })`;
|
||||
await b.evaluate(`(() => { const e = document.querySelector('#pane-a #project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
await wait(`document.querySelector('#pane-a #project-request-select').value === 'request-2'`);
|
||||
await b.evaluate(`document.querySelector('#pane-a .project-prd-toggle').click()`);
|
||||
await wait(`document.querySelector('#pane-a [data-prd]')?.classList.contains('expanded')`);
|
||||
// A new conversation changes the header's count, so each pane replaces its project content.
|
||||
const before = await b.evaluate(`document.querySelector('#pane-a .project-header p')?.textContent || ''`);
|
||||
await b.evaluate(`window.fixtureThreads.push({ ...window.fixtureThreads[0], id: '20000000-0000-4000-8000-000000000004', name: 'A new conversation' }); window.MosaicPanes.refresh()`);
|
||||
await wait(`(() => { const t = document.querySelector('#pane-a .project-header p')?.textContent || ''; return t !== ${JSON.stringify(before)} && /4 conversations/.test(t) && /4 conversations/.test(document.querySelector('#pane-b .project-header p')?.textContent || ''); })()`);
|
||||
await settled(b);
|
||||
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2 && document.querySelectorAll('[data-pane-body] [data-prd]').length === 2`);
|
||||
assert.deepEqual(await b.evaluate(state), { selected: ['request-2', 'request-1'], expanded: [true, false] });
|
||||
});
|
||||
|
||||
test('a pane navigation that finishes late does not take focus from the other pane', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await wait(`document.querySelector('#pane-a [data-focus-key="agents-all"]') && document.querySelector('#pane-b-pick')?.options.length > 4`);
|
||||
await b.evaluate(hold('/api/orgs/ddigital/bus/instances'));
|
||||
await b.evaluate(`document.querySelector('#pane-a [data-focus-key="agents-all"]').click()`);
|
||||
await wait('window.held.pending');
|
||||
await b.evaluate(`document.querySelector('#pane-b-pick').focus(); window.held.release()`);
|
||||
await wait(`${title('a')}.startsWith('Agents')`);
|
||||
await settled(b);
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'pane-b-pick');
|
||||
// Without a move elsewhere, the same navigation still lands on the new page's heading.
|
||||
await pick(b, 'a', 'ddigital/organization');
|
||||
await wait(`document.querySelector('#pane-a [data-focus-key="agents-all"]')`);
|
||||
await b.evaluate(`document.querySelector('#pane-a [data-focus-key="agents-all"]').click()`);
|
||||
await wait(`${title('a')}.startsWith('Agents') && document.activeElement?.tagName === 'H1' && document.querySelector('#pane-a').contains(document.activeElement)`);
|
||||
});
|
||||
|
||||
// A1's "Available in a project" chooser, with an Agents pane per organization: each pane's choice
|
||||
// redraws its own table and survives a refresh, keyed by that pane's organization.
|
||||
test('each Agents pane keeps its own project choice for its organization', async t => {
|
||||
const { b, wait } = await open(t, { hash: '#/orgs/ddigital/side-by-side?a=ddigital/agents&b=forge/agents' });
|
||||
const a = (name, level = 'org', definition = name) => ({ name, level, definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] });
|
||||
const set = agents => ({ state: 'ok', agents, excluded: [] });
|
||||
const levels = { root: { state: 'ok', assistant: null, agents: [] }, orgs: [
|
||||
{ id: 'ddigital', state: 'ok', base: set([a('chief')]), defined: { 'spring-launch': [{ name: 'social-scheduler', form: 'define' }] },
|
||||
projects: { 'spring-launch': set([a('chief'), a('social-scheduler', 'project', 'coder')]), 'brand-refresh': set([a('chief')]) } },
|
||||
{ id: 'forge', state: 'ok', base: set([a('chief')]), defined: { 'api-gateway': [{ name: 'load-tester', form: 'define' }] },
|
||||
projects: { 'api-gateway': set([a('chief'), a('load-tester', 'project', 'coder')]), ops: set([a('chief')]) } },
|
||||
] };
|
||||
await b.evaluate(`(() => { const real = window.fetch; window.fetch = (url, init) => String(url).endsWith('/api/agents/levels') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(levels))}, { headers: { 'content-type': 'application/json' } })) : real(url, init); })()`);
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
const select = key => `document.querySelector('#pane-${key} [data-agents-project]')`;
|
||||
const rows = key => `[...document.querySelectorAll('#pane-${key} [data-agents-available] tr[data-agent]')].map(r => r.dataset.agent)`;
|
||||
await wait(`${select('a')} && ${select('b')} && ${rows('a')}.includes('social-scheduler') && ${rows('b')}.includes('load-tester')`);
|
||||
await b.evaluate(`(() => { const s = ${select('b')}; s.value = 'ops'; s.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
await wait(`!${rows('b')}.includes('load-tester')`);
|
||||
assert.equal(await b.evaluate(`${rows('a')}.includes('social-scheduler')`), true);
|
||||
assert.equal(await b.evaluate(`${rows('a')}.includes('load-tester')`), false);
|
||||
await b.evaluate('window.MosaicPanes.refresh()');
|
||||
await settled(b);
|
||||
await wait(`${select('a')} && ${select('b')}`);
|
||||
assert.deepEqual(await b.evaluate(`[${select('a')}.value, ${select('b')}.value]`), ['spring-launch', 'ops']);
|
||||
assert.equal(await b.evaluate(`!${rows('b')}.includes('load-tester') && ${rows('a')}.includes('social-scheduler')`), true);
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
// Phase P2 (#1873): System types on the organization dashboard (sample 03) and on a project's information
|
||||
// page. The Console, its per-organization routes and the pages are real; only the two bus transports are
|
||||
// the fake in helpers/system-types-fake.mjs, which reads the installed modules from packages/plugins. The
|
||||
// server-side admission checks and the real organization writer are in system-types-http.test.mjs.
|
||||
// MOSAIC_UX_SHOTS=<dir> also writes light and dark shots at 1440x900 and 390x844.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { systemTypesFake } from './helpers/system-types-fake.mjs';
|
||||
|
||||
const P = '10000000-0000-4000-8000-0000000000b1';
|
||||
const RECEIPT = /Receipt ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\./;
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)}+' | '+(document.querySelector('main')?.innerText||'').slice(0,1500))})()`);
|
||||
|
||||
// One running organization with one project. `send` is the owner's signed-in send mode; without it the
|
||||
// Console only reads and the route gets no writer.
|
||||
function organizations(st, { send = true } = {}) {
|
||||
const business = { business: { id: 'acme', human: 'jason', arbiters: [], projects: [P], vars: {}, instances: [], appearance: { theme: null } } };
|
||||
return {
|
||||
list: async () => [{ id: 'acme', label: 'Acme', theme: null, busHealthy: true }],
|
||||
resolve: async id => id !== 'acme' ? null : ({
|
||||
busHealthy: true, requestRead: st.requestRead, systemTypesWrite: send ? st.systemTypesWrite : null, ...(send ? { send: async () => ({}) } : {}),
|
||||
reads: { business: async () => business, settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: { instances: async () => ({ business: 'acme', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }),
|
||||
messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] },
|
||||
chat: { projects: async () => [{ id: P, name: 'Work', repo: null }], threads: async () => [] },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function page(t, st, { send = true, width = 1440, height = 900 } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), ...(send ? { send: async () => ({}) } : {}), organizations: organizations(st, { send }), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const errors = [];
|
||||
b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'exception'));
|
||||
await b.viewport(width, height);
|
||||
const base = `http://127.0.0.1:${server.address().port}/`;
|
||||
return { b, base, wait: waitFor(b), errors };
|
||||
}
|
||||
const dashboard = async ({ b, base, wait }) => {
|
||||
await b.navigate(`${base}#/orgs/acme/organization`);
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-system-types]") && !/Reading/.test(document.querySelector("[data-system-types]").textContent)');
|
||||
};
|
||||
const settings = async ({ b, base, wait }) => {
|
||||
await b.navigate(`${base}#/orgs/acme/projects/${P}/settings`);
|
||||
await wait('document.querySelector("[data-project-system-types]") && !/Reading system types/.test(document.querySelector("[data-project-system-types]").textContent)');
|
||||
};
|
||||
const text = (b, selector) => b.evaluate(`(document.querySelector(${JSON.stringify(selector)})?.innerText ?? '').replace(/\\s+/g, ' ').trim()`);
|
||||
const chips = b => b.evaluate(`[...document.querySelectorAll('[data-system-type-chips] .ws-chip')].map(c => c.textContent)`);
|
||||
const click = (b, selector) => b.evaluate(`(document.querySelector(${JSON.stringify(selector)}).click(), true)`);
|
||||
|
||||
test('the dashboard lists the installed modules from the plug-in data and the owner switches one with a receipt', { timeout: 60000 }, async t => {
|
||||
const st = await systemTypesFake({ enabled: ['coding'], projects: { [P]: [] } });
|
||||
const p = await page(t, st); const { b, wait } = p;
|
||||
await dashboard(p);
|
||||
// Every row is a module the plug-ins declare, in their order, with their label and description.
|
||||
const rows = await b.evaluate(`[...document.querySelectorAll('[data-system-types] li')].map(li => [li.dataset.systemType, li.querySelector('.t b').textContent, li.querySelector('.t .d').textContent, li.querySelector('.ws-dot').textContent.replace(/[●–]/, '')])`);
|
||||
assert.deepEqual(rows, st.installed.map(m => [m.id, m.label, m.description, m.id === 'coding' ? 'enabled' : 'off']));
|
||||
assert.ok(st.installed.length >= 2, 'the tree installs more than one system type module');
|
||||
assert.deepEqual(await chips(b), ['Coding']);
|
||||
assert.match(await text(b, '[data-system-type-chips]'), /Each project picks from these; none is required\.$/);
|
||||
assert.match(await text(b, '[data-org-setting="System types"]'), new RegExp(`1 of ${st.installed.length} installed enabled`));
|
||||
// The Settings row's Open moves focus to the section heading.
|
||||
await click(b, '[data-org-setting="System types"] [data-system-types-open]');
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset.systemTypesHeading'), '');
|
||||
|
||||
// Enable Marketing from the keyboard; the receipt is the organization writer's transaction.
|
||||
await b.evaluate(`document.querySelector('[data-system-type-toggle="marketing"]').focus(), true`);
|
||||
assert.equal(await b.evaluate(`document.activeElement.getAttribute('aria-label')`), 'Enable Marketing');
|
||||
await b.key('Enter');
|
||||
await wait(`/Receipt/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
|
||||
assert.match(await text(b, '[data-system-types-status]'), new RegExp(`^Marketing enabled\\. ${RECEIPT.source}$`));
|
||||
assert.deepEqual(st.state.enabled, ['coding', 'marketing']);
|
||||
assert.deepEqual(await chips(b), ['Coding', 'Marketing']);
|
||||
assert.match(await text(b, '[data-org-setting="System types"]'), new RegExp(`2 of ${st.installed.length} installed enabled`));
|
||||
// Focus stays on the switch it pressed, which now says Disable.
|
||||
assert.deepEqual(await b.evaluate(`[document.activeElement.dataset.focusKey, document.activeElement.getAttribute('aria-label')]`), ['st:marketing', 'Disable Marketing']);
|
||||
|
||||
// Disable Coding; another write changed the organization file meanwhile, so the first try is refused.
|
||||
st.state.n++;
|
||||
await click(b, '[data-system-type-toggle="coding"]');
|
||||
await wait(`/changed after this page read it/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
|
||||
assert.deepEqual(st.state.enabled, ['coding', 'marketing']);
|
||||
await click(b, '[data-system-type-toggle="coding"]');
|
||||
await wait(`/Coding disabled/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
|
||||
assert.match(await text(b, '[data-system-types-status]'), RECEIPT);
|
||||
assert.deepEqual(st.state.enabled, ['marketing']);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-system-type="coding"] .ws-dot').textContent.replace(/[●–]/, '')`), 'off');
|
||||
assert.deepEqual(p.errors, []);
|
||||
});
|
||||
|
||||
test('without the owner proof the switch is refused, and a read-only Console offers none', { timeout: 60000 }, async t => {
|
||||
// The writer's owner fence refuses: the page says so and nothing changes.
|
||||
const fenced = await systemTypesFake({ enabled: ['coding'], projects: { [P]: [] }, owner: false });
|
||||
const p = await page(t, fenced); const { b, wait } = p;
|
||||
await dashboard(p);
|
||||
await click(b, '[data-system-type-toggle="marketing"]');
|
||||
await wait(`/owner can change/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
|
||||
assert.equal(await text(b, '[data-system-types-status]'), "Only the organization's owner can change system types.");
|
||||
assert.deepEqual(fenced.state.enabled, ['coding']);
|
||||
assert.doesNotMatch(await text(b, '[data-system-types]'), /Receipt/);
|
||||
|
||||
// A Console without send mode reads the list and shows no switch; no write reaches the transport.
|
||||
const readOnly = await systemTypesFake({ enabled: ['coding'], projects: { [P]: ['coding'] } });
|
||||
const q = await page(t, readOnly, { send: false });
|
||||
await dashboard(q);
|
||||
assert.equal(await q.b.evaluate(`document.querySelectorAll('[data-system-types] li').length`), readOnly.installed.length);
|
||||
assert.equal(await q.b.evaluate(`document.querySelectorAll('[data-system-type-toggle]').length`), 0);
|
||||
assert.match(await text(q.b, '[data-system-types]'), /This Console only reads\. Changing system types needs signed-in send mode\./);
|
||||
// The project page shows the selection read only too.
|
||||
await settings(q);
|
||||
assert.equal(await q.b.evaluate(`[...document.querySelectorAll('[data-project-system-types] input')].every(i => i.disabled)`), true);
|
||||
assert.equal(await q.b.evaluate(`document.querySelector('[data-project-system-types] button[type=submit]')`), null);
|
||||
assert.deepEqual(readOnly.state.writes, []);
|
||||
});
|
||||
|
||||
test('a project selects only enabled modules and a disabled selection shows as inactive', { timeout: 60000 }, async t => {
|
||||
// Montage was selected, then disabled in the organization.
|
||||
const st = await systemTypesFake({ enabled: ['coding', 'marketing'], projects: { [P]: ['montage'] } });
|
||||
const p = await page(t, st); const { b, wait } = p;
|
||||
await settings(p);
|
||||
const label = id => st.installed.find(m => m.id === id).label;
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-project-system-types] input[name=system-type]')].map(i => [i.value, i.checked, i.closest('label').querySelector('strong').textContent])`),
|
||||
[['coding', false, label('coding')], ['marketing', false, label('marketing')]]);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-inactive-type]')].map(li => [li.dataset.inactiveType, li.innerText.replace(/\\s+/g, ' ').trim()])`), [['montage', `${label('montage')} inactive`]]);
|
||||
assert.match(await text(b, '[data-project-system-types-inactive]'), /Saving keeps only enabled types/);
|
||||
|
||||
// Pick Marketing and save: the inactive one goes, the saved selection is the bus's answer.
|
||||
await b.evaluate(`document.querySelector('[data-project-system-types] input[value=marketing]').click(), true`);
|
||||
await click(b, '[data-project-system-types] button[type=submit]');
|
||||
await wait(`/Saved/.test(document.querySelector('[data-project-system-types-status]')?.textContent)`);
|
||||
assert.equal(await text(b, '[data-project-system-types-status]'), `Saved. Selected: ${label('marketing')}.`);
|
||||
assert.equal(await b.evaluate(`document.activeElement?.hasAttribute('data-project-system-types-status')`), true);
|
||||
assert.deepEqual(st.state.projects[P], ['marketing']);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-inactive-type]')`), null);
|
||||
|
||||
// The organization disables Coding after the page read it; saving it is refused and the list is read again.
|
||||
await b.evaluate(`document.querySelector('[data-project-system-types] input[value=coding]').click(), true`);
|
||||
st.state.enabled = ['marketing'];
|
||||
await click(b, '[data-project-system-types] button[type=submit]');
|
||||
await wait(`/not enabled in the organization/.test(document.querySelector('[data-project-system-types-status]')?.textContent)`);
|
||||
assert.deepEqual(st.state.projects[P], ['marketing']);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-project-system-types] input[name=system-type]')].map(i => i.value)`), ['marketing']);
|
||||
|
||||
// The owner disables Marketing on the dashboard; back on the project it stays selected, shown inactive.
|
||||
await dashboard(p);
|
||||
await click(b, '[data-system-type-toggle="marketing"]');
|
||||
await wait(`/Marketing disabled/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
|
||||
assert.deepEqual(st.state.enabled, []);
|
||||
await settings(p);
|
||||
assert.match(await text(b, '[data-project-system-types]'), /The organization has no system types enabled\./);
|
||||
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-inactive-type]')].map(li => li.dataset.inactiveType)`), ['marketing']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('[data-project-system-types] input').length`), 0);
|
||||
// With nothing enabled the owner can still clear the inactive selection, without enabling a type first.
|
||||
assert.equal(await text(b, '[data-project-system-types] button[type=submit]'), 'Clear the inactive types');
|
||||
await click(b, '[data-project-system-types] button[type=submit]');
|
||||
await wait(`/Saved/.test(document.querySelector('[data-project-system-types-status]')?.textContent)`);
|
||||
assert.equal(await text(b, '[data-project-system-types-status]'), 'Saved. No system types selected.');
|
||||
assert.deepEqual(st.state.projects[P], []);
|
||||
assert.deepEqual(st.state.enabled, []);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-inactive-type]')`), null);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-project-system-types] button[type=submit]')`), null);
|
||||
assert.deepEqual(p.errors, []);
|
||||
|
||||
// A read-only Console shows the same inactive selection and offers no clear button.
|
||||
const readOnly = await systemTypesFake({ enabled: [], projects: { [P]: ['coding'] } });
|
||||
const q = await page(t, readOnly, { send: false });
|
||||
await settings(q);
|
||||
assert.deepEqual(await q.b.evaluate(`[...document.querySelectorAll('[data-inactive-type]')].map(li => li.dataset.inactiveType)`), ['coding']);
|
||||
assert.equal(await q.b.evaluate(`document.querySelector('[data-project-system-types] button[type=submit]')`), null);
|
||||
assert.deepEqual(readOnly.state.writes, []);
|
||||
});
|
||||
|
||||
test('the dashboard section and the project page fit a phone screen in light and dark', { timeout: 90000 }, async t => {
|
||||
const st = await systemTypesFake({ enabled: ['coding', 'marketing'], projects: { [P]: ['coding', 'montage'] } });
|
||||
const shots = process.env.MOSAIC_UX_SHOTS;
|
||||
if (shots) mkdirSync(shots, { recursive: true });
|
||||
for (const [width, height] of [[1440, 900], [390, 844]]) {
|
||||
const p = await page(t, st, { width, height }); const { b } = p;
|
||||
for (const mode of ['light', 'dark']) {
|
||||
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: mode }] });
|
||||
await dashboard(p);
|
||||
// The default appearance is Harbor in the dark whatever the OS says; the header switch picks the mode.
|
||||
if (await b.evaluate(`document.getElementById('theme-switch').getAttribute('aria-checked')`) !== String(mode === 'dark')) {
|
||||
await b.evaluate(`document.getElementById('theme-switch').click(), true`);
|
||||
await p.wait(`document.getElementById('theme-switch').getAttribute('aria-checked') === '${mode === 'dark'}'`);
|
||||
}
|
||||
await b.evaluate(`document.querySelector('[data-system-types-heading]').scrollIntoView({ block: 'start' }), true`);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, `dashboard ${width} ${mode}`);
|
||||
assert.equal(await b.evaluate(`[...document.querySelectorAll('[data-system-types] li')].every(li => li.scrollWidth <= li.clientWidth)`), true, `dashboard rows ${width} ${mode}`);
|
||||
if (shots) await b.screenshot(join(shots, `p2-dashboard-types-${width}-${mode}.png`));
|
||||
await settings(p);
|
||||
await b.evaluate(`document.querySelector('[data-project-system-types]').scrollIntoView({ block: 'start' }), true`);
|
||||
// At 1440 a project page taller than the window already overflows by its header's primary action
|
||||
// (projects.css, before P2), so the wide check is the section's own box; the phone check is the page.
|
||||
if (width === 390) assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, `project ${width} ${mode}`);
|
||||
assert.equal(await b.evaluate(`(s => s.scrollWidth <= s.clientWidth && s.getBoundingClientRect().right <= document.documentElement.clientWidth)(document.querySelector('[data-project-system-types]'))`), true, `project section ${width} ${mode}`);
|
||||
assert.equal(await b.evaluate(`[...document.querySelectorAll('[data-project-system-types] .project-types-option')].every(o => o.getBoundingClientRect().height >= 44)`), true, `targets ${width} ${mode}`);
|
||||
if (shots) await b.screenshot(join(shots, `p2-project-types-${width}-${mode}.png`));
|
||||
}
|
||||
assert.deepEqual(p.errors, []);
|
||||
}
|
||||
});
|
||||
|
||||
// U5's Side by side draws an organization dashboard into a pane. Each pane reads its own organization's
|
||||
// system types; a pane only shows them (the switch stays in the organization's full view, as the bus
|
||||
// restart does), and its Settings row opens the section in the same pane.
|
||||
test('a side-by-side pane shows its own organization\'s system types and offers no switch', { timeout: 60000 }, async t => {
|
||||
const acme = await systemTypesFake({ enabled: ['coding'], projects: { [P]: [] } });
|
||||
const beta = await systemTypesFake({ enabled: ['marketing'], projects: {} });
|
||||
const one = organizations(acme);
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: {
|
||||
list: async () => [{ id: 'acme', label: 'Acme', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: true }],
|
||||
resolve: async id => id === 'acme' ? one.resolve('acme') : id === 'beta' ? { ...(await one.resolve('acme')), requestRead: beta.requestRead, systemTypesWrite: beta.systemTypesWrite,
|
||||
chat: { projects: async () => [], threads: async () => [] } } : null,
|
||||
} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const errors = [];
|
||||
b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'exception'));
|
||||
await b.viewport(1440, 900);
|
||||
const wait = waitFor(b);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/side-by-side?a=acme/organization&b=beta/organization`);
|
||||
const ready = key => `document.querySelector('#pane-${key} [data-system-types]') && !/Reading/.test(document.querySelector('#pane-${key} [data-system-types]').textContent)`;
|
||||
await wait(`${ready('a')} && ${ready('b')}`);
|
||||
const paneChips = key => b.evaluate(`[...document.querySelectorAll('#pane-${key} [data-system-type-chips] .ws-chip')].map(c => c.textContent)`);
|
||||
assert.deepEqual(await paneChips('a'), ['Coding']);
|
||||
assert.deepEqual(await paneChips('b'), ['Marketing']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('.panes [data-system-type-toggle]').length`), 0);
|
||||
for (const key of ['a', 'b'])
|
||||
assert.match(await text(b, `#pane-${key} [data-system-types]`), /Change system types from this organization's full view\./);
|
||||
const ids = await b.evaluate(`[...document.querySelectorAll('[data-system-types-heading]')].map(h => h.id)`);
|
||||
assert.equal(ids.length, 2); assert.equal(new Set(ids).size, 2);
|
||||
await click(b, '#pane-b [data-org-setting="System types"] [data-system-types-open]');
|
||||
assert.equal(await b.evaluate(`document.activeElement === document.querySelector('#pane-b [data-system-types-heading]')`), true);
|
||||
assert.deepEqual([acme.state.writes.includes('plugins.set'), beta.state.writes.includes('plugins.set')], [false, false]);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { request } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { busOrganization } from '../../bus/src/organization-context.mjs';
|
||||
import { startBroker } from '../../bus/src/runtime.mjs';
|
||||
import { serve } from '../../bus/src/server.mjs';
|
||||
import { Client } from '../../bus/src/client.mjs';
|
||||
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
// Phase P2 (#1873): the owner's System types routes on a selected organization, end to end. An
|
||||
// ephemeral organization with the real runtime writer (plugins.set, the system types owner fence,
|
||||
// changes.jsonl) and the broker's console socket for the read; the SSO session is a fixture
|
||||
// authority. `writer` picks the cap the write transport binds: the owner's human cap, or the tester.
|
||||
async function fixture(t, { writer = 'owner', send = true, afterRead = null, beforeWrite = null } = {}) {
|
||||
// Cleanups run last-made first, registered as soon as each resource exists.
|
||||
const undo = []; t.after(async () => { for (const step of undo.reverse()) await step(); });
|
||||
const root = mkdtempSync(join(tmpdir(), 'st-'));
|
||||
const rolesDir = rolesCopy(root), system = systemFor(root);
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { ...(doc.plugins ?? {}), 'system-type': ['coding'], harness: ['pi'] };
|
||||
doc.vars = { ...doc.vars, 'bus.tester': true };
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const business = loadBusiness('demo', { dir: root, rolesDir });
|
||||
const resolved = Object.fromEntries(Object.keys(business.roles).map(instance => [instance, resolveInstance({ system, business, instance })]));
|
||||
undo.push(() => rmSync(root, { recursive: true, force: true }));
|
||||
const runtime = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busOrganization(business, resolved) }, organization: { dir: root, rolesDir, system } });
|
||||
undo.push(() => runtime.close());
|
||||
const path = join(root, 'b.sock');
|
||||
const socketServer = await serve({ broker: runtime.broker, path, peerReader: { read: async () => ({}), close: () => {} },
|
||||
authenticateConsole: proof => runtime.broker.bindConsole({ business: 'demo', human: 'jason', subject: proof.subject }) });
|
||||
undo.push(() => socketServer.close());
|
||||
const owner = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const role = Object.keys(business.roles)[0];
|
||||
const cap = writer === 'tester' ? runtime.broker.bindTester({ business: 'demo' })
|
||||
: writer === 'agent' ? runtime.broker.bindLaunch({ business: 'demo', role, run: 'agent-run', harness: 'pi' }) : owner;
|
||||
const calls = [];
|
||||
// afterRead and beforeWrite let a test act between the route's read and its write.
|
||||
const requestRead = async (verb, args, ctx) => { calls.push(['read', verb]); const r = await new Client({ path, console: { subject: ctx.subject } }).call(verb, args); await afterRead?.(); return r; };
|
||||
const systemTypesWrite = async (verb, args) => { await beforeWrite?.(verb); calls.push(['write', verb]); return runtime.broker.request(cap, { verb, args }); };
|
||||
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason' };
|
||||
let active = true;
|
||||
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
|
||||
const organizations = {
|
||||
list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
|
||||
resolve: async id => id !== 'demo' ? null : { busHealthy: true, send: send ? async () => ({}) : null, requestRead, systemTypesWrite: send ? systemTypesWrite : null,
|
||||
reads: { business: async () => ({ business: { id: 'demo' } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } },
|
||||
};
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), organizations, log: () => {} });
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
sso.publicOrigin = base; sso.publicHost = new URL(base).host;
|
||||
undo.push(() => close(web));
|
||||
const project = runtime.broker.request(owner, { verb: 'project.create', args: { name: 'example' } }).id;
|
||||
const onDisk = () => JSON.parse(readFileSync(file)).plugins['system-type'];
|
||||
const ownerRequest = (verb, args) => runtime.broker.request(owner, { verb, args });
|
||||
return { base, file, project, calls, onDisk, ownerRequest, signOut: () => { active = false; } };
|
||||
}
|
||||
|
||||
function call(f, path, method = 'GET', body, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
|
||||
const req = request(f.base + path, { method, headers: { ...(data !== undefined ? { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...headers } }, res => {
|
||||
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)]));
|
||||
});
|
||||
req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(data);
|
||||
});
|
||||
}
|
||||
const ORG = '/api/orgs/demo/system-types';
|
||||
|
||||
test('the owner reads the installed modules and enables one with the revision, getting a receipt', async t => {
|
||||
const f = await fixture(t);
|
||||
const [status, before] = await call(f, ORG);
|
||||
assert.equal(status, 200, JSON.stringify(before));
|
||||
// Labels and descriptions are the modules' own, never a list in the WebUI.
|
||||
const modules = (await installedSystemTypes()).map(({ id, label, description }) => ({ id, label, description }));
|
||||
assert.deepEqual(before.installed, modules);
|
||||
assert.deepEqual(before.enabled, ['coding']);
|
||||
assert.equal(before.project, null); assert.equal(before.writable, true); assert.match(before.revision, /^[a-f0-9]{64}$/);
|
||||
const [saved, receipt] = await call(f, ORG, 'POST', { revision: before.revision, enabled: ['coding', 'montage'] });
|
||||
assert.equal(saved, 200, JSON.stringify(receipt));
|
||||
assert.deepEqual(receipt.enabled, ['coding', 'montage']);
|
||||
assert.match(receipt.transaction, /^[0-9a-f-]{36}$/); assert.notEqual(receipt.revision, before.revision);
|
||||
assert.deepEqual(f.onDisk(), ['coding', 'montage']);
|
||||
const [, after] = await call(f, ORG);
|
||||
assert.deepEqual(after.enabled, ['coding', 'montage']); assert.equal(after.revision, receipt.revision);
|
||||
// The revision is the proof: the old one is refused and nothing is written.
|
||||
const [stale, conflict] = await call(f, ORG, 'POST', { revision: before.revision, enabled: [] });
|
||||
assert.deepEqual([stale, conflict.error], [409, 'revision-conflict']);
|
||||
assert.deepEqual(f.onDisk(), ['coding', 'montage']);
|
||||
});
|
||||
|
||||
test('the admission checks refuse before any write', async t => {
|
||||
const f = await fixture(t);
|
||||
const [, { revision }] = await call(f, ORG);
|
||||
const good = { revision, enabled: [] };
|
||||
for (const [label, body, headers, expected] of [
|
||||
['unknown module', { revision, enabled: ['nope'] }, {}, [400, 'system-type-unknown']],
|
||||
['duplicate', { revision, enabled: ['coding', 'coding'] }, {}, [400, 'invalid-request']],
|
||||
['extra field', { ...good, actor: 'jason' }, {}, [400, 'invalid-request']],
|
||||
['business field', { ...good, business: 'other' }, {}, [400, 'invalid-request']],
|
||||
['no revision', { enabled: [] }, {}, [400, 'invalid-request']],
|
||||
['bad revision', { revision: 'x', enabled: [] }, {}, [400, 'invalid-request']],
|
||||
['not JSON', '{', {}, [400, 'invalid-request']],
|
||||
['too large', JSON.stringify({ revision, enabled: [], pad: 'x'.repeat(5000) }), {}, [413, 'invalid-request']],
|
||||
['form post', good, { 'content-type': 'text/plain' }, [400, 'invalid-request']],
|
||||
// The Console's own origin check answers a foreign Origin first; the route's own check answers Sec-Fetch-Site.
|
||||
['cross origin', good, { origin: 'https://other.example' }, [403, 'cross-origin request refused']],
|
||||
['cross site', good, { 'sec-fetch-site': 'cross-site' }, [403, 'cross-origin']],
|
||||
]) {
|
||||
const writes = f.calls.filter(c => c[0] === 'write' && c[1] === 'plugins.set').length;
|
||||
const [status, result] = await call(f, ORG, 'POST', body, headers);
|
||||
assert.deepEqual([status, result.error], expected, label);
|
||||
assert.equal(f.calls.filter(c => c[0] === 'write' && c[1] === 'plugins.set').length, writes, label);
|
||||
assert.deepEqual(f.onDisk(), ['coding'], label);
|
||||
}
|
||||
const [method] = await call(f, ORG, 'PUT');
|
||||
assert.equal(method, 405);
|
||||
const [cross, crossBody] = await call(f, ORG, 'GET', undefined, { origin: 'https://other.example' });
|
||||
assert.equal(cross, 403); assert.match(crossBody.error, /^cross-origin/);
|
||||
const [badProject, badProjectBody] = await call(f, `${ORG}/projects/${encodeURIComponent('../x')}`);
|
||||
assert.deepEqual([badProject, badProjectBody.error], [404, 'project-not-found']);
|
||||
f.signOut();
|
||||
const [signedOut, signedOutBody] = await call(f, ORG, 'POST', good);
|
||||
assert.deepEqual([signedOut, signedOutBody.error], [401, 'sign-in-required']);
|
||||
assert.deepEqual(f.onDisk(), ['coding']);
|
||||
});
|
||||
|
||||
test('a sign-out while the read runs dispatches no write on either route', async t => {
|
||||
let signOutOnRead = false;
|
||||
const f = await fixture(t, { afterRead: () => { if (signOutOnRead) f.signOut(); } });
|
||||
const [, { revision }] = await call(f, ORG);
|
||||
signOutOnRead = true;
|
||||
const writes = () => f.calls.filter(c => c[0] === 'write' && c[1] !== 'plugins').length;
|
||||
for (const [path, body] of [[ORG, { revision, enabled: [] }], [`${ORG}/projects/${f.project}`, { systemTypes: ['coding'] }]]) {
|
||||
const [status, result] = await call(f, path, 'POST', body);
|
||||
assert.deepEqual([status, result.error], [401, 'sign-in-required'], path);
|
||||
}
|
||||
assert.equal(writes(), 0);
|
||||
assert.deepEqual(f.onDisk(), ['coding']);
|
||||
});
|
||||
|
||||
test('the enabled set and its revision come from one read, so a change in between is not undone', async t => {
|
||||
let raced = false;
|
||||
const f = await fixture(t, { beforeWrite: async verb => {
|
||||
// Another owner action enables Marketing after the route's Console read and before its plugins read.
|
||||
if (verb !== 'plugins' || raced) return;
|
||||
raced = true;
|
||||
const { revision } = await f.ownerRequest('plugins', {});
|
||||
await f.ownerRequest('plugins.set', { revision, plugins: { 'system-type': ['coding', 'marketing'] } });
|
||||
} });
|
||||
const [, view] = await call(f, ORG);
|
||||
assert.equal(raced, true);
|
||||
assert.deepEqual(view.enabled, ['coding', 'marketing']);
|
||||
const [saved, receipt] = await call(f, ORG, 'POST', { revision: view.revision, enabled: view.enabled.filter(x => x !== 'coding') });
|
||||
assert.equal(saved, 200, JSON.stringify(receipt));
|
||||
assert.deepEqual(f.onDisk(), ['marketing']);
|
||||
});
|
||||
|
||||
test('without the owner human proof the enable switch is refused', async t => {
|
||||
// A Console without --send has no write transport: the read works, the write is read-only.
|
||||
const readOnly = await fixture(t, { send: false });
|
||||
const [status, view] = await call(readOnly, ORG);
|
||||
assert.equal(status, 200); assert.equal(view.writable, false); assert.equal(view.revision, undefined);
|
||||
const [refused, body] = await call(readOnly, ORG, 'POST', { revision: 'a'.repeat(64), enabled: [] });
|
||||
assert.deepEqual([refused, body.error], [403, 'read-only']);
|
||||
assert.deepEqual(readOnly.onDisk(), ['coding']);
|
||||
// A write transport bound as the tester or as an agent, not the owner's human cap, is refused by the
|
||||
// broker, and the receipt-bearing path never runs.
|
||||
for (const writer of ['tester', 'agent']) {
|
||||
const f = await fixture(t, { writer });
|
||||
const [fenced, fence] = await call(f, ORG, 'POST', { revision: 'b'.repeat(64), enabled: [] });
|
||||
assert.deepEqual([fenced, fence.error], [403, 'human-required'], writer);
|
||||
assert.deepEqual(f.onDisk(), ['coding'], writer);
|
||||
}
|
||||
});
|
||||
|
||||
test('a project selects only enabled modules and a disabled selection reads as inactive', async t => {
|
||||
const f = await fixture(t);
|
||||
const path = `${ORG}/projects/${f.project}`;
|
||||
const [status, empty] = await call(f, path);
|
||||
assert.equal(status, 200, JSON.stringify(empty));
|
||||
assert.deepEqual(empty.project, { project: f.project, selected: [], active: [], inactive: [] });
|
||||
assert.equal(empty.revision, undefined);
|
||||
const [notEnabled, notEnabledBody] = await call(f, path, 'POST', { systemTypes: ['montage'] });
|
||||
assert.deepEqual([notEnabled, notEnabledBody.error], [409, 'system-type-not-enabled']);
|
||||
const [unknown, unknownBody] = await call(f, path, 'POST', { systemTypes: ['nope'] });
|
||||
assert.deepEqual([unknown, unknownBody.error], [400, 'system-type-unknown']);
|
||||
const [saved, selection] = await call(f, path, 'POST', { systemTypes: ['coding'] });
|
||||
assert.equal(saved, 200, JSON.stringify(selection));
|
||||
assert.deepEqual(selection, { project: f.project, selected: ['coding'], active: ['coding'], inactive: [] });
|
||||
// The owner disables coding for the organization; the project keeps its selection, now inactive.
|
||||
const [, { revision }] = await call(f, ORG);
|
||||
assert.equal((await call(f, ORG, 'POST', { revision, enabled: [] }))[0], 200);
|
||||
const [, after] = await call(f, path);
|
||||
assert.deepEqual(after.enabled, []);
|
||||
assert.deepEqual(after.project, { project: f.project, selected: ['coding'], active: [], inactive: ['coding'] });
|
||||
const [missing, missingBody] = await call(f, `${ORG}/projects/no-such-project`);
|
||||
assert.deepEqual([missing, missingBody.error], [404, 'project-not-found']);
|
||||
const [missingWrite, missingWriteBody] = await call(f, `${ORG}/projects/no-such-project`, 'POST', { systemTypes: [] });
|
||||
assert.deepEqual([missingWrite, missingWriteBody.error], [404, 'project-not-found']);
|
||||
});
|
||||
|
||||
test('the tester listener and a Console without SSO never reach the routes or the files', async t => {
|
||||
const { createDevOwner } = await import('../src/dev-owner.mjs');
|
||||
let reached = 0;
|
||||
const hit = async () => { reached++; return {}; };
|
||||
const organizations = { list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
|
||||
resolve: async () => ({ busHealthy: true, send: hit, requestRead: hit, systemTypesWrite: hit, reads: { business: async () => ({ business: { id: 'demo' } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } }) };
|
||||
const root = mkdtempSync(join(tmpdir(), 'stt-')), socket = join(root, 't.sock');
|
||||
const tester = await startServer({ tester: true, testerSocket: socket, board: 'http://127.0.0.1:9', organizations, log: () => {} });
|
||||
t.after(async () => { await close(tester); rmSync(root, { recursive: true, force: true }); });
|
||||
const viaTester = (path, method = 'GET') => new Promise((resolve, reject) => {
|
||||
const req = request({ socketPath: socket, path, method, headers: { host: 'mosaic-tester.invalid' } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); });
|
||||
req.on('error', reject); req.end();
|
||||
});
|
||||
for (const path of ['/system-types.js', '/system-types.css']) assert.equal(await viaTester(path), 403, path);
|
||||
// The tester has no organization session, so the per-organization routes answer org-unavailable.
|
||||
for (const [path, method] of [[ORG, 'GET'], [ORG, 'POST'], [`${ORG}/projects/p1`, 'GET']]) assert.ok([403, 404].includes(await viaTester(path, method)), path);
|
||||
const plain = await startServer({ port: 0, organizations, log: () => {} }); t.after(() => close(plain));
|
||||
const plainBase = `http://127.0.0.1:${plain.address().port}`;
|
||||
assert.ok((await fetch(`${plainBase}${ORG}`)).status >= 400);
|
||||
// The owner's dev session on a Console with the routes: the file is served.
|
||||
const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, log: () => {} }); t.after(() => close(owner));
|
||||
assert.equal((await fetch(`http://127.0.0.1:${owner.address().port}/system-types.js`)).status, 200);
|
||||
assert.equal(reached, 0);
|
||||
await assert.rejects(startServer({ port: 0, sso: createDevOwner(), systemTypesWrite: hit, log: () => {} }), /System types writes need/);
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
// U4b (#1873, PRD 1.20): the task board across organizations (sample 06) in a real browser.
|
||||
// Three orgs: forge keeps its tasks in the built-in store, ddigital in Vikunja, home's bus is down.
|
||||
// Every bus is a fake in this file; each org's taskWrite records what reached that org only.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
const D = n => String(n).repeat(64);
|
||||
|
||||
function boardOrgs({ refuse = null, tasksFail = [] } = {}) {
|
||||
const ORGS = [
|
||||
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
|
||||
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
|
||||
];
|
||||
const DOCS = {
|
||||
ddigital: { appearance: { theme: 'rosewood' }, tracker: { kind: 'vikunja' }, roles: { copywriter: { tracker: { botId: 7 } }, editor: { tracker: { botId: 8 } } } },
|
||||
forge: { appearance: { theme: 'cobalt' }, roles: { coder: {}, reviewer: {} } },
|
||||
home: { appearance: { theme: 'grove' } },
|
||||
};
|
||||
const PROJECTS = {
|
||||
ddigital: [{ id: 'spring-launch', repo: 'spring', name: 'Spring launch', at: '2026-11-09T10:00:00.000Z' }],
|
||||
forge: [{ id: 'api-gateway', repo: 'gateway', name: 'API gateway', at: '2026-11-09T10:00:00.000Z' }, { id: 'billing', repo: 'billing', name: 'Billing', at: '2026-11-08T10:00:00.000Z' }],
|
||||
};
|
||||
const store = [
|
||||
{ n: 41, title: 'Rate limit the public API', state: 'todo', group: 'api-gateway', assignees: ['coder'], priority: 4, due: '2026-11-20T00:00:00.000Z' },
|
||||
{ n: 42, title: 'Invoice export', state: 'in-progress', group: 'billing', assignees: [], priority: 0, due: null },
|
||||
{ n: 43, title: 'Retire the v1 gateway', state: 'done', group: 'api-gateway', assignees: ['reviewer'], priority: 2, due: null },
|
||||
].map(t => ({ ...t, rev: 0 }));
|
||||
// A task's digest changes with every write to it, as the store's does.
|
||||
const digest = t => D((t.n + t.rev) % 10);
|
||||
const TASKS = {
|
||||
forge: () => store.map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: t.priority, digest: digest(t), requirement: 'REQ-API-1',
|
||||
fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: t.due } })),
|
||||
ddigital: () => [
|
||||
{ task_ref: 'vikunja:3/112', project_key: 'spring', priority: 3, at: '2026-11-09T11:00:00.000Z', fields: { title: 'Launch email, draft 2', done: false, assignees: [7], bucket: 12, labels: ['launch'] } },
|
||||
{ task_ref: 'vikunja:3/118', project_key: 'spring', priority: 0, at: '2026-11-08T11:00:00.000Z', fields: { title: 'Teaser reel', done: true, assignees: [8], bucket: 14, labels: [] } },
|
||||
],
|
||||
};
|
||||
const writes = [];
|
||||
const taskWrite = org => async (verb, args) => {
|
||||
writes.push([org, verb, args]);
|
||||
if (refuse) throw Object.assign(new Error('refused'), { code: refuse });
|
||||
const t = store.find(s => `task:${s.n}` === args.task_ref);
|
||||
if (t && args.expect && args.expect !== digest(t)) throw Object.assign(new Error('conflict'), { code: 'task-conflict' });
|
||||
if (verb === 'tasks.edit' && t) { for (const f of ['title', 'state', 'assignees']) if (args[f] !== undefined) t[f] = args[f]; t.rev++; }
|
||||
return { task_ref: args.task_ref, digest: t ? digest(t) : D(9) };
|
||||
};
|
||||
const options = id => ({
|
||||
bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => { if (tasksFail.includes(id)) throw Object.assign(new Error('tasks'), { code: 'tracker-unavailable' }); return TASKS[id](); }, messages: async () => ({ messages: [], unread: 0 }) },
|
||||
chat: { projects: async () => PROJECTS[id], threads: async () => [] },
|
||||
});
|
||||
const healthy = async () => ({ healthy: true, pid: 4242, boot: null });
|
||||
const down = async () => ({ healthy: false, pid: null, boot: null });
|
||||
return {
|
||||
writes, store,
|
||||
list: async () => ORGS,
|
||||
members: async () => ORGS.map(o => ({ id: o.id, doc: DOCS[o.id], status: o.busHealthy ? healthy : down, options: () => options(o.id) })),
|
||||
resolve: async id => {
|
||||
const org = ORGS.find(o => o.id === id); if (!org) return null;
|
||||
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] }, taskWrite: taskWrite(id) };
|
||||
return { busHealthy: true, reads: { business: async () => ({ business: { id, projects: PROJECTS[id].map(p => p.id) } }) }, ...options(id), taskWrite: taskWrite(id) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function open(t, { refuse = null, tasksFail = [], send = true, width = 1440, height = 900, orgs = boardOrgs({ refuse, tasksFail }) } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, ...(send ? { send: async () => ({}) } : {}), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(width, height);
|
||||
const url = `http://127.0.0.1:${server.address().port}/#/orgs/forge/tasks`;
|
||||
await b.navigate(url);
|
||||
const wait = waitFor(b);
|
||||
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
|
||||
return { b, wait, orgs, url };
|
||||
}
|
||||
const cards = b => b.evaluate('[...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask)');
|
||||
|
||||
test('the board merges every organization\'s tasks, tags each with its org, and a down org keeps its state', async t => {
|
||||
const { b } = await open(t);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'kanban');
|
||||
assert.deepEqual((await cards(b)).sort(), ['ddigital|vikunja:3/112', 'ddigital|vikunja:3/118', 'forge|task:41', 'forge|task:42', 'forge|task:43']);
|
||||
// Each card carries its org's mark and the id in its tracker's form.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-card")].map(c => [c.dataset.org, c.querySelector(".id").textContent, !!c.querySelector(".ws-av"), c.querySelector(".tb-trk").textContent]).sort()'),
|
||||
[['ddigital', 'V-112', true, 'Vikunja'], ['ddigital', 'V-118', true, 'Vikunja'], ['forge', '#41', true, 'built-in'], ['forge', '#42', true, 'built-in'], ['forge', '#43', true, 'built-in']]);
|
||||
// Columns: the store's states, plus one for the Vikunja task whose bucket the bus does not name.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-col h3")].map(h => h.textContent)'), ['To do', 'In progress', 'Review', 'Blocked', 'Done', 'State not read']);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelector("[data-tb-col=todo] [data-tb-task=\'forge|task:41\'] .bot").children].map(e => e.textContent.trim())'), ['API gateway', 'coder', 'P4', 'due 20 Nov']);
|
||||
// Only built-in store cards can be dragged; a Vikunja card is read only.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-task=\'forge|task:41\']").draggable'), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-task=\'ddigital|vikunja:3/112\']").draggable'), false);
|
||||
// home's bus is down: it shows its state and reason, and the other orgs still render.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-tracker]")].map(e => [e.dataset.tbTracker, e.dataset.state])'), [['ddigital', 'ok'], ['forge', 'ok'], ['home', 'down']]);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tb-tracker=home]").textContent'), /Home.*bus down.*the bus is not running/s);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tb-org=home]").textContent'), /Home\s*unavailable/);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /5 tasks from 2 of 3 organizations.*Vikunja tasks are read only here/);
|
||||
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /not built yet/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), false);
|
||||
});
|
||||
|
||||
test('org and project filters narrow the board', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-org=forge]").click()');
|
||||
await wait('document.querySelector("[data-tb-org=forge]").getAttribute("aria-pressed") === "true"');
|
||||
assert.deepEqual((await cards(b)).sort(), ['forge|task:41', 'forge|task:42', 'forge|task:43']);
|
||||
assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 3 of 5 tasks.');
|
||||
// The project list follows the org filter.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-filter=project] option")].map(o => o.value).sort()'), ['', '-', 'forge:api-gateway', 'forge:billing']);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
|
||||
assert.deepEqual(await cards(b), ['forge|task:42']);
|
||||
assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 1 task that matches the filters.');
|
||||
// Back to every org; the project filter stays on forge's project.
|
||||
await b.evaluate('document.querySelector("[data-tb-org=\'\']").click()');
|
||||
await wait('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed") === "true"');
|
||||
assert.deepEqual(await cards(b), ['forge|task:42']);
|
||||
// A project of another org, then the assignee filter on a Vikunja role.
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "ddigital:spring-launch"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 2');
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=assignee]"); s.value = "copywriter"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
|
||||
assert.deepEqual(await cards(b), ['ddigital|vikunja:3/112']);
|
||||
});
|
||||
|
||||
test('the layout toggle is remembered across a reload', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "list" && document.querySelector("[data-tb-list]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-layout=list]").getAttribute("aria-pressed")'), 'true');
|
||||
await b.evaluate('location.reload()');
|
||||
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'list');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-tb-list] tr.tb-row").length'), 5);
|
||||
});
|
||||
|
||||
test('a phone opens on List with no sideways scroll, and Kanban is one tap away', async t => {
|
||||
const { b, wait } = await open(t, { width: 390, height: 844 });
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'list');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=kanban]").click()');
|
||||
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "kanban"');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
|
||||
// One column on a phone.
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector(".tb-board")).gridTemplateColumns.split(" ").length'), 1);
|
||||
});
|
||||
|
||||
test('a move writes only to its own org, and a polled repaint keeps focus', async t => {
|
||||
const { b, wait, orgs } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
|
||||
// Vikunja rows have no State control; a store row does.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-move^=ddigital]")'), null);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:41\']"); s.focus(); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('/moved to Review in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
|
||||
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:41', expect: D(1), state: 'in-review' }]]);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-move=\'forge|task:41\']").value'), 'in-review');
|
||||
// The search keeps focus and its caret through the poll's repaint.
|
||||
await b.evaluate('(() => { const q = document.querySelector("[data-tb-q]"); q.focus(); q.value = "inv"; q.dispatchEvent(new Event("input", { bubbles: true })); q.setSelectionRange(1, 1); })()');
|
||||
await wait('document.querySelectorAll("[data-tb-list] tr.tb-row").length === 1');
|
||||
// bus.js repaints the view on its own 10 s poll; wait for that repaint to replace the board.
|
||||
assert.equal(await b.evaluate('(async () => { const before = document.querySelector("[data-tasks-board]"); for (let i = 0; i < 140 && document.querySelector("[data-tasks-board]") === before; i++) await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 50)); return document.querySelector("[data-tasks-board]") !== before; })()'), true);
|
||||
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement?.selectionStart, document.querySelector("[data-tb-q]").value]'), ['tb-q', 1, 'inv']);
|
||||
});
|
||||
|
||||
test('a write the bus refuses for want of a person is shown, and nothing else is written', async t => {
|
||||
const { b, wait, orgs } = await open(t, { refuse: 'human-required' });
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
await wait('document.querySelector("[data-tb-move=\'forge|task:42\']")');
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:42\']"); s.value = "done"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('document.querySelector("[data-tb-notice][role=alert]")');
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tb-notice]").textContent'), /#42 was not moved\. The bus takes task changes only from a person at the Console\. Nothing was written\./);
|
||||
assert.deepEqual(orgs.writes.map(w => w[0]), ['forge']);
|
||||
// The board still shows the task where the bus has it.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-move=\'forge|task:42\']").value'), 'in-progress');
|
||||
// The edit dialog shows the same refusal.
|
||||
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
||||
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
||||
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
||||
await wait('/only from a person/.test(document.querySelector("#tb-dialog [data-tb-form-status]").textContent)');
|
||||
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]);
|
||||
});
|
||||
|
||||
test('a Console without --send shows a read-only board and the write route refuses', async t => {
|
||||
const { b, orgs } = await open(t, { send: false });
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), true);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll(".tb-card[draggable=true]").length'), 0);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /board is read only/);
|
||||
const status = await b.evaluate('fetch("/api/workspace/tasks", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ op: "edit", org: "forge", task_ref: "task:41", state: "done" }) }).then(r => r.status)');
|
||||
assert.equal(status, 403);
|
||||
assert.deepEqual(orgs.writes, []);
|
||||
});
|
||||
|
||||
// bus.js repaints the view on its own 10 s poll; this resolves once that repaint has replaced the board.
|
||||
const POLLED = '(async () => { const before = document.querySelector("[data-tasks-board]"); for (let i = 0; i < 140 && document.querySelector("[data-tasks-board]") === before; i++) await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 50)); return document.querySelector("[data-tasks-board]") !== before; })()';
|
||||
|
||||
test('an edit dialog left open while another writer changes the task saves against what it showed and gets a conflict', async t => {
|
||||
const { b, wait, orgs } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
||||
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
||||
// Another writer renames the task and assigns it; a poll then reads the new version.
|
||||
const t42 = orgs.store.find(s => s.n === 42);
|
||||
Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 });
|
||||
assert.equal(await b.evaluate(POLLED), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export');
|
||||
// Save sends the digest and the changes against the task as the dialog showed it.
|
||||
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
||||
await wait('/changed after the board read it/.test(document.querySelector("#tb-dialog [data-tb-form-status]")?.textContent)');
|
||||
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]]);
|
||||
assert.equal(t42.title, 'Invoice export (renamed elsewhere)');
|
||||
// The dialog reopens on the current task, so the next save is checked against what it now shows.
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']);
|
||||
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
||||
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
|
||||
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), false);
|
||||
});
|
||||
|
||||
test('a poll that answers after focus has moved leaves focus where the person put it', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
|
||||
// Hold the next workspace read until focus has moved from the search to a row's State control.
|
||||
await b.evaluate(`(() => {
|
||||
const real = window.fetch;
|
||||
window.__held = null;
|
||||
window.fetch = (u, o) => String(u).startsWith('/api/workspace?') && !window.__held
|
||||
? new Promise(go => { window.__held = () => real(u, o).then(go); })
|
||||
: real(u, o);
|
||||
document.querySelector("[data-tb-q]").focus();
|
||||
})()`);
|
||||
// bus.js polls every 10 s, longer than waitFor allows.
|
||||
assert.equal(await b.evaluate('(async () => { for (let i = 0; i < 140 && !window.__held; i++) await new Promise(r => setTimeout(r, 100)); return !!window.__held; })()'), true);
|
||||
await b.evaluate('(window.__board = document.querySelector("[data-tasks-board]"), document.querySelector("[data-tb-move=\'forge|task:43\']").focus())');
|
||||
await b.evaluate('window.__held()');
|
||||
await wait('document.querySelector("[data-tasks-board]") !== window.__board');
|
||||
await b.evaluate('new Promise(r => setTimeout(r, 100))');
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'move:forge|task:43');
|
||||
});
|
||||
|
||||
test('an org whose tasks read fails shows its tasks as not read, not as none', async t => {
|
||||
const { b } = await open(t, { tasksFail: ['forge'] });
|
||||
assert.deepEqual((await cards(b)).sort(), ['ddigital|vikunja:3/112', 'ddigital|vikunja:3/118']);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tb-tracker=forge]").textContent'), /Forge.*tasks not read.*tracker-unavailable/s);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tb-org=forge]").textContent'), /Forge\s*unavailable/);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /2 tasks from 1 of 3 organizations/);
|
||||
// No org that can take a write is left, so New task is off.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), true);
|
||||
});
|
||||
|
||||
test('a board with no tasks read at all says so instead of showing an empty board', async t => {
|
||||
const { b } = await open(t, { tasksFail: ['forge', 'ddigital'] });
|
||||
assert.deepEqual(await cards(b), []);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]").textContent'), /No organization answered with its tasks, so no tasks could be read\./);
|
||||
});
|
||||
|
||||
test('a committed write whose answer cannot be read is shown as unknown, not as refused', async t => {
|
||||
const { b, wait, orgs } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
|
||||
// The write reaches the bus; a proxy then replaces the answer with a page that is not JSON.
|
||||
await b.evaluate(`(() => { const real = window.fetch; window.fetch = async (u, o) => { const r = await real(u, o); return String(u) === '/api/workspace/tasks' ? new Response('<html>Bad gateway</html>', { status: 502, headers: { 'content-type': 'text/html' } }) : r; }; })()`);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:41\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('/may or may not have been written/.test(document.querySelector("[data-tb-notice]").textContent)');
|
||||
const text = await b.evaluate('document.querySelector("[data-tb-notice]").textContent');
|
||||
assert.doesNotMatch(text, /was not moved|Nothing was written/);
|
||||
assert.match(text, /^#41: /);
|
||||
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:41', expect: D(1), state: 'in-review' }]]);
|
||||
// The board reads again and shows where the bus has the task.
|
||||
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']").value === "in-review"');
|
||||
});
|
||||
|
||||
test('a filter change asks the server for the matching tasks', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await b.evaluate(`(() => { const real = window.fetch; window.__reads = []; window.fetch = (u, o) => { if (String(u).startsWith('/api/workspace?')) window.__reads.push(String(u)); return real(u, o); }; })()`);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('window.__reads.length');
|
||||
const q = new URLSearchParams(await b.evaluate('window.__reads[0].split("?")[1]'));
|
||||
assert.equal(q.get('project'), 'forge:billing');
|
||||
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
|
||||
assert.deepEqual(await cards(b), ['forge|task:42']);
|
||||
});
|
||||
|
||||
test('a conflict on a task another writer moved out of the board\'s filter reopens on the current task', async t => {
|
||||
const { b, wait, orgs } = await open(t);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "in-progress"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
|
||||
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
||||
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
||||
// Another writer finishes the task, so the board's In progress read no longer holds it.
|
||||
const t42 = orgs.store.find(s => s.n === 42);
|
||||
Object.assign(t42, { state: 'done', rev: t42.rev + 1 });
|
||||
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
||||
await wait('document.querySelector("#tb-dialog form")?.state.value === "done"');
|
||||
assert.match(await b.evaluate('document.querySelector("#tb-dialog [data-tb-form-status]").textContent'), /changed after the board read it/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), true);
|
||||
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
||||
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
|
||||
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
|
||||
});
|
||||
|
||||
test('an empty result names the organizations whose tasks were not read', async t => {
|
||||
const { b, wait } = await open(t, { tasksFail: ['ddigital'] });
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "blocked"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('/No tasks match/.test(document.querySelector("[data-tb-results]").textContent)');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-results] .ws-panel").textContent'),
|
||||
'No tasks match these filters in the organizations that answered. Tasks from Distinctly Digital, Home could not be read.');
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
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';
|
||||
import { systemTypesFake } from './helpers/system-types-fake.mjs';
|
||||
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
|
||||
// Three organizations as sample 01 and 03 show them: two running buses and one stopped. P2: each running
|
||||
// organization's System types read comes from the installed modules; `send` adds the owner's writes.
|
||||
function workspaceOrgs({ failThreads = false, seen = null, send = false, types = {} } = {}) {
|
||||
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 TYPES = { ddigital: { enabled: ['coding', 'marketing'], projects: { 'spring-launch': ['coding'], 'brand-refresh': ['marketing'] } }, forge: { enabled: [], projects: { 'api-gateway': [] } }, ...types };
|
||||
const fakes = {};
|
||||
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 () => [] } };
|
||||
fakes[id] ??= systemTypesFake(TYPES[id]);
|
||||
const st = await fakes[id];
|
||||
return {
|
||||
busHealthy: true, requestRead: st.requestRead, systemTypesWrite: send ? st.systemTypesWrite : null, ...(send ? { send: async () => ({}) } : {}),
|
||||
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")');
|
||||
// Every sidebar view is built: Inbox (U3, workspace-pages-browser.test.mjs), Tasks (U4b,
|
||||
// tasks-board-browser.test.mjs) and Side by side (U5, side-by-side-browser.test.mjs).
|
||||
});
|
||||
|
||||
test('organization dashboard renders every section from the per-organization reads', async t => {
|
||||
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);
|
||||
// System types: the enabled modules by their own labels (P2).
|
||||
assert.match(await b.evaluate('document.querySelector(".ws-typechips").innerText'), /System types\s+Coding\s*Marketing\s+Each project picks from these/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'].value], ['org file', '2 of 5 installed enabled']);
|
||||
assert.equal(await b.evaluate('document.querySelector(\'[data-org-setting="System types"] button.go\').textContent'), 'Open');
|
||||
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.
|
||||
// The empty organization has no System types read, so it says unavailable, never that none exist.
|
||||
for (const line of [/System types\s+System types unavailable/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\)/);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ test('integrated project settings and conversation mounts use authorized context
|
||||
[`GET /api/chat/rooms/${ids.project}/messages?limit=100`]: {status:503,body:{error:'fixture-unavailable'}},
|
||||
'GET /api/project-files?project=fixture-repository': {status:503,body:{error:'fixture-unavailable'}},
|
||||
'GET /api/project-file?project=fixture-repository&path=spec%2Fnorth-star.md': {status:503,body:{error:'fixture-unavailable'}},
|
||||
[`GET /api/system-types/projects/${ids.project}`]: {status:503,body:{error:'fixture-unavailable'}},
|
||||
'/api/project-remotes/fixture-repository': { project: ids.repository, remote: null, revision: 'a'.repeat(64), firstSaveAllowed: true },
|
||||
};
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario });
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
// U3 (#1873, PRD 1.20 REQ-WS-1..6): the cross-organization Dashboard, Projects and Inbox pages
|
||||
// (samples 02, 04 and 05). Every bus here is a fake: two running organizations and one stopped,
|
||||
// read through the real GET /api/workspace (workspace-read.mjs) and the real page code.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
const at = minutesAgo => new Date(Date.now() - minutesAgo * 60000).toISOString();
|
||||
|
||||
// acme and beta answer; gamma's bus is stopped. Decision ids are long so a shortened id would show.
|
||||
const ACME_DECISION = 'dec-acme-7f3c9a21-5b8e-4d1f-9c2a-0e6b7d8f9a10';
|
||||
const BETA_DECISION = 'dec-beta-1a2b3c4d-5e6f-4a1b-8c9d-0e1f2a3b4c5d';
|
||||
function fixture({ pollMs, fail = {}, down = [] } = {}) {
|
||||
const LIST = [
|
||||
{ id: 'acme', label: 'Acme Studio', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'beta', label: 'Beta Works', theme: 'grove', busHealthy: true },
|
||||
{ id: 'gamma', label: 'Gamma Home', theme: 'rosewood', busHealthy: false },
|
||||
];
|
||||
const DATA = {
|
||||
acme: {
|
||||
projects: [
|
||||
{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: at(30) },
|
||||
{ id: 'p-zeta', repo: 'zeta', name: 'Zeta API', archived: false, at: at(300) },
|
||||
{ id: 'p-old', repo: 'old', name: 'Old brochure', archived: true, at: at(60 * 24 * 20) },
|
||||
],
|
||||
tasks: [{ task_ref: 'local:1', project_key: 'site', at: at(20), fields: { title: 'Hero copy', done: false, bucket: 'Doing' } }],
|
||||
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'working', at: at(40), working: { since: at(40) } }],
|
||||
claims: [], threads: [],
|
||||
inbox: [{ id: ACME_DECISION, question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', task_ref: 'local:1', at: at(10), options: [{ key: 'yes', text: 'Ship it' }, { key: 'no', text: 'Hold' }] }],
|
||||
decisions: [],
|
||||
},
|
||||
beta: {
|
||||
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: at(120) }],
|
||||
tasks: [], agents: [{ role: 'pm', holder_run: 'run-9', harness: 'claude', state: 'waiting', at: at(15) }], claims: [], threads: [],
|
||||
inbox: [{ id: BETA_DECISION, question: 'Pick a launch price', class: 'within-role', blocking: false, raised_by_role: 'pm', at: at(5), options: [{ key: 'low', text: 'Low' }] }],
|
||||
decisions: [],
|
||||
},
|
||||
};
|
||||
// fail: { org: [read names] } makes those reads refuse the way a disabled chat channel does.
|
||||
const value = (id, name) => fail[id]?.includes(name) ? Promise.reject(Object.assign(new Error('off'), { code: 'channel-disabled' })) : Promise.resolve(DATA[id]?.[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
|
||||
const opts = id => ({
|
||||
bus: { inbox: () => value(id, 'inbox'), agents: () => value(id, 'agents'), 'thread.claims': () => value(id, 'claims'), tasks: () => value(id, 'tasks'), usage: () => value(id, 'usage'), limits: () => value(id, 'limits'), decisions: () => value(id, 'decisions') },
|
||||
chat: { threads: () => value(id, 'threads'), projects: () => value(id, 'projects') },
|
||||
});
|
||||
const member = org => ({
|
||||
id: org.id, doc: { appearance: { theme: org.theme } },
|
||||
// down: the cross-organization read finds these buses stopped; the page's own reads still answer.
|
||||
status: async () => (org.busHealthy && !down.includes(org.id) ? { healthy: true, pid: 4242, boot: null } : { healthy: false, boot: { ok: false, at: at(90), lastGood: null, reason: 'stopped by the owner' } }),
|
||||
options: () => opts(org.id),
|
||||
});
|
||||
return {
|
||||
list: async () => LIST,
|
||||
members: async () => LIST.map(member),
|
||||
resolve: async id => {
|
||||
const org = LIST.find(o => o.id === id); if (!org) return null;
|
||||
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
|
||||
return {
|
||||
busHealthy: true,
|
||||
reads: { business: async () => ({ business: { id, human: 'jason', projects: DATA[id].projects.map(p => p.repo) } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
|
||||
bus: { messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => DATA[id].inbox.map(d => ({ ...d, business: id, raised_by_run: 'run', routed_to: 'human' })), tasks: async () => [], instances: async () => ({ business: id, instances: {} }) },
|
||||
chat: { projects: async () => DATA[id].projects, threads: async () => [] },
|
||||
};
|
||||
},
|
||||
...(pollMs ? { pollMs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(t, { viewport = [1440, 900], hash = '/', workspaceTimeoutMs, fail, down } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: fixture({ fail, down }), log: () => {}, ...(workspaceTimeoutMs ? { workspaceTimeoutMs } : {}) });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(...viewport);
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
await b.navigate(`${base}/#/orgs/acme${hash}`);
|
||||
return { b, base, wait: waitFor(b), text: sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.innerText ?? null`) };
|
||||
}
|
||||
|
||||
test('Dashboard renders every section across organizations, and a stopped one shows its state while the others render', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait('document.querySelector("[data-ws-home] [data-ws-tiles]") && document.querySelectorAll("[data-ws-orgcard]").length === 3');
|
||||
// The Dashboard nav item is this page.
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Dashboard');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/');
|
||||
assert.equal(await text('#bus-view h1'), 'Dashboard');
|
||||
// Tiles: needs you, sessions, open tasks, projects, and an honest line for spend.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-tile]")].map(e => e.dataset.wsTile)'), ['needs', 'sessions', 'tasks', 'projects', 'spend']);
|
||||
assert.match(await text('[data-ws-tile=needs]'), /Needs you\s*2\s*items/i);
|
||||
assert.match(await text('[data-ws-tile=projects]'), /Projects\s*4\s*3 active/i);
|
||||
assert.match(await text('[data-ws-tile=spend]'), /No spend figures yet/);
|
||||
assert.doesNotMatch(await text('[data-ws-tile=spend]'), /\$|\d/);
|
||||
// Organization cards: two running with their read, the stopped one with its state and the time it was read.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-orgcard]")].map(e => [e.dataset.wsOrgcard, e.dataset.state])'), [['acme', 'ok'], ['beta', 'ok'], ['gamma', 'down']]);
|
||||
assert.match(await text('[data-ws-orgcard=gamma]'), /Gamma Home/);
|
||||
assert.match(await text('[data-ws-orgcard=gamma]'), /down/);
|
||||
assert.match(await text('[data-ws-orgcard=gamma]'), /stopped by the owner/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").getAttribute("href")'), '#/orgs/beta/organization');
|
||||
assert.match(await text('[data-ws-home] .sub'), /2 of 3 organizations answered/);
|
||||
assert.match(await text('[data-ws-home] .sub'), /Gamma Home down, read \d\d:\d\d/);
|
||||
// Recent project activity across both running organizations, newest first, each linking into its org.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-recent] a")].map(a => a.getAttribute("href"))'), ['#/orgs/acme/projects/p-site', '#/orgs/beta/projects/p-shop', '#/orgs/acme/projects/p-zeta']);
|
||||
assert.match(await text('[data-ws-states]'), /Doing\s*1/);
|
||||
assert.match(await text('[data-ws-states]'), /Done this week\s*0/);
|
||||
// Needs you lists both organizations' decisions, newest raised first as the read orders them.
|
||||
const needs = await b.evaluate('[...document.querySelectorAll("[data-ws-needs] a.q")].map(a => [a.textContent, a.getAttribute("href")])');
|
||||
assert.equal(needs.length, 2);
|
||||
assert.ok(needs.some(([q, h]) => q === 'Ship the hero?' && h === `#/orgs/acme/inbox?d=${encodeURIComponent(`acme:${ACME_DECISION}`)}`), JSON.stringify(needs));
|
||||
assert.ok(needs.some(([q]) => q === 'Pick a launch price'));
|
||||
// Sessions now: the working and the waiting role, each with its organization.
|
||||
assert.match(await text('[data-ws-sessions]'), /coder/);
|
||||
assert.match(await text('[data-ws-sessions]'), /pm/);
|
||||
// The sidebar Inbox count follows the read across organizations.
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[href=\'#/inbox\'] .ws-n")?.textContent'), '2');
|
||||
// The per-organization board moved to #/board; the processing link goes there.
|
||||
assert.equal(await b.evaluate('document.getElementById("processing-link").getAttribute("href")'), '#/board');
|
||||
});
|
||||
|
||||
test('Projects sorts and filters by each key, remembers the grouping, and rows open the project in its organization', async t => {
|
||||
const { b, wait, text } = await setup(t, { hash: '/projects' });
|
||||
const names = 'JSON.stringify([...document.querySelectorAll("[data-ws-project]")].map(r => r.dataset.wsProject))';
|
||||
const settle = async (hash, expected) => { await wait(`location.hash === ${JSON.stringify(hash)} && ${names} === ${JSON.stringify(JSON.stringify(expected))}`); };
|
||||
await wait('document.querySelector("[data-ws-projects] [data-ws-table]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Projects');
|
||||
// Default: active only, newest activity first, grouped by organization with the stopped one marked.
|
||||
assert.equal(await b.evaluate(names), JSON.stringify(['acme:p-site', 'acme:p-zeta', 'beta:p-shop']));
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-group-org]")].map(r => r.dataset.wsGroupOrg)'), ['acme', 'beta', 'gamma']);
|
||||
assert.match(await text('[data-ws-table] tr.ws-unread'), /Not read.*the bus/);
|
||||
// Rows open the existing project page in their own organization.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-project=\'beta:p-shop\'] a.nm").getAttribute("href")'), '#/orgs/beta/projects/p-shop');
|
||||
// Sort by name (ascending), then again to reverse it.
|
||||
await b.evaluate('document.querySelector("[data-ws-sort=name]").click()');
|
||||
await b.evaluate('document.querySelector("[data-ws-group=flat]").click()');
|
||||
await settle('#/orgs/acme/projects?sort=name', ['beta:p-shop', 'acme:p-site', 'acme:p-zeta']);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-table] th[aria-sort]").textContent.trim()'), 'Project↑');
|
||||
await b.evaluate('document.querySelector("[data-ws-sort=name]").click()');
|
||||
await settle('#/orgs/acme/projects?sort=name&order=desc', ['acme:p-zeta', 'acme:p-site', 'beta:p-shop']);
|
||||
// Sort by organization and by status.
|
||||
await b.evaluate('document.querySelector("[data-ws-sort=org]").click()');
|
||||
await wait('location.hash === "#/orgs/acme/projects?sort=org" && document.querySelector("[data-ws-sort=org]").getAttribute("aria-pressed") === "true"');
|
||||
assert.deepEqual(JSON.parse(await b.evaluate(names)).map(k => k.split(':')[0]), ['acme', 'acme', 'beta']);
|
||||
await b.evaluate('document.querySelector("[data-ws-sort=status]").click()');
|
||||
await wait('location.hash === "#/orgs/acme/projects?sort=status" && document.querySelector("[data-ws-sort=status]").getAttribute("aria-pressed") === "true"');
|
||||
// Filter by status: archived and both.
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-ws-status]"); s.value = "archived"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await settle('#/orgs/acme/projects?sort=status&status=archived', ['acme:p-old']);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-ws-status]"); s.value = "active,archived"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('document.querySelectorAll("[data-ws-project]").length === 4');
|
||||
// Filter by session state: only Website has a decision waiting.
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-ws-state]"); s.value = "waiting"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait(`${names} === ${JSON.stringify(JSON.stringify(['acme:p-site']))}`);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-ws-state]"); s.value = ""; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('document.querySelectorAll("[data-ws-project]").length === 4');
|
||||
// Filter by organization chip, then back to all.
|
||||
await b.evaluate('document.querySelector("[data-ws-org=beta]").click()');
|
||||
await wait(`${names} === ${JSON.stringify(JSON.stringify(['beta:p-shop']))} && document.querySelector("[data-ws-org=beta]").getAttribute("aria-pressed") === "true"`);
|
||||
assert.match(location(await b.evaluate('location.hash')), /org=beta/);
|
||||
await b.evaluate('document.querySelector("[data-ws-org=\'\']").click()');
|
||||
await wait('document.querySelectorAll("[data-ws-project]").length === 4');
|
||||
// Filter by name: typing keeps focus and the caret in the box while the list narrows.
|
||||
await b.evaluate('(() => { const i = document.querySelector("[data-ws-name]"); i.focus(); i.value = "zet"; i.dispatchEvent(new Event("input", { bubbles: true })); })()');
|
||||
await wait(`${names} === ${JSON.stringify(JSON.stringify(['acme:p-zeta']))}`);
|
||||
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement.value, document.activeElement.selectionStart]'), ['proj-name', 'zet', 3]);
|
||||
// Flat mode shows an Organization column and names the stopped organization; the choice is the
|
||||
// sidebar's remembered one and survives a reload.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-table]").dataset.grouping'), 'flat');
|
||||
assert.match(await text('[data-ws-unread]'), /Gamma Home down/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#workspace-nav [data-group=flat]").getAttribute("aria-pressed")'), 'true');
|
||||
assert.equal(await b.evaluate('localStorage.getItem("mosaic-workspace-project-grouping-v1")'), 'flat');
|
||||
await b.evaluate('location.reload()');
|
||||
await wait('document.querySelector("[data-ws-table]")?.dataset.grouping === "flat" && document.querySelectorAll("[data-ws-project]").length === 1 && document.querySelector("#workspace-nav [data-group=flat]")?.getAttribute("aria-pressed") === "true"');
|
||||
// The sidebar's toggle switches this page back.
|
||||
// The nav repaints after async reads, so the click retries until the page switches.
|
||||
await wait('(document.querySelector("#workspace-nav [data-group=org]")?.click(), document.querySelector("[data-ws-table]")?.dataset.grouping === "org")');
|
||||
await wait('document.querySelector("[data-ws-table]")?.dataset.grouping === "org" && document.querySelector("[data-ws-group=org]").getAttribute("aria-pressed") === "true"');
|
||||
});
|
||||
const location = hash => decodeURIComponent(hash);
|
||||
|
||||
test('Inbox lists both organizations, copies a command with --org and the full id, and deciding needs human proof', async t => {
|
||||
const { b, base, wait, text } = await setup(t, { hash: '/inbox' });
|
||||
await wait('document.querySelectorAll("[data-ws-item]").length === 2 && document.querySelector("[data-ws-insp]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page] .t")?.textContent'), 'Inbox');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-not-built]")'), null);
|
||||
const items = await b.evaluate('[...document.querySelectorAll("[data-ws-item]")].map(r => [r.dataset.wsItem, r.innerText])');
|
||||
assert.deepEqual(items.map(([k]) => k).sort(), [`acme:${ACME_DECISION}`, `beta:${BETA_DECISION}`]);
|
||||
// Each row names its organization, project and session.
|
||||
const acme = items.find(([k]) => k.startsWith('acme'))[1];
|
||||
assert.match(acme, /Acme Studio/); assert.match(acme, /Website/); assert.match(acme, /coder/); assert.match(acme, /gated/);
|
||||
const beta = items.find(([k]) => k.startsWith('beta'))[1];
|
||||
assert.match(beta, /Beta Works/); assert.match(beta, /No project/); assert.match(beta, /pm/);
|
||||
assert.match(await text('[data-ws-unread]'), /Gamma Home down/);
|
||||
// Tabs the data can't back say so.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-ws-inbox] .ws-seg button")].map(x => [x.textContent, x.disabled])'), [['Needs you', false], ['Seen', true], ['Answered', true]]);
|
||||
assert.match(await text('[data-ws-inbox]'), /not built yet/);
|
||||
// Select the acme decision; the inspector shows it, and the selection stays in the hash.
|
||||
// A pointer click focuses the link before it activates; .click() alone does not.
|
||||
await b.evaluate(`(() => { const a = document.querySelector('[data-ws-select="acme:${ACME_DECISION}"]'); a.focus(); a.click(); })()`);
|
||||
await wait(`document.querySelector("[data-ws-insp]")?.dataset.wsInsp === ${JSON.stringify(`acme:${ACME_DECISION}`)}`);
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), `item:acme:${ACME_DECISION}`);
|
||||
const cmds = await b.evaluate('[...document.querySelectorAll("[data-ws-cmds] [data-copy]")].map(x => [x.dataset.copy, x.previousElementSibling.tagName, x.previousElementSibling.textContent])');
|
||||
assert.deepEqual(cmds, [[`mosaic decide ${ACME_DECISION} yes --org acme`, 'CODE', `mosaic decide ${ACME_DECISION} yes --org acme`], [`mosaic decide ${ACME_DECISION} no --org acme`, 'CODE', `mosaic decide ${ACME_DECISION} no --org acme`]]);
|
||||
// Copy puts the full command on the clipboard (or selects it when there is none) and says so.
|
||||
await b.evaluate('document.querySelector("[data-ws-cmds] [data-copy]").click()');
|
||||
await wait('document.getElementById("copy-status")?.textContent.length > 0');
|
||||
const status = await text('#copy-status');
|
||||
assert.ok(status.includes(`mosaic decide ${ACME_DECISION} yes --org acme`) || /clipboard is unavailable/.test(status), status);
|
||||
// An answer opens the existing per-organization decision page.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-ws-insp] .ws-opts a").getAttribute("href")'), `#/orgs/acme/inbox/${ACME_DECISION}`);
|
||||
// Without a signed-in human session and its proof, the per-organization decide path refuses.
|
||||
const refused = await b.evaluate(`(async () => { const r = await fetch('/api/orgs/acme/decisions/${ACME_DECISION}/confirmation', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'yes' }) }); return [r.status, (await r.json().catch(() => ({}))).error ?? null]; })()`);
|
||||
assert.ok(refused[0] >= 400 && refused[0] < 500, JSON.stringify(refused));
|
||||
// The decision page itself offers no answer controls without that proof.
|
||||
await b.navigate(`${base}/#/orgs/acme/inbox/${ACME_DECISION}`);
|
||||
await wait('[...document.querySelectorAll("#bus-view h1")].length > 0 && !document.querySelector("#bus-view .loading")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#decide-section [data-decide]")'), null);
|
||||
// Filter to one organization.
|
||||
await b.navigate(`${base}/#/orgs/acme/inbox`);
|
||||
await wait('document.querySelectorAll("[data-ws-item]").length === 2');
|
||||
await b.evaluate('document.querySelector("[data-ws-org=beta]").click()');
|
||||
await wait(`document.querySelectorAll("[data-ws-item]").length === 1 && document.querySelector("[data-ws-item]").dataset.wsItem === ${JSON.stringify(`beta:${BETA_DECISION}`)}`);
|
||||
});
|
||||
|
||||
test('A poll repaints in place: focus, caret and scroll stay where they were', async t => {
|
||||
const { b, wait } = await setup(t, { hash: '/projects', viewport: [1000, 420] });
|
||||
await wait('document.querySelector("[data-ws-table]") && document.querySelectorAll("[data-ws-project]").length === 3');
|
||||
assert.ok(await b.evaluate('window.MosaicWorkspace.pollMs') >= 1000);
|
||||
// Focus a row link, scroll the page, and repaint as a poll does.
|
||||
await b.evaluate('document.querySelector("[data-ws-project=\'beta:p-shop\'] a.nm").focus({ preventScroll: true }); (document.scrollingElement.scrollTop = 120); document.getElementById("main").scrollTop = 120;');
|
||||
const before = await b.evaluate('[document.scrollingElement.scrollTop, document.getElementById("main").scrollTop]');
|
||||
assert.ok(before.some(v => v > 0), `nothing scrolled: ${before}`);
|
||||
await b.evaluate('window.__marker = document.querySelector("[data-ws-table]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
|
||||
await wait('document.querySelector("[data-ws-table]") !== window.__marker');
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'prow:beta:p-shop');
|
||||
assert.deepEqual(await b.evaluate('[document.scrollingElement.scrollTop, document.getElementById("main").scrollTop]'), before);
|
||||
// The search box keeps its caret across a repaint.
|
||||
await b.evaluate('(() => { const i = document.querySelector("[data-ws-name]"); i.focus(); i.value = "shop"; i.dispatchEvent(new Event("input", { bubbles: true })); })()');
|
||||
await wait('location.hash.includes("name=shop") && document.querySelectorAll("[data-ws-project]").length === 1 && document.activeElement?.dataset.focusKey === "proj-name"');
|
||||
await b.evaluate('document.activeElement.setSelectionRange(2, 2)');
|
||||
await b.evaluate('window.__marker = document.querySelector("[data-ws-table]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
|
||||
await wait('document.querySelector("[data-ws-table]") !== window.__marker');
|
||||
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement.selectionStart]'), ['proj-name', 2]);
|
||||
// The Dashboard and the Inbox repaint the same way.
|
||||
await b.evaluate('location.hash = "#/orgs/acme/"');
|
||||
await wait('document.querySelectorAll("[data-ws-orgcard]").length === 3');
|
||||
await b.evaluate('document.querySelector("[data-ws-orgcard=beta] a").focus()');
|
||||
await b.evaluate('window.__marker = document.querySelector("[data-ws-orgs]"); document.dispatchEvent(new CustomEvent("mosaic-workspace-render"))');
|
||||
await wait('document.querySelector("[data-ws-orgs]") !== window.__marker');
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'orgcard:beta');
|
||||
// No loading view replaced the page during that repaint.
|
||||
assert.equal(await b.evaluate('document.querySelector("#bus-view .loading")'), null);
|
||||
});
|
||||
|
||||
test('Phone width: no horizontal scroll on any of the three pages, and the drawer opens and closes', async t => {
|
||||
const { b, wait } = await setup(t, { viewport: [390, 844] });
|
||||
for (const [hash, ready] of [['/', '[data-ws-orgcard]'], ['/projects', '[data-ws-project]'], ['/inbox', '[data-ws-item]']]) {
|
||||
await b.evaluate(`location.hash = "#/orgs/acme${hash}"`);
|
||||
await wait(`document.querySelector(${JSON.stringify(ready)})`);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, hash);
|
||||
}
|
||||
await b.evaluate('document.getElementById("nav-open").click()');
|
||||
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "true"');
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.getElementById("console-nav")).visibility !== "hidden" && document.getElementById("console-nav").getBoundingClientRect().right > 0'), true);
|
||||
await b.evaluate('document.getElementById("nav-close").click()');
|
||||
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "false"');
|
||||
// A sidebar link closes the drawer and opens its page.
|
||||
await b.evaluate('document.getElementById("nav-open").click()');
|
||||
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "true"');
|
||||
await b.evaluate('document.querySelector("#sections a[href=\'#/projects\']").click()');
|
||||
await wait('location.hash === "#/orgs/acme/projects" && document.querySelector("[data-ws-project]")');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
|
||||
// An inbox row at phone width puts its source on its own line above the question.
|
||||
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
|
||||
await wait('document.querySelector("[data-ws-item] .ws-msrc")');
|
||||
assert.equal(await b.evaluate(`(() => { const r = document.querySelector('[data-ws-item]'), s = r.querySelector('.ws-msrc').getBoundingClientRect(), q = r.querySelector('a.q').getBoundingClientRect(); return s.height > 0 && s.bottom <= q.top + 1; })()`), true, 'source above question');
|
||||
});
|
||||
|
||||
test('A read that an answering organization could not make says not read, never zero', async t => {
|
||||
// beta answers, but its project and inbox reads refuse.
|
||||
const { b, wait, text } = await setup(t, { fail: { beta: ['projects', 'inbox'] } });
|
||||
await wait('document.querySelector("[data-ws-tiles]")');
|
||||
assert.match(await text('[data-ws-tile=projects] [data-ws-miss=projects]'), /Not read in Beta Works \(the chat channel is off\)/);
|
||||
assert.match(await text('[data-ws-tile=needs] [data-ws-miss=needs]'), /Not read in Beta Works/);
|
||||
assert.match(await text('[data-ws-miss=recent]'), /Not read in Beta Works/);
|
||||
assert.match(await text('[data-ws-miss=needs]:not([data-ws-tile] *)'), /Not read in Beta Works/);
|
||||
assert.doesNotMatch(await text('.ws-home .sub'), /and \d+ projects?/, 'no project total when a project read failed');
|
||||
// Projects, grouped: beta's group says why and shows no count or "No projects match".
|
||||
await b.evaluate('location.hash = "#/orgs/acme/projects"');
|
||||
await wait('document.querySelector("[data-ws-group-org=beta]")');
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-ws-group-org=beta] .ws-n')`), null);
|
||||
const betaRow = await b.evaluate(`document.querySelector('[data-ws-group-org=beta]').nextElementSibling.innerText`);
|
||||
assert.match(betaRow, /Projects not read: the chat channel is off/);
|
||||
assert.match(await text('[data-ws-org=beta] .n'), /not read/i);
|
||||
// Flat: the note names it.
|
||||
await b.evaluate('document.querySelector("[data-ws-group=flat]").click()');
|
||||
await wait('document.querySelector("[data-ws-miss=projects]")');
|
||||
assert.match(await text('[data-ws-miss=projects]'), /Not read in Beta Works/);
|
||||
// Inbox: the note names it, and the chip carries no count.
|
||||
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
|
||||
await wait('document.querySelector("[data-ws-miss=inbox]")');
|
||||
assert.match(await text('[data-ws-miss=inbox]'), /Not read in Beta Works \(the chat channel is off\)/);
|
||||
assert.match(await text('[data-ws-org=beta] .n'), /not read/i);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-item]").length'), 1);
|
||||
});
|
||||
|
||||
test('When every answering organization failed a read, its tile says Not read instead of a number', async t => {
|
||||
const { wait, text } = await setup(t, { fail: { acme: ['projects'], beta: ['projects'] } });
|
||||
await wait('document.querySelector("[data-ws-tiles]")');
|
||||
assert.match(await text('[data-ws-tile=projects]'), /Not read/i);
|
||||
assert.equal(await text('[data-ws-tile=projects] .ws-big'), null);
|
||||
});
|
||||
|
||||
// Review P2 (org-u3): a project row's figures come from reads beyond the project list.
|
||||
test('Project rows and per-organization figures say not read when the reads behind them failed', async t => {
|
||||
const { b, wait, text } = await setup(t, { fail: { beta: ['inbox', 'tasks', 'agents', 'claims', 'threads'] }, hash: '/projects' });
|
||||
await wait('document.querySelector("[data-ws-project=\'beta:p-shop\']")');
|
||||
const cells = await b.evaluate(`[...document.querySelector("[data-ws-project='beta:p-shop']").cells].map(c => c.innerText.trim())`);
|
||||
// Grouped columns: Project, Status, Sessions, Open tasks, Needs you, Last activity, Git.
|
||||
assert.deepEqual(cells.slice(2, 5), ['not read', 'not read', 'not read'], 'sessions, open tasks and needs you');
|
||||
assert.match(cells[5], /some reads missing/);
|
||||
const acme = await b.evaluate(`[...document.querySelector("[data-ws-project='acme:p-site']").cells].map(c => c.innerText.trim())`);
|
||||
assert.equal(acme[3], '1'); assert.equal(acme[4], '1');
|
||||
assert.match(await text('[data-ws-miss=figures]'), /Some figures not read in Beta Works/);
|
||||
// Dashboard: the per-organization figures leave Beta out instead of showing its 0.
|
||||
await b.evaluate('location.hash = "#/orgs/acme/"');
|
||||
await wait('document.querySelector("[data-ws-tiles]")');
|
||||
for (const tile of ['needs', 'sessions', 'tasks']) {
|
||||
const by = await b.evaluate(`document.querySelector('[data-ws-tile=${tile}] .ws-by')?.textContent ?? ''`);
|
||||
assert.doesNotMatch(by, /Beta Works/, tile);
|
||||
assert.match(await text(`[data-ws-tile=${tile}] [data-ws-miss=${tile}]`), /Not read in Beta Works/, tile);
|
||||
}
|
||||
assert.doesNotMatch(await b.evaluate(`[...document.querySelectorAll('[data-ws-states] .ws-srow')].find(r => /Done this week/.test(r.textContent))?.querySelector('.ws-by')?.textContent ?? ''`), /Beta Works/);
|
||||
});
|
||||
|
||||
test('With every organization down the dashboard and inbox show no totals and no empty-list claims', async t => {
|
||||
const { b, wait, text } = await setup(t, { down: ['acme', 'beta'] });
|
||||
await wait('document.querySelector("[data-ws-tiles]")');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-ws-home] .ws-big").length'), 0);
|
||||
assert.match(await text('[data-ws-tiles]'), /No organization answered/);
|
||||
assert.equal(await text('[data-ws-needs]'), 'Decisions not read');
|
||||
assert.equal(await text('[data-ws-states]'), 'Tasks not read');
|
||||
assert.equal(await text('[data-ws-recent]'), 'Projects not read');
|
||||
assert.equal(await text('[data-ws-sessions]'), 'Sessions not read');
|
||||
await b.evaluate('location.hash = "#/orgs/acme/inbox"');
|
||||
await wait('document.querySelector("[data-ws-items]")');
|
||||
assert.equal(await text('[data-ws-items]'), 'Decisions not read');
|
||||
await b.evaluate('location.hash = "#/orgs/acme/projects?grouping=flat"');
|
||||
await wait('document.querySelector("[data-ws-table]")');
|
||||
await b.evaluate('document.querySelector("[data-ws-group=flat]").click()');
|
||||
await wait('document.querySelector("[data-ws-table][data-grouping=flat]")');
|
||||
assert.match(await text('[data-ws-table] tbody'), /Projects not read/);
|
||||
});
|
||||
|
||||
test('When no organization could read its tasks, Tasks by state shows no Done this week figure', async t => {
|
||||
const { wait, text } = await setup(t, { fail: { acme: ['tasks'], beta: ['tasks'] } });
|
||||
await wait('document.querySelector("[data-ws-states]")');
|
||||
assert.equal(await text('[data-ws-states]'), 'Tasks not read');
|
||||
assert.match(await text('[data-ws-tile=tasks]'), /Not read/);
|
||||
});
|
||||
|
||||
// Review P3: the session's organization is in the accessible text, not only in the hidden avatar.
|
||||
test('Sessions now names each session\'s organization for a screen reader', async t => {
|
||||
const { b, wait } = await setup(t);
|
||||
await wait('document.querySelector("[data-ws-sessions] td.who")');
|
||||
const who = await b.evaluate('[...document.querySelectorAll("[data-ws-sessions] td.who")].map(c => c.textContent)');
|
||||
assert.ok(who.some(w => /Acme Studio: coder/.test(w)), who.join('|'));
|
||||
assert.ok(who.some(w => /Beta Works: pm/.test(w)), who.join('|'));
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
// U4b (#1873, PRD 1.20): the task board's server side. The write route POST /api/workspace/tasks
|
||||
// (strict body, the hook fence, owner and --send, one org per write, the bus's refusals), the task
|
||||
// rows the cross-organization read adds, and a Vikunja organization's tasks read through the real
|
||||
// tasks adapter from a fake Vikunja listening on port 0. Every bus here is a real Broker over a Store
|
||||
// in this test's own scratch, or a fake options object; no test reaches a live bus or a real Vikunja.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { storeAdapter } from '../../tasks/src/store.mjs';
|
||||
import { FakeVikunja } from '../../tasks/src/fake.mjs';
|
||||
import { world, sleep } from '../../tasks/tests/world.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { actionTrail } from '../src/action-trail.mjs';
|
||||
import { parseTaskWrite, TASK_WRITE_ROUTE } from '../src/workspace-tasks.mjs';
|
||||
import { parseWorkspaceQuery, readWorkspace, WorkspaceQueryError, TASKS_PER_ORG } from '../src/workspace-read.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const D = n => String(n).repeat(64);
|
||||
const scratch = (t, prefix) => { const root = mkdtempSync(join(tmpdir(), prefix)); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
|
||||
|
||||
function call(target, { method = 'GET', path, headers = {}, body }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const all = { ...headers };
|
||||
for (const k of Object.keys(all)) if (all[k] === null) delete all[k];
|
||||
if (body !== undefined) all['content-length'] = Buffer.byteLength(body);
|
||||
const req = request({ ...target, method, path, headers: all }, res => {
|
||||
let raw = '';
|
||||
res.on('data', c => { raw += c; });
|
||||
res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch { /* not JSON */ } resolve({ status: res.statusCode, body: json }); });
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
// One organization's bus: a real Broker over its own Store. `taskTracker` names the module the bus
|
||||
// runtime chose (a Vikunja org's is not the store). `human` is the person's cap, `agent` a role's.
|
||||
const AUTHORITY = { withinRole: ['task.update.assigned', 'message.send'], crossRole: [] };
|
||||
async function bus(t, id, { taskTracker = null } = {}) {
|
||||
const root = scratch(t, 'tb-');
|
||||
const businesses = { [id]: { id, human: 'jason', arbiters: { technical: 'reviewer', delivery: 'coder' }, roles: { coder: { definition: 'coder', authority: AUTHORITY }, reviewer: { definition: 'reviewer', authority: AUTHORITY } } } };
|
||||
const store = new Store(root);
|
||||
const broker = new Broker({ store, businesses, ...(taskTracker ? { taskTracker } : {}) });
|
||||
const adapter = await storeAdapter()({ broker, businesses });
|
||||
t.after(() => { adapter.close(); store.close(); });
|
||||
const human = broker.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const agent = broker.bindLaunch({ business: id, role: 'coder', run: 'coder-run', harness: 'pi' });
|
||||
broker.request(agent, { verb: 'role.claim' });
|
||||
const read = () => broker.request(broker.bindReader({ business: id }), { verb: 'tasks', args: {} });
|
||||
return { broker, human, agent, read };
|
||||
}
|
||||
|
||||
// The registry the route resolves through: `resolve(id, session)` admits an org the session belongs
|
||||
// to and answers its own transport. Every resolve and every bus call is recorded by org.
|
||||
async function registry(t) {
|
||||
const forge = await bus(t, 'forge');
|
||||
const ddigital = await bus(t, 'ddigital', { taskTracker: () => 'vikunja' });
|
||||
const robot = await bus(t, 'robot');
|
||||
const resolved = [], sent = [];
|
||||
const writer = (org, b, cap) => async (verb, args) => { sent.push([org, verb, args]); return b.broker.request(cap, { verb, args }); };
|
||||
const orgs = {
|
||||
forge: { busHealthy: true, taskWrite: writer('forge', forge, forge.human) },
|
||||
ddigital: { busHealthy: true, taskWrite: writer('ddigital', ddigital, ddigital.human) },
|
||||
// A Console started by an agent: the bus sees no person and answers human-required.
|
||||
robot: { busHealthy: true, taskWrite: writer('robot', robot, robot.agent) },
|
||||
home: { busHealthy: false, taskWrite: async (...a) => { sent.push(['home', ...a]); return {}; } },
|
||||
lost: { busHealthy: true, taskWrite: async (...a) => { sent.push(['lost', ...a]); throw new Error('socket closed'); } },
|
||||
odd: { busHealthy: true, taskWrite: async (...a) => { sent.push(['odd', ...a]); throw Object.assign(new Error('x'), { code: 'brand-new-code' }); } },
|
||||
quiet: { busHealthy: true, taskWrite: null },
|
||||
};
|
||||
const organizations = {
|
||||
list: async () => [], members: async () => [],
|
||||
async resolve(id, session) {
|
||||
resolved.push([id, session?.sub ?? null]);
|
||||
if (id === 'old') throw Object.assign(new Error('archived'), { code: 'org-archived' });
|
||||
if (id === 'broken') throw new Error('/srv/x/org.json: Unexpected token');
|
||||
return orgs[id] ?? null;
|
||||
},
|
||||
};
|
||||
return { organizations, resolved, sent, forge, ddigital, robot };
|
||||
}
|
||||
|
||||
async function serve(t, { organizations, send = true, sso = createDevOwner() }) {
|
||||
const dir = join(scratch(t, 'tw-'), 'console');
|
||||
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, organizations, log: () => {}, trail: actionTrail(dir), ...(send ? { send: async () => ({}) } : {}) });
|
||||
t.after(() => close(server));
|
||||
const port = server.address().port, host = `127.0.0.1:${port}`, origin = `http://${host}`;
|
||||
const post = (body, headers = {}) => call({ host: '127.0.0.1', port }, { method: 'POST', path: TASK_WRITE_ROUTE, body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
headers: { host, origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...headers } });
|
||||
const trail = () => (existsSync(join(dir, 'actions.jsonl')) ? readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []);
|
||||
return { post, trail, origin };
|
||||
}
|
||||
|
||||
test('parseTaskWrite takes the three ops strictly and refuses before any bus', () => {
|
||||
assert.deepEqual(parseTaskWrite({ op: 'create', org: 'forge', title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', state: 'todo', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' }),
|
||||
{ op: 'create', org: 'forge', verb: 'tasks.create', args: { title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', state: 'todo', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' } });
|
||||
assert.deepEqual(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), state: 'in-review' }),
|
||||
{ op: 'edit', org: 'forge', verb: 'tasks.edit', args: { task_ref: 'task:4', expect: D(1), state: 'in-review' } });
|
||||
assert.deepEqual(parseTaskWrite({ op: 'archive', org: 'forge', task_ref: 'task:4', expect: D(1) }), { op: 'archive', org: 'forge', verb: 'tasks.archive', args: { task_ref: 'task:4', expect: D(1) } });
|
||||
assert.equal(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), due_date: null }).args.due_date, null);
|
||||
const refused = [
|
||||
[{ op: 'delete', org: 'forge', task_ref: 'task:1' }, /^op takes create, edit, archive$/],
|
||||
[{ op: 'unarchive', org: 'forge', task_ref: 'task:1' }, /^op takes/],
|
||||
[{ op: 'edit', org: 'Forge!', task_ref: 'task:1', title: 'x' }, /^org takes/],
|
||||
[{ op: 'edit', task_ref: 'task:1', title: 'x' }, /^org takes/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', description: 'x' }, /^edit does not take description$/],
|
||||
[{ op: 'archive', org: 'forge', task_ref: 'task:1', title: 'x' }, /^archive does not take title$/],
|
||||
[{ op: 'create', org: 'forge', title: 'x' }, /^create needs requirement$/],
|
||||
[{ op: 'edit', org: 'forge', title: 'x' }, /^edit needs task_ref$/],
|
||||
// An edit or archive without the digest the board read is refused before any bus (no silent overwrite).
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', title: 'x' }, /^edit needs expect$/],
|
||||
[{ op: 'archive', org: 'forge', task_ref: 'task:1' }, /^archive needs expect$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(2) }, /^edit needs a field to change$/],
|
||||
// A Vikunja task is never written from the board: its ref is refused here.
|
||||
[{ op: 'edit', org: 'ddigital', expect: D(1), task_ref: 'vikunja:3/112', title: 'x' }, /^task_ref is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:0', title: 'x' }, /^task_ref is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: 'abc', title: 'x' }, /^expect is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: ' ' }, /^title is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: 'x'.repeat(251) }, /^title is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', state: 'doing' }, /^state is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 6 }, /^priority is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 1.5 }, /^priority is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: ['coder', 'bad name'] }, /^assignees is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: 'coder' }, /^assignees is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-11-20' }, /^due_date is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-02-30T00:00:00.000Z' }, /^due_date is not valid$/],
|
||||
[{ op: 'create', org: 'forge', title: 'x', requirement: 'req-1' }, /^requirement is not valid$/],
|
||||
[{ op: 'create', org: 'forge', title: 'x', requirement: 'REQ-TASK-1', group: 'a\nb' }, /^group is not valid$/],
|
||||
];
|
||||
for (const [body, message] of refused) assert.throws(() => parseTaskWrite(body), e => e.status === 400 && message.test(e.message), JSON.stringify(body));
|
||||
});
|
||||
|
||||
test('route: the fence, the owner and --send come before any org is resolved', async t => {
|
||||
const r = await registry(t);
|
||||
const { post } = await serve(t, { organizations: r.organizations });
|
||||
const body = { op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(1), title: 'x' };
|
||||
const fenced = [
|
||||
[{ origin: null }, 'cross-origin request refused'],
|
||||
[{ origin: 'http://evil.test' }, 'cross-origin request refused'],
|
||||
[{ 'sec-fetch-site': null }, 'cross-site request refused'],
|
||||
[{ 'sec-fetch-site': 'cross-site' }, 'cross-site request refused'],
|
||||
[{ 'sec-fetch-site': 'same-site' }, 'cross-site request refused'],
|
||||
];
|
||||
for (const [headers, error] of fenced) {
|
||||
const res = await post(body, headers);
|
||||
assert.equal(res.status, 403, JSON.stringify(headers)); assert.equal(res.body.error, error);
|
||||
}
|
||||
// Body checks: content type, size and shape, each with sent:false.
|
||||
const bad = [
|
||||
[await post(JSON.stringify(body), { 'content-type': 'text/plain' }), 415],
|
||||
[await post(JSON.stringify({ ...body, title: 'x'.repeat(17000) })), 413],
|
||||
[await post('{not json'), 400],
|
||||
[await post('[1]'), 400],
|
||||
[await post({ ...body, extra: 1 }), 400],
|
||||
];
|
||||
for (const [res, status] of bad) { assert.equal(res.status, status); assert.equal(res.body.error, 'invalid-request'); assert.equal(res.body.sent, false); }
|
||||
assert.deepEqual(r.resolved, []); assert.deepEqual(r.sent, []);
|
||||
|
||||
// Without --send the Console is read only.
|
||||
const ro = await serve(t, { organizations: r.organizations, send: false });
|
||||
const refused = await ro.post(body);
|
||||
assert.equal(refused.status, 403); assert.equal(refused.body.error, 'read-only');
|
||||
// A granted user's session is fenced as owner-only before the handler.
|
||||
const granted = { ...createDevOwner(), session: () => ({ provider: 'local-dev', iss: 'local-dev', sub: 'alice-sub', user: 'alice', name: 'Alice' }) };
|
||||
const user = await serve(t, { organizations: r.organizations, sso: granted });
|
||||
const other = await user.post(body);
|
||||
assert.equal(other.status, 403); assert.equal(other.body.error, 'owner-only');
|
||||
assert.deepEqual(r.resolved, []); assert.deepEqual(r.sent, []);
|
||||
});
|
||||
|
||||
test('route: each write reaches only the org it names, through that org\'s real bus, with the person\'s verbs', async t => {
|
||||
const r = await registry(t);
|
||||
const { post, trail } = await serve(t, { organizations: r.organizations });
|
||||
|
||||
const created = await post({ op: 'create', org: 'forge', title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', assignees: ['coder'], priority: 2 });
|
||||
assert.equal(created.status, 200, JSON.stringify(created.body));
|
||||
assert.equal(created.body.ok, true); assert.equal(created.body.org, 'forge'); assert.equal(created.body.op, 'create');
|
||||
assert.equal(created.body.task_ref, 'task:1'); assert.match(created.body.digest, DIGEST);
|
||||
assert.deepEqual(r.resolved, [['forge', 'local-dev-owner']]);
|
||||
assert.deepEqual(r.sent.map(([org, verb]) => [org, verb]), [['forge', 'tasks.create']]);
|
||||
const [task] = r.forge.read();
|
||||
assert.equal(task.task_ref, 'task:1'); assert.equal(task.source, 'store'); assert.equal(task.fields.title, 'Rate limits');
|
||||
assert.equal(task.digest, created.body.digest);
|
||||
// No other org's bus holds anything.
|
||||
assert.deepEqual(r.robot.read(), []);
|
||||
|
||||
// An edit with the digest the board read is written; a stale one is refused and changes nothing.
|
||||
const moved = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: created.body.digest, state: 'in-review' });
|
||||
assert.equal(moved.status, 200); assert.notEqual(moved.body.digest, created.body.digest);
|
||||
assert.equal(r.forge.read()[0].fields.state, 'in-review');
|
||||
const stale = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: created.body.digest, title: 'Lost update' });
|
||||
assert.equal(stale.status, 409); assert.equal(stale.body.error, 'task-conflict'); assert.equal(stale.body.sent, false);
|
||||
assert.match(stale.body.message, /Nothing was written/);
|
||||
assert.equal(r.forge.read()[0].fields.title, 'Rate limits');
|
||||
const missing = await post({ op: 'edit', org: 'forge', task_ref: 'task:9', expect: D(1), title: 'x' });
|
||||
assert.equal(missing.status, 404); assert.equal(missing.body.error, 'task-not-found');
|
||||
const role = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: moved.body.digest, assignees: ['nobody'] });
|
||||
assert.equal(role.status, 400); assert.equal(role.body.error, 'unknown-role');
|
||||
const archived = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: moved.body.digest });
|
||||
assert.equal(archived.status, 200);
|
||||
assert.deepEqual(r.forge.read(), [], 'an archived task leaves the read');
|
||||
const again = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: archived.body.digest });
|
||||
assert.equal(again.status, 409); assert.equal(again.body.error, 'task-already-archived');
|
||||
|
||||
// A Vikunja organization's bus refuses the store verbs; the board never writes to Vikunja.
|
||||
const vik = await post({ op: 'create', org: 'ddigital', title: 'Launch post', requirement: 'REQ-TASK-1' });
|
||||
assert.equal(vik.status, 409); assert.equal(vik.body.error, 'tracker-not-store'); assert.equal(vik.body.sent, false);
|
||||
assert.match(vik.body.message, /keeps its tasks in Vikunja/);
|
||||
assert.deepEqual(r.ddigital.read(), []);
|
||||
// A Console started by an agent: the bus finds no person, so nothing is written.
|
||||
const robot = await post({ op: 'create', org: 'robot', title: 'Sneaky', requirement: 'REQ-TASK-1' });
|
||||
assert.equal(robot.status, 403); assert.equal(robot.body.error, 'human-required');
|
||||
assert.match(robot.body.message, /only from a person/);
|
||||
assert.deepEqual(r.robot.read(), []);
|
||||
|
||||
// An org outside the session, an archived org, an unreadable registry, a down bus, a Console
|
||||
// without the write: each is refused before or without a bus call.
|
||||
const before = r.sent.length;
|
||||
const outside = await post({ op: 'edit', org: 'acme', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(outside.status, 404); assert.deepEqual(outside.body, { error: 'org-unavailable', sent: false });
|
||||
const old = await post({ op: 'edit', org: 'old', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(old.status, 409); assert.equal(old.body.error, 'org-archived');
|
||||
const broken = await post({ op: 'edit', org: 'broken', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(broken.status, 404); assert.deepEqual(broken.body, { error: 'org-unavailable', sent: false });
|
||||
const down = await post({ op: 'edit', org: 'home', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(down.status, 503); assert.equal(down.body.error, 'no-bus-host'); assert.equal(down.body.sent, false);
|
||||
const quiet = await post({ op: 'edit', org: 'quiet', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(quiet.status, 403); assert.equal(quiet.body.error, 'read-only');
|
||||
assert.equal(r.sent.length, before);
|
||||
|
||||
// A lost answer is not a refusal: the change may have been written, and the person is told so.
|
||||
const lost = await post({ op: 'edit', org: 'lost', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(lost.status, 503); assert.equal(lost.body.error, 'outcome-unknown'); assert.equal(lost.body.sent, 'unknown');
|
||||
const odd = await post({ op: 'edit', org: 'odd', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(odd.status, 502); assert.equal(odd.body.error, 'brand-new-code'); assert.equal(odd.body.sent, false);
|
||||
|
||||
// Every resolve named exactly the org in the body, and each bus call went to that org.
|
||||
for (const [org, verb] of r.sent) assert.ok(['tasks.create', 'tasks.edit', 'tasks.archive'].includes(verb), `${org} ${verb}`);
|
||||
assert.deepEqual([...new Set(r.resolved.map(([id]) => id))], ['forge', 'ddigital', 'robot', 'acme', 'old', 'broken', 'home', 'quiet', 'lost', 'odd']);
|
||||
// The action trail notes each outcome with who, which org and which verb.
|
||||
const notes = trail().map(e => [e.org, e.action, e.task ?? null, e.outcome]);
|
||||
assert.deepEqual(notes, [
|
||||
['forge', 'tasks.create', null, 'written'],
|
||||
['forge', 'tasks.edit', 'task:1', 'written'],
|
||||
['forge', 'tasks.edit', 'task:1', 'refused task-conflict'],
|
||||
['forge', 'tasks.edit', 'task:9', 'refused task-not-found'],
|
||||
['forge', 'tasks.edit', 'task:1', 'refused unknown-role'],
|
||||
['forge', 'tasks.archive', 'task:1', 'written'],
|
||||
['forge', 'tasks.archive', 'task:1', 'refused task-already-archived'],
|
||||
['ddigital', 'tasks.create', null, 'refused tracker-not-store'],
|
||||
['robot', 'tasks.create', null, 'refused human-required'],
|
||||
['lost', 'tasks.edit', 'task:1', 'unknown'],
|
||||
['odd', 'tasks.edit', 'task:1', 'refused brand-new-code'],
|
||||
]);
|
||||
assert.ok(trail().every(e => e.by?.sub === 'local-dev-owner' && e.by?.provider === 'local-dev'));
|
||||
});
|
||||
|
||||
// One org's reads as the workspace read sees them (options().bus and options().chat).
|
||||
function member(id, data, doc = {}) {
|
||||
const value = name => Promise.resolve(data[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
|
||||
return {
|
||||
id, doc, status: async () => ({ healthy: true, pid: 1, boot: null }),
|
||||
options: () => ({
|
||||
bus: { inbox: () => value('inbox'), agents: () => value('agents'), 'thread.claims': () => value('claims'), tasks: () => value('tasks'), usage: () => value('usage'), limits: () => value('limits'), decisions: () => value('decisions') },
|
||||
chat: { threads: () => value('threads'), projects: () => value('projects') },
|
||||
}),
|
||||
};
|
||||
}
|
||||
const SESSION = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
|
||||
const readTasks = (members, search = 'sections=tasks') => readWorkspace({ organizations: { members: async () => members }, session: SESSION, query: parseWorkspaceQuery(search), now: () => Date.parse('2026-11-10T12:00:00.000Z') });
|
||||
|
||||
test('read: a built-in store org\'s rows, from its real bus, carry state, project, roles and the digest a write sends back', async t => {
|
||||
const forge = await bus(t, 'forge');
|
||||
const p = (verb, args) => forge.broker.request(forge.human, { verb, args });
|
||||
p('tasks.create', { title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' });
|
||||
p('tasks.create', { title: 'Invoice PDF', requirement: 'REQ-TASK-1', group: 'billing', state: 'blocked' });
|
||||
p('tasks.create', { title: 'Loose end', requirement: 'REQ-TASK-1' });
|
||||
const gone = p('tasks.create', { title: 'Old idea', requirement: 'REQ-TASK-1' });
|
||||
p('tasks.archive', { task_ref: gone.task_ref });
|
||||
const projects = [{ id: 'p-api', repo: 'api-gateway', name: 'API gateway', archived: false, at: '2026-11-01T00:00:00.000Z' }, { id: 'p-bill', repo: 'billing', name: 'Billing', archived: false, at: '2026-11-01T00:00:00.000Z' }];
|
||||
const body = await readTasks([member('forge', { tasks: forge.read(), projects }, { roles: { coder: {}, reviewer: {} } })]);
|
||||
assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker]), [['forge', 'ok', 'built-in']]);
|
||||
const rows = body.tasks.map(x => [x.ref, x.tracker, x.title, x.state, x.project?.key ?? null, x.group, x.assignees, x.priority, x.dueDate, x.writable, DIGEST.test(x.digest)]);
|
||||
assert.deepEqual(rows, [
|
||||
// A group matches a project by id or by key; a group naming neither has no project.
|
||||
['task:1', 'built-in', 'Rate limits', 'todo', 'api-gateway', 'p-api', ['coder'], 3, '2026-11-20T00:00:00.000Z', true, true],
|
||||
['task:2', 'built-in', 'Invoice PDF', 'blocked', 'billing', 'billing', [], 0, null, true, true],
|
||||
['task:3', 'built-in', 'Loose end', 'todo', null, null, [], 0, null, true, true],
|
||||
]);
|
||||
// The digest is the bus's own, so an edit's `expect` matches the task the board shows.
|
||||
assert.equal(body.tasks[0].digest, forge.read().find(x => x.task_ref === 'task:1').digest);
|
||||
p('tasks.edit', { task_ref: 'task:1', expect: body.tasks[0].digest, state: 'done' });
|
||||
const after = await readTasks([member('forge', { tasks: forge.read(), projects }, {})], 'sections=tasks&task-state=done');
|
||||
assert.deepEqual(after.tasks.map(x => [x.ref, x.state, x.done]), [['task:1', 'done', true]]);
|
||||
});
|
||||
|
||||
test('read: filters, the per-org cap, and rows the read refuses', async () => {
|
||||
const store = (n, f = {}, extra = {}) => ({ task_ref: `task:${n}`, source: 'store', digest: D(n % 10), at: '2026-11-09T00:00:00.000Z', fields: { title: `T${n}`, state: 'todo', group: null, assignees: [], labels: [], ...f }, ...extra });
|
||||
const tasks = [store(1, { state: 'in-review', assignees: ['coder'], labels: [4] }), store(2, { assignees: ['reviewer'] }), store(3, { state: 'nonsense' }), store(4, {}, { digest: 'not-a-digest' }), store(5, {}, { archived: true })];
|
||||
const one = async search => (await readTasks([member('forge', { tasks })], `sections=tasks${search}`)).tasks.map(x => x.ref);
|
||||
assert.deepEqual(await one(''), ['task:1', 'task:2', 'task:3', 'task:4']);
|
||||
assert.deepEqual(await one('&task-state=in-review'), ['task:1']);
|
||||
// An unknown state reads as null, which only `open` matches.
|
||||
assert.deepEqual(await one('&task-state=open'), ['task:3']);
|
||||
assert.deepEqual(await one('&assignee=reviewer,coder'), ['task:1', 'task:2']);
|
||||
assert.deepEqual(await one('&label=4'), ['task:1']);
|
||||
const all = (await readTasks([member('forge', { tasks })])).tasks;
|
||||
assert.equal(all.find(x => x.ref === 'task:4').digest, null, 'a malformed digest is never sent back');
|
||||
for (const search of ['task-state=doing', 'assignee=', 'assignee=a b', 'label=0', 'label=x', 'task-state=todo&task-state=done'])
|
||||
assert.throws(() => parseWorkspaceQuery(search), WorkspaceQueryError, search);
|
||||
|
||||
// Past the cap the org keeps its TASKS_PER_ORG most recently changed rows and says how many
|
||||
// matched; another org is untouched. Task n changed n minutes after midnight.
|
||||
const minute = n => new Date(Date.parse('2026-11-09T00:00:00.000Z') + n * 60000).toISOString();
|
||||
const many = Array.from({ length: TASKS_PER_ORG + 7 }, (_, i) => store(i + 1, i + 1 > TASKS_PER_ORG ? { group: 'late', state: 'blocked' } : { group: 'p1' }, { at: minute(i + 1), project_key: 'k' }));
|
||||
const projects = [{ id: 'p1', repo: 'one', name: 'One', archived: false, at: minute(0) }, { id: 'late', repo: 'late', name: 'Late', archived: false, at: minute(0) }];
|
||||
const big = [member('big', { tasks: many, projects }), member('small', { tasks: [store(1)] })];
|
||||
const capped = await readTasks(big, 'sections=tasks,projects,metrics');
|
||||
const bigRows = capped.tasks.filter(x => x.org === 'big');
|
||||
assert.equal(bigRows.length, TASKS_PER_ORG);
|
||||
assert.equal(capped.orgs.find(o => o.org === 'big').tasksHeld, TASKS_PER_ORG + 7);
|
||||
assert.equal(capped.orgs.find(o => o.org === 'small').tasksHeld, undefined);
|
||||
assert.equal(bigRows.some(x => x.ref === 'task:7'), false, 'the oldest changes are the ones cut');
|
||||
assert.equal(bigRows.some(x => x.ref === 'task:8'), true);
|
||||
// Store refs sort by number, so task:10 follows task:9.
|
||||
assert.deepEqual(bigRows.slice(1, 4).map(x => x.ref), ['task:9', 'task:10', 'task:11']);
|
||||
// Projects and metrics count every task the org holds, not only the ones the answer carries.
|
||||
const proj = Object.fromEntries(capped.projects.filter(x => x.org === 'big').map(x => [x.id, x]));
|
||||
assert.equal(proj.p1.openTasks, TASKS_PER_ORG); assert.equal(proj.late.openTasks, 7);
|
||||
assert.equal(capped.metrics.orgs.find(m => m.org === 'big').openTasks, TASKS_PER_ORG + 7);
|
||||
// A filter runs before the cap, so it reaches tasks past row TASKS_PER_ORG.
|
||||
for (const search of ['sections=tasks&project=big:late', 'sections=tasks&task-state=blocked']) {
|
||||
const filtered = await readTasks(big, search);
|
||||
assert.deepEqual(filtered.tasks.map(x => x.ref), Array.from({ length: 7 }, (_, i) => `task:${TASKS_PER_ORG + i + 1}`), search);
|
||||
assert.equal(filtered.orgs.find(o => o.org === 'big').tasksHeld, undefined, search);
|
||||
}
|
||||
});
|
||||
|
||||
test('read: a Vikunja org\'s tasks come through the real tasks adapter from a fake Vikunja on port 0', async t => {
|
||||
const fake = new FakeVikunja();
|
||||
const server = await fake.listen();
|
||||
t.after(server.close);
|
||||
assert.match(server.url, /^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
const w = await world(t, { fake, fetch: globalThis.fetch, tracker: { baseUrl: server.url } });
|
||||
await w.adapter.start('demo');
|
||||
assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused);
|
||||
const pm = w.agent('pm');
|
||||
const a = await w.call(pm, 'task.create', { title: 'Launch post', request: w.instruction(), requirement: 'REQ-TASK-1' });
|
||||
const b = await w.call(pm, 'task.create', { title: 'Press kit', request: w.instruction(), requirement: 'REQ-TASK-1' });
|
||||
const id = ref => Number(ref.slice(ref.indexOf('/') + 1));
|
||||
// The owner works in the Vikunja UI; the adapter's poll picks it up.
|
||||
assert.equal((await w.ui('POST', `/tasks/${id(a.task_ref)}/assignees`, { user_id: w.bots.coder })).status, 201);
|
||||
assert.equal((await w.ui('POST', `/tasks/${id(a.task_ref)}/labels`, { label_id: w.label })).status, 201);
|
||||
assert.equal((await w.ui('PATCH', `/tasks/${id(a.task_ref)}`, { due_date: '2026-11-20T00:00:00Z', percent_done: 0.5, description: 'not on the board' })).status, 200);
|
||||
assert.equal((await w.ui('PATCH', `/tasks/${id(b.task_ref)}`, { done: true })).status, 200);
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
assert.ok(fake.requests.length > 0, 'the adapter read the fake over HTTP');
|
||||
|
||||
const rows = w.broker.request(w.human, { verb: 'tasks', args: {} });
|
||||
const doc = w.businesses.demo;
|
||||
const body = await readTasks([member('demo', { tasks: rows, projects: [] }, doc)]);
|
||||
assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker]), [['demo', 'ok', 'vikunja']]);
|
||||
const [x, y] = body.tasks;
|
||||
// Mapped: ref, title, done (the only state the bus names), bot ids as roles, labels, priority,
|
||||
// due date and the numeric bucket. Left out: description, percent_done, relations, comments.
|
||||
assert.deepEqual({ ref: x.ref, tracker: x.tracker, title: x.title, state: x.state, done: x.done, assignees: x.assignees, labels: x.labels, dueDate: x.dueDate, writable: x.writable, digest: x.digest },
|
||||
{ ref: a.task_ref, tracker: 'vikunja', title: 'Launch post', state: null, done: false, assignees: ['coder'], labels: [w.label], dueDate: '2026-11-20T00:00:00.000Z', writable: false, digest: null });
|
||||
assert.ok(Number.isSafeInteger(x.bucket) && x.bucket > 0, 'the bucket is the board\'s numeric id');
|
||||
assert.equal(x.requirement, 'REQ-TASK-1');
|
||||
assert.deepEqual([y.ref, y.state, y.done], [b.task_ref, 'done', true]);
|
||||
for (const key of ['description', 'percent_done', 'percentDone', 'relations', 'comments']) assert.equal(Object.hasOwn(x, key), false, key);
|
||||
// The board's filters work on a Vikunja org too.
|
||||
const open = await readTasks([member('demo', { tasks: rows, projects: [] }, doc)], 'sections=tasks&task-state=open&assignee=coder');
|
||||
assert.deepEqual(open.tasks.map(t => t.ref), [a.task_ref]);
|
||||
});
|
||||
Reference in new issue
Block a user