Compare commits

...
14 Commits
Author SHA1 Message Date
jason.woltje 200b30b721 fix(webui): P2 system types in a side-by-side pane only show, never switch (#1873)
U5 draws the organization dashboard into a side-by-side pane, and the
System types section came with it: Enable and Disable buttons that wrote
to the selected organization whatever the pane showed, a heading id
repeated in both panes, and a Settings Open button that focused the
first pane's section. A pane now shows its own organization's types
with no switch and says to change them from the full view, as U5 does
for the bus restart. The heading id carries the pane's prefix, Open
focuses the section in its own dashboard, and the click handler and
the post-write repaint ignore panes.
2026-11-10 14:44:02 -06:00
jason.woltje ff74bde424 test(webui): answer the project system-types read in the W15 integration fixture (#1873) 2026-11-10 14:41:53 -06:00
jason.woltje 85e9fcd894 fix(webui): P2 review fixes for system types (#1873)
Recheck the session after the read so a sign-out dispatches no write.
Take the enabled set and its revision from one plugins read. Let the
owner clear an inactive-only selection when nothing is enabled. Keep
role tools as the hard ceiling in the toolset design, name only
vocabulary tools in the mapping, and intersect mission and task over
the final set.
2026-11-10 14:41:53 -06:00
jason.woltje fec416d1df feat(webui): P2 system types on the org dashboard and project page, toolset design (#1873)
The org dashboard lists the installed system-type modules with their own
labels and descriptions, marks which are enabled, and gives the owner an
enable or disable control through plugins.set behind the owner fence, with
the revision and the transaction receipt. The project page selects from the
organization's enabled set through project.system-types.set and shows a
disabled selection as inactive, as system-types.console reports. Both routes
go through the per-org Console call path and its admission checks.

docs/plans/2026-11-10_system-type-toolsets-design.md proposes how a launch
turns a project's active system types into toolsets under the role, mission
and task ceilings. Design only; no roles/ or contract change is applied.
2026-11-10 14:41:53 -06:00
jason.woltje 52a82a4e56 test(webui): U5 Agents panes keep their own project choice (#1873)
The rebase onto U3 brought in A1's "Available in a project" chooser. Its
change handler kept one renderer and wrote into the first table in the
document, keyed by the page's organization. The rebase resolution keys
the renderer and the choice by each grid's organization and redraws the
table in the chooser's own section. This test covers it: before that
change, pane b's choice never redrew pane b.
2026-11-10 14:07:05 -06:00
jason.woltje 5f3706e807 fix(webui): U5 pane view state survives a project repaint (#1873)
A pane keeps its request choice and PRD disclosure on the pane, per
organization, instead of on the project content element. A new
conversation changes the project header, which replaces that element;
the choice now survives it.
2026-11-10 14:07:05 -06:00
jason.woltje 5f57008aed fix(webui): U5 review fixes for side by side panes (#1873)
One observer per pane element, disconnected on leaving the page, so a
closed and reopened pane rewrites links for its current organization.
Project reads check the pane generation and organization before any
DOM write. Request selection and expanded PRDs are kept per pane.
A late navigation focuses its heading only when focus has not moved to
the other pane. Project pages in a pane use a compact layout with
container queries.
2026-11-10 14:07:05 -06:00
jason.woltje bc3e0a9f49 feat(webui): U5 side by side, two panes across organizations and projects (#1873)
The Side by side page now opens two panes. Each pane shows an organization
dashboard, an agents grid or a project page, chosen from a picker that lists
every organization and project. Pane state lives in the URL and is kept per
viewer. A separator resizes the panes by pointer or arrow keys, closing a
pane goes back to one, and each pane keeps its own focus and scroll through
polling. A pane whose organization's bus is down says so while the other
pane keeps working. On a phone a two-way switch shows one pane at a time.

Panes are read-only. Conversation, room, settings, queue, docs and inbox
pages open in full view; chat.js is a single-instance page with global ids.
2026-11-10 14:07:05 -06:00
jason.woltje a71da41146 fix(webui): U4b re-check fixes, conflict recovery and partial empty state (#1873)
A conflict on a task another writer moved out of the board's server
filter now reads that task from its organization unfiltered, so the
dialog reopens on the current task. An empty result names the
organizations whose tasks were not read instead of saying there are
no tasks.
2026-11-10 20:04:49 +00:00
jason.woltje 4c74e7ea49 fix(webui): U4b review fixes for the task board (#1873)
- Edit and archive require the digest the board read (expect); the route
  refuses a write without it.
- The edit dialog saves against the task it showed, so a poll that reads a
  newer task turns a stale save into task-conflict; the dialog reopens on the
  current task.
- An answer the board cannot read, or one marked sent=unknown, is shown as an
  open outcome, not as a refusal.
- The per-org cap applies after the filters and keeps the most recently
  changed tasks; projects, metrics and the inbox count every task. The board
  sends project, state, assignee and label filters to the server.
- A poll keeps the focus, caret and scroll the person has at the moment it
  answers, not when it started.
- An org whose tasks read fails shows "tasks not read" and is not writable.
- browser.test.mjs pins tasks-board.js in the script order.
2026-11-10 20:04:49 +00:00
jason.woltje ac3a863bec feat(webui): U4b one task board across organizations (#1873)
Replaces the Tasks placeholder with sample 06: every organization's
tasks read through its own bus and tagged with the org, Kanban and List
with the layout remembered per viewer, and org, project, state,
assignee and label filters. A down org keeps its U1 state.

Writes (create, move, edit, archive) go to POST /api/workspace/tasks,
behind the hook fence, owner only and --send only. Each write resolves
only the org it names and calls that org's bus with the person's store
verbs, so the bus's human check, tracker check and digest check all
apply. Refusals reach the person with what was and was not written.

A Vikunja organization's tasks are read through the tasks adapter and
shown read only; the store verbs refuse them with tracker-not-store.
2026-11-10 20:04:49 +00:00
jason.woltje af7d330101 docs: clarify host trust boundary 2026-11-10 19:57:49 +00:00
jason.woltje db75994f82 docs: define accounts host trust boundary 2026-11-10 19:57:49 +00:00
jason.woltje ac977d833b test(webui): retry the projects grouping click while the nav repaints (#1873)
The workspace nav repaints after async organization and project reads.
Under load (gate at load 14) a repaint landed between the flat-grouping
wait and the org click, and the click found no button. The click now
retries inside the wait until the page shows org grouping.
2026-11-10 13:46:18 -06:00
30 changed files with 3686 additions and 89 deletions

No files matched your search

+68
View File
@@ -881,6 +881,58 @@ OIDC session or an account. The rule:
never on a deployed one.
- Dev mode keeps every CLI verb as it is.
### Host trust boundary
The host is the trust boundary. The instance uid can read and write the
instance's config, data root, bus files and credentials. That makes the uid
equivalent to root for this instance, though not root on the host. A CLI
token cannot protect the instance from someone who already has that uid:
they can read the token file, the bus database and every key, or change the
files directly. A token could help a remote admin CLI that has no host
access. That is a possible later feature, not part of Phase M.
Phase M narrows who is assigned the instance uid. In the current managed
host setup, both Case 1 and Case 2 use `mosaic`: the host run sheet creates
it as a system account with a non-login shell and no supplementary groups
or sudo rule (`docs/guides/host-run-sheet.md:19-26`). Case 1 acceptance
checks that the Console and bus run as the configured `--user`
(`scripts/case1-accept.sh:102-116,457-469`). Case 2's unit renderer uses
its `--user` for both services (`scripts/case2-unit.sh:136-145,514`); the
host setup runs that path as `mosaic`
(`docs/plans/2026-10-20_case2-rows.md:1197-1200`). These are today's
configured identities, not an installer guarantee: `case2-unit.sh` accepts
any existing non-root user with a matching home. The run sheet, rather than
the renderer, selects the dedicated account.
There is also a separate Console path. `webui-service.sh` installs a
systemd user unit, then asks the operator to start it with
`systemctl --user` (`scripts/webui-service.sh:1-7,19,413-418`). That Console
runs as the account whose user manager starts it, not as the managed
`mosaic` service account. It is a single-user development path, not a safe
accounts-mode deployment path. M3 follow-up 1 is to refuse a deployed or
accounts-mode Console service through this user-unit path.
The stack's own managed agent sessions run as the instance uid inside a
bubblewrap sandbox, not in containers (`scripts/case1-accept.sh:480-496`,
`docs/plans/2026-10-20_case2-rows.md:1031-1034,1197-1199`). The sandbox
limits their filesystem view. A sandbox escape has the same authority as
holding the instance uid. This is different from a development agent seat:
Phase M requires the instance uid to differ from every human login and
development seat uid. A non-login shell prevents direct login, but root or a
human with sudo or equivalent authority can still act as `mosaic`.
M2's accounts loader refuses a file with the wrong uid or group or other
write permission. M2 does not currently specify the config-directory owner
check for `mosaic accounts` or an OS-uid receipt. M3 pins both as integration
expectations. The recorded uid identifies the process, not
necessarily the person who invoked it. The receipt is writable by the
instance uid and is not tamper-evident against that uid. Stamped builds and
`.deployed` also refuse dev flags in M3.
The residual risk is accepted: host root, or the instance uid, owns the
instance. Accounts protect users from one another through the Console and
bus. They do not protect an instance from its host owner.
### Locks
Three locks exist after this design: the per-org lifecycle lock
@@ -1295,6 +1347,7 @@ Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off.
| Identity confusion across providers | Identity is `(provider, sub)` with the issuer from config. Email matches invites only. A subject belongs to one account. |
| Local CLI acting outside the check | In accounts mode `mosaic org` and `decide --org` refuse. `mosaic accounts` runs as the instance uid, which can already read every bus file, and has no rights verbs. |
| Tampering with accounts.json on disk | The file must belong to the instance uid and not be group or other writable. Anyone who can write it already runs as the instance user and can read every bus. That is outside this design, as it is for access.json. |
| Host root or the instance uid bypassing accounts | Accepted residual risk: either can read and change the instance config, data root, bus files and credentials. An administrator who can become the instance uid has the same authority. An OS-uid receipt identifies the process, not the human, and the instance uid can change the receipt. |
Not covered: a compromised identity provider, a stolen session cookie
before its 1 hour idle limit, and a compromised Console process, which holds
@@ -1397,6 +1450,21 @@ Acceptance tests, against a fake OIDC provider on port 0:
- `--dev-local-owner` from a checkout refuses with accounts.json present,
with `console.sso`, and with `.deployed`.
- `webui-service.sh render` and `case2-unit.sh` refuse a dev flag.
- `case2-unit.sh` accepts the host run sheet's `mosaic` account fixture only
when its shell is non-login, its primary group has no other members, and
it owns the config directory and data root. Fixtures for a human login and
a development seat with interactive shells are refused. Both rendered
system units use that same uid.
- `case1-accept.sh` F1 continues to verify that the running Console and bus
processes have the configured instance uid.
- M3 integration tests verify that `mosaic accounts` refuses when its
effective uid does not own the config directory and that its receipt
records the effective OS uid. The test documents that this is process
identity, not human attribution or tamper evidence.
- **M3 follow-up 1:** accounts mode refuses the `webui-service.sh` systemd
user-unit path. Managed accounts instances start through the system units
rendered by `case2-unit.sh`. Tests show the user-unit path cannot start a
deployed accounts instance while development mode remains available.
- The existing sso and dev-owner suites stay green.
Grant needed: `stack-copy.sh` writes a new file into every copy, and the
@@ -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.
+4
View File
@@ -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';
@@ -169,6 +170,9 @@ 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 }),
};
},
+5 -1
View File
@@ -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;
}
+10 -8
View File
@@ -1442,12 +1442,6 @@
// ---------------------------------------------------------------- router
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
const routeOf = () => window.MosaicOrg?.route() ?? (location.hash.startsWith('#/') ? location.hash.slice(1) : '/');
// A one-line scope note right under a view's page heading.
function afterHead(html, note) {
const start = html.indexOf('<div class="page-head">'), end = start < 0 ? -1 : html.indexOf('</div></div>', start);
const line = `<p class="small muted ws-scope" data-not-built>${esc(note)}</p>`;
return end < 0 ? `${line}${html}` : `${html.slice(0, end + 12)}${line}${html.slice(end + 12)}`;
}
function view(get, path, q, opts, current) {
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
if (parts.includes(null)) return notFound();
@@ -1455,9 +1449,8 @@
if (n === 0 && window.MosaicOrg) return window.MosaicWorkspace.home();
if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q);
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
if (a === 'side-by-side' && n === 1 && window.MosaicOrg) return `${head('Side by side', 'Not built yet.')}${empty('Side by side is not built yet.', 'It arrives with a later unified workspace update. Projects and Agents work today.')}`;
if (a === 'inbox' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.inboxPage(q);
if (a === 'tasks' && n === 1 && window.MosaicOrg) return tasksView(get).then(html => afterHead(html, 'Tasks across all organizations are not built yet. These are the selected organization\'s tasks.'));
if (a === '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);
@@ -1548,6 +1541,15 @@
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.
+4 -1
View File
@@ -620,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>';
@@ -684,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');
+2 -1
View File
@@ -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(?:\?|$)|workspace(?:\?|$)|agents\/levels(?:\?|$))/.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);
};
+3 -3
View File
@@ -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="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<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>
+83
View File
@@ -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}
}
+477
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 });
})();
+28 -16
View File
@@ -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;
@@ -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}
+195
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 });
})();
+97
View File
@@ -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}
}
+496
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 });
})();
+36 -24
View File
@@ -1,7 +1,8 @@
// 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.
// 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;
@@ -154,7 +155,7 @@
const secH = (title, note = '', link = '') => `<div class="ws-sec-h"><h2>${title}</h2>${note ? `<span class="note">${note}</span>` : ''}${link}</div>`;
function settingsList(org, ctx) {
const { business, settings, instances, orgCount } = ctx;
const { 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;
@@ -166,7 +167,7 @@
const rows = [
['business', 'Identity', b ? `${label(org)} · ${org.id} · kind not recorded` : unread, 'org file', 'business'],
['flag', 'Appearance', `${titleCase(theme)} theme · ${initials(org)} avatar`, b?.appearance?.theme || org.theme ? 'org file' : 'system', 'appearance'],
['cube', 'System types', 'Not built yet', 'not built', null],
['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'],
@@ -185,20 +186,25 @@
['close', 'Archive or delete', "Archive or delete this organization's file", 'danger', 'organizations'],
];
return `<ul class="ws-cfg one" data-org-settings>${rows.map(([ic, name, value, tag, page]) => {
const href = page === null ? null : page.startsWith('#') ? page : `#/settings/${page}`;
const go = href ? `<a class="go" href="${esc(href)}" data-focus-key="${esc(`set:${name}`)}" aria-label="Open ${esc(name)}">Open</a>` : '<span class="go" aria-hidden="true"></span>';
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>`;
}
async function dashboard() {
const id = window.MosaicOrg.id;
// 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 reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id)]);
const 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));
@@ -210,14 +216,15 @@
// Header: the bus restart is console-restart.js's button where the Console allows it, otherwise
// the same control, present and disabled, with the reason.
const restart = window.MosaicConsole?.busButton?.('Restart bus') || '';
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${icon('restart', 'ws-busicon')}${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="Restarting the bus is the owner's trailed action, on a Console started with send and the action trail">${icon('restart')}Restart bus</button></span>`;
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="ws-reload-why" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
const 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="ws-reload-why">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
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 = '<div class="ws-typechips ws-chips"><span class="ws-label">System types</span><span class="note">No system types yet. They arrive with the system types update.</span></div>';
const 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>`;
@@ -255,7 +262,8 @@
</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 })}
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null, types })}
${types ? types.section(id, { pane, idPrefix: opts.idPrefix || '' }) : ''}
</section>
</div>
</article>`;
@@ -269,7 +277,9 @@
const LEVEL_CLS = { root: 'root', org: 'org', project: 'proj' };
const projectName = (rows, id) => { const p = Array.isArray(rows) ? rows.find(r => r?.id === id) : null; return p?.name || id; };
let agentsPick = null; // the project chosen in "Available in a project", kept across repaints
let agentsAvailable = null; // repaints that section alone when the choice changes
// 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;
@@ -322,9 +332,8 @@
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 = '') {
async function agentGrid(note = '', selected = window.MosaicOrg?.id) {
const [list, levels] = await Promise.all([data.organizations().catch(() => null), data.agentLevels().catch(() => null), ensureThemes()]);
const selected = window.MosaicOrg?.id;
const head = `<div class="ws-vh"><h1 tabindex="-1">Agents</h1><div class="ws-right"><a class="ws-btn" href="#/settings/agents" data-focus-key="agents-edit">${icon('settings')}Edit agents</a></div><p class="sub">Agents are defined at three levels: root, organization and project. Root and project agents are read from their files and are not launched yet.${note ? ` ${note}` : ''}</p></div>`;
if (!Array.isArray(list)) return `<section class="ws ws-agents" data-org-agents>${head}<p class="ws-panel tight note">Organizations unavailable.</p></section>`;
const valid = list.filter(org => !org.invalid);
@@ -372,8 +381,8 @@
const projectIds = lv?.state === 'ok' ? Object.keys(lv.projects) : [];
const pick = projectIds.includes(agentsPick?.[selected]) ? agentsPick[selected] : projectIds[0] ?? null;
const ctx = { selOrg, lv, mine, levels, modelOf, names: id => projectName(mine?.rows, id), others: valid.filter(o => o.id !== selected) };
agentsAvailable = projectId => levelsTable(ctx, projectId);
const chooser = projectIds.length ? `<label class="ws-psel">${av(selOrg, 'xs')}<span class="ws-sr">Project</span><select data-agents-project data-focus-key="agents-project">${projectIds.map(id => `<option value="${esc(id)}"${id === pick ? ' selected' : ''}>${esc(ctx.names(id))}</option>`).join('')}</select></label>` : '';
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>`;
@@ -383,10 +392,11 @@
}
document.addEventListener('change', e => {
const select = e.target.closest?.('[data-agents-project]');
const box = select && document.querySelector('[data-agents-available]');
if (!box || !agentsAvailable || !window.MosaicOrg) return;
agentsPick = { ...agentsPick, [window.MosaicOrg.id]: select.value };
box.innerHTML = agentsAvailable(select.value);
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) ----------
@@ -735,6 +745,8 @@
clearTimeout(typing); typing = setTimeout(() => document.dispatchEvent(new CustomEvent('mosaic-workspace-render')), 250);
});
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, refresh: () => refresh(), home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
// 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();
})();
+28 -11
View File
@@ -18,10 +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';
@@ -59,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', 'workspace-data.js', 'workspace.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']],
@@ -75,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']]),
]);
@@ -506,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.
@@ -772,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, agentLevels = null } = {}) {
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.
@@ -795,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);
@@ -811,11 +820,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
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 }))) {
@@ -829,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
@@ -899,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;
@@ -911,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();
@@ -925,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) ?? [];
}
}
@@ -956,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.
@@ -1013,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 });
+140
View File
@@ -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);
}
+53 -8
View File
@@ -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;
+133
View File
@@ -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 });
}
+2 -2
View File
@@ -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. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
// 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 };
}
@@ -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.');
});
@@ -6,11 +6,13 @@ 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.
function workspaceOrgs({ failThreads = false, seen = null } = {}) {
// 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 },
@@ -24,6 +26,8 @@ function workspaceOrgs({ failThreads = false, seen = null } = {}) {
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: [] }])) });
@@ -35,8 +39,10 @@ function workspaceOrgs({ failThreads = false, seen = null } = {}) {
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,
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 () => [] },
@@ -110,13 +116,8 @@ test('sidebar follows sample 01 and Queue opens from the organization dashboard,
assert.equal(queue, '#/queue');
await b.evaluate('document.querySelector("[data-org-queue]").click()');
await wait('location.hash === "#/orgs/ddigital/queue" && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Queue")');
// Views not built yet open and say so.
// Inbox is built (U3, workspace-pages-browser.test.mjs).
for (const [route, title] of [['tasks', 'Tasks'], ['side-by-side', 'Side by side']]) {
await b.evaluate(`location.hash = "#/orgs/ddigital/${route}"`);
await wait(`document.getElementById("bus-view").innerText.includes("not built yet") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === ${JSON.stringify(title)})`);
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /error|failed/i, route);
}
// 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 => {
@@ -130,7 +131,8 @@ test('organization dashboard renders every section from the per-organization rea
assert.equal(head.avatar, 'DD'); assert.equal(head.title, 'Distinctly Digital');
assert.match(head.meta, /Kind not recorded/); assert.match(head.meta, /id ddigital/); assert.match(head.meta, /Tracker: Vikunja/); assert.match(head.meta, /bus running, read \d/);
assert.match(head.colour, /^#[0-9a-f]{6}$/i);
assert.match(await b.evaluate('document.querySelector(".ws-typechips").innerText'), /System types\s+No system types yet/i);
// 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.
@@ -155,7 +157,8 @@ test('organization dashboard renders every section from the per-organization rea
for (const s of settings) { assert.ok(s.icon, s.name); assert.ok(s.value, s.name); assert.ok(TAGS.includes(s.tag), `${s.name}: ${s.tag}`); }
const by = Object.fromEntries(settings.map(s => [s.name, s]));
assert.deepEqual([by['Members and invites'].tag, by['Members and invites'].href], ['not built', null]);
assert.deepEqual([by['System types'].tag, by['System types'].href], ['not built', null]);
assert.deepEqual([by['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');
@@ -175,7 +178,8 @@ test('organization dashboard empty states say so in one short line', async t =>
await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")');
const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
// Section labels are uppercased by CSS, so innerText carries the uppercase form.
for (const line of [/System types\s+No system types yet\. They arrive with the system types update\./i, /Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
// 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);
});
@@ -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 });
@@ -172,7 +172,8 @@ test('Projects sorts and filters by each key, remembers the grouping, and rows o
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.
await b.evaluate('document.querySelector("#workspace-nav [data-group=org]").click()');
// 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);
@@ -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]);
});