Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
200b30b721 | ||
|
|
ff74bde424 | ||
|
|
85e9fcd894 | ||
|
|
fec416d1df | ||
|
|
52a82a4e56 | ||
|
|
5f3706e807 | ||
|
|
5f57008aed | ||
|
|
bc3e0a9f49 |
No files matched your search
@@ -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.
|
||||
@@ -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 }),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1449,7 +1449,6 @@
|
||||
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 window.MosaicTasksBoard.view();
|
||||
if (a === 'inbox' && n === 1) return inboxView(get);
|
||||
@@ -1542,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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<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="/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><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>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/* Side by side (U5, sample 07 "EXT 8"): a bar, then two panes with a divider between them. Each pane
|
||||
has its own header with the organization's mark and edge, and its own scroll. One pane and a
|
||||
shown-pane switch on a phone. Loaded after shell.css; every rule is scoped to the side-by-side page. */
|
||||
html[data-organizations] .content:has(> #bus-view > .sbs){padding:0}
|
||||
html[data-organizations] .sbs{margin:0;display:flex;flex-direction:column;min-height:0;height:calc(100vh - var(--bar-h,53px) - var(--foot-h,33px))}
|
||||
html[data-organizations] .sbs .panebar{display:flex;align-items:center;gap:10px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--canvas);flex-wrap:wrap}
|
||||
html[data-organizations] .sbs .panebar h1{font-size:16px;font-weight:700;margin:0;line-height:1.4}
|
||||
html[data-organizations] .sbs .panebar h1:focus{outline:none}
|
||||
html[data-organizations] .sbs .panebar .note{font-size:13px;color:var(--muted)}
|
||||
html[data-organizations] .sbs .panebar [data-pane-add]{margin-left:auto}
|
||||
html[data-organizations] .sbs .ws-seg.pane-tabs{display:none;margin-left:0}
|
||||
html[data-organizations] .sbs .pane-tabs>button{gap:5px;max-width:44vw}
|
||||
html[data-organizations] .sbs .pane-tabs>button>span:last-child{overflow:hidden;text-overflow:ellipsis}
|
||||
|
||||
html[data-organizations] .panes{--sbs-a:1fr;--sbs-b:1fr;display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-height:0}
|
||||
html[data-organizations] .panes.two{grid-template-columns:minmax(0,var(--sbs-a)) 1px minmax(0,var(--sbs-b))}
|
||||
html[data-organizations] .pane{display:flex;flex-direction:column;min-width:0;min-height:0}
|
||||
html[data-organizations] .pane[hidden]{display:none}
|
||||
html[data-organizations] .pane-h{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--surface);box-shadow:inset 0 3px 0 var(--org,var(--line));min-width:0}
|
||||
html[data-organizations] .pane-h .ttl{font-weight:700;color:var(--strong,var(--text));overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto}
|
||||
html[data-organizations] .pane-h .ttl small{font-weight:500;color:var(--muted);margin-left:6px;font-size:inherit}
|
||||
html[data-organizations] .pane-h [data-pane-state]:empty{display:none}
|
||||
html[data-organizations] .pane-pick{flex:0 1 180px;min-width:0;max-width:220px;margin-left:auto;font:500 12px/1.4 var(--font);color:var(--text);background:var(--canvas);border:1px solid var(--line);border-radius:var(--r);padding:3px 6px;min-height:0}
|
||||
html[data-organizations] .pane-h .tools{display:flex;gap:2px;flex:none}
|
||||
html[data-organizations] .pane-tool{display:inline-grid;place-items:center;width:26px;height:26px;border-radius:var(--r);color:var(--muted);border:0;background:none;padding:0;min-height:0;cursor:pointer}
|
||||
html[data-organizations] .pane-tool:hover{background:var(--raised);color:var(--text)}
|
||||
html[data-organizations] .pane-tool[hidden]{display:none}
|
||||
html[data-organizations] .pane-tool svg.i{width:15px;height:15px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||
html[data-organizations] .pane-line{margin:0;padding:6px 14px;border-bottom:1px solid var(--line);font-size:13px;color:var(--text);background:color-mix(in srgb,var(--warn,#b7791f) 12%,var(--surface))}
|
||||
html[data-organizations] .pane-line[hidden]{display:none}
|
||||
html[data-organizations] .pane-body{overflow:auto;padding:14px;min-height:0;flex:1}
|
||||
html[data-organizations] .pane-body:focus{outline:none}
|
||||
html[data-organizations] .pane-body:focus-visible{outline:2px solid var(--focus,var(--action));outline-offset:-2px}
|
||||
/* A page inside a pane is a section of the side-by-side page: smaller headings, no page chrome. */
|
||||
html[data-organizations] .pane-body .ws h1{font-size:20px}
|
||||
html[data-organizations] .pane-body .chat-area.pane-project{display:block;height:auto;min-height:0;overflow:visible;padding:0}
|
||||
/* A project in a pane is compact (sample 07): the pane header already names the project and its
|
||||
organization, so the page heading stays only for assistive technology and focus, the full-page
|
||||
actions give way (North star and PRD are on the Requirements tab, Settings in the full view), and
|
||||
the tabs, measures and current work come first. Project pages size some parts from the window
|
||||
(projects.css); in a pane they size from the pane. */
|
||||
html[data-organizations] .pane-body{container-type:inline-size}
|
||||
html[data-organizations] .pane-body .project-header{display:block;width:auto;max-width:100%;margin:0 0 6px;padding:0}
|
||||
html[data-organizations] .pane-body .project-header .project-heading,html[data-organizations] .pane-body .project-header .project-heading>div{display:block}
|
||||
html[data-organizations] .pane-body .project-header-icon,html[data-organizations] .pane-body .project-actions{display:none}
|
||||
html[data-organizations] .pane-body .project-header h1{position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
||||
html[data-organizations] .pane-body:has(.project-header h1:focus-visible){outline:2px solid var(--focus,var(--action));outline-offset:-2px}
|
||||
html[data-organizations] .pane-body .project-header .project-heading p{margin:0;font-size:13px;color:var(--muted)}
|
||||
html[data-organizations] .pane-body .project-tabs a{min-height:40px;padding:0 12px;font-size:14px}
|
||||
html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:10px;padding:6px 0}
|
||||
html[data-organizations] .pane-body .project-metrics a{padding:4px 12px;border-right:0;border-bottom:0;line-height:1.3}
|
||||
html[data-organizations] .pane-body .project-metrics a span,html[data-organizations] .pane-body .project-metrics small{font-size:12px;overflow-wrap:anywhere}
|
||||
html[data-organizations] .pane-body .project-metrics strong{font-size:20px}
|
||||
html[data-organizations] .pane-body .project-metrics p{margin:6px 12px 0;padding-top:6px}
|
||||
html[data-organizations] .pane-body .project-columns{grid-template-columns:minmax(0,1fr);gap:14px;margin-top:12px}
|
||||
html[data-organizations] .pane-body .project-columns h2{font-size:16px;margin:12px 0 8px}
|
||||
html[data-organizations] .pane-body .project-columns h3{font-size:14px}
|
||||
html[data-organizations] .pane-body .project-columns .project-section-title h2,html[data-organizations] .pane-body .project-columns aside>h2:first-child{margin-top:0}
|
||||
@container (min-width:560px){
|
||||
html[data-organizations] .pane-body .project-metrics{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
html[data-organizations] .pane-body .project-metrics a:not(:nth-child(4)){border-right:1px solid var(--line)}
|
||||
}
|
||||
/* The side-by-side page has its own bar; the page breadcrumb would name only one pane. */
|
||||
body:has(#bus-view > .sbs) #breadcrumbs{display:none}
|
||||
html[data-organizations] .content:has(> #bus-view > .sbs) > .org-cue{margin:8px 14px}
|
||||
|
||||
html[data-organizations] .pane-div{position:relative;background:var(--line);cursor:col-resize;touch-action:none;outline:none}
|
||||
html[data-organizations] .pane-div::before{content:"";position:absolute;inset:0 -4px;z-index:1}
|
||||
html[data-organizations] .pane-div:hover,html[data-organizations] .pane-div.dragging{background:var(--action)}
|
||||
html[data-organizations] .pane-div:focus-visible{background:var(--action);box-shadow:0 0 0 2px var(--action)}
|
||||
html[data-organizations] .pane-div[hidden]{display:none}
|
||||
|
||||
@media (max-width:760px){
|
||||
html[data-organizations] .content:has(> #bus-view > .sbs){padding:0}
|
||||
html[data-organizations] .sbs{height:auto}
|
||||
html[data-organizations] .sbs .ws-seg.pane-tabs{display:inline-flex}
|
||||
html[data-organizations] .sbs .hide-m{display:none}
|
||||
html[data-organizations] .panes,html[data-organizations] .panes.two{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .pane.alt,html[data-organizations] .pane-div{display:none}
|
||||
html[data-organizations] .pane-body{overflow:visible}
|
||||
html[data-organizations] .pane-h{flex-wrap:wrap}
|
||||
html[data-organizations] .pane-pick{flex:1 1 100%;max-width:none;order:5;margin-left:0}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
// Side by side (U5, sample 07): two panes, each showing one page of any organization. A pane holds
|
||||
// the organization dashboard, the Agents levels or a project page (overview, conversations, tasks,
|
||||
// requirements, files), drawn by the same modules as the full view (workspace.js, projects.js) into
|
||||
// the pane instead of #bus-view. Every read names the pane's organization, so the panes never share
|
||||
// state. Pages that keep one global form, dialog or editor (a conversation, the room, settings, the
|
||||
// queue, docs, the inbox) are not offered in a pane; their links open the full view.
|
||||
//
|
||||
// The panes' routes, the divider and the phone's shown pane live in the URL
|
||||
// (#/orgs/<page>/side-by-side?a=<org>/<route>&b=<org>/<route>&w=<percent>&show=a|b), updated with
|
||||
// replaceState so nothing reloads, and the last layout is remembered in this browser.
|
||||
(() => {
|
||||
'use strict';
|
||||
if (!document.documentElement.hasAttribute('data-organizations')) return;
|
||||
const data = window.MosaicWorkspaceData, ws = () => window.MosaicWorkspace;
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const enc = encodeURIComponent;
|
||||
const STORE = 'mosaic-side-by-side-v1', POLL = 10000, LIST_TTL = 3000, PROJECTS_TTL = 30000;
|
||||
const MIN = 25, MAX = 75, STEP = 5;
|
||||
const ORG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const PROJECT = /^\/projects\/([A-Za-z0-9][A-Za-z0-9._-]{0,159})(?:\/(conversations|tasks|requirements|files))?$/;
|
||||
const SECTIONS = { overview: 'Overview', conversations: 'Conversations', tasks: 'Tasks', requirements: 'Requirements', files: 'Files' };
|
||||
const FOCUSABLE = 'a[href],button:not([disabled]),select,textarea,input,summary,[tabindex]:not([tabindex="-1"])';
|
||||
const NAMES = { a: 'left', b: 'right' };
|
||||
const load = () => { try { return localStorage.getItem(STORE); } catch { return null; } };
|
||||
const save = value => { try { localStorage.setItem(STORE, value); } catch {} };
|
||||
const paused = () => document.getElementById('pause')?.getAttribute('aria-pressed') === 'true';
|
||||
const svg = d => `<svg class="i sm" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>`;
|
||||
const FULL = svg('<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/>');
|
||||
const CLOSE = svg('<path d="M6 6l12 12M18 6 6 18"/>');
|
||||
const PLUS = svg('<path d="M12 5v14M5 12h14"/>');
|
||||
|
||||
// A pane's page: the routes a pane can draw. Anything else is not pane-safe.
|
||||
function page(route) {
|
||||
if (route === '/organization') return { kind: 'dashboard' };
|
||||
if (route === '/agents') return { kind: 'agents' };
|
||||
const m = PROJECT.exec(route);
|
||||
if (m && m[1] !== 'threads' && m[1] !== 'rooms') return { kind: 'project', id: m[1], section: m[2] || 'overview' };
|
||||
return null;
|
||||
}
|
||||
// "<org>/<route>" from the URL; null when either part is not one a pane draws.
|
||||
function parsePane(value) {
|
||||
if (typeof value !== 'string') return null;
|
||||
const cut = value.indexOf('/');
|
||||
if (cut < 1) return null;
|
||||
const org = value.slice(0, cut), route = value.slice(cut);
|
||||
return ORG.test(org) && page(route) ? { org, route } : null;
|
||||
}
|
||||
const paneValue = p => `${p.org}${p.route}`;
|
||||
const clampW = n => Math.min(MAX, Math.max(MIN, Math.round(n)));
|
||||
|
||||
// ---------- shared reads (one organization list and project lists for both panes) ----------
|
||||
let listAt = 0, listPromise = null, projectReads = new Map();
|
||||
function orgList(fresh = false) {
|
||||
if (!listPromise || fresh || Date.now() - listAt > LIST_TTL) {
|
||||
listAt = Date.now();
|
||||
listPromise = Promise.all([data.organizations(), ws()?.themes?.()]).then(([rows]) => (Array.isArray(rows) ? rows : null)).catch(() => null);
|
||||
}
|
||||
return listPromise;
|
||||
}
|
||||
function projectsOf(org, fresh = false) {
|
||||
const hit = projectReads.get(org);
|
||||
if (hit && !fresh && Date.now() - hit.at < PROJECTS_TTL) return hit.promise;
|
||||
const promise = data.projects(org).then(b => (Array.isArray(b?.rows) ? b.rows.filter(p => p && typeof p.id === 'string') : null)).catch(() => null);
|
||||
projectReads.set(org, { at: Date.now(), promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---------- state ----------
|
||||
let mounted = null; // { root, panes: [pane], w, show, gen }
|
||||
let mountGen = 0;
|
||||
let listCache = null; // the last organization list read, for the shown-pane tabs
|
||||
|
||||
function readQuery(query) {
|
||||
const q = new URLSearchParams(query || '');
|
||||
return { a: parsePane(q.get('a')), b: parsePane(q.get('b')), w: Number(q.get('w')), show: q.get('show'), named: q.has('a') || q.has('b') };
|
||||
}
|
||||
function queryOf(state) {
|
||||
const q = new URLSearchParams();
|
||||
state.panes.forEach((p, i) => q.set(i ? 'b' : 'a', paneValue(p)));
|
||||
q.set('w', String(state.w));
|
||||
q.set('show', state.show);
|
||||
return q.toString().replace(/%2F/g, '/');
|
||||
}
|
||||
function writeUrl() {
|
||||
if (!mounted) return;
|
||||
const qs = queryOf(mounted);
|
||||
save(qs);
|
||||
const next = `#/orgs/${enc(window.MosaicOrg.id)}/side-by-side?${qs}`;
|
||||
if (location.hash !== next) history.replaceState(null, '', next);
|
||||
}
|
||||
// The first pane is this page's organization; the second is the next organization on the list,
|
||||
// or this one's Agents when there is no other.
|
||||
async function defaults() {
|
||||
const here = window.MosaicOrg.id, list = await orgList();
|
||||
const other = (list || []).find(o => o && !o.invalid && o.id !== here && ORG.test(o.id));
|
||||
return [{ org: here, route: '/organization' }, other ? { org: other.id, route: '/organization' } : { org: here, route: '/agents' }];
|
||||
}
|
||||
|
||||
// ---------- markup ----------
|
||||
function paneShell(key) {
|
||||
const name = NAMES[key];
|
||||
return `<section class="pane" id="pane-${key}" data-pane="${key}" aria-label="${name[0].toUpperCase()}${name.slice(1)} pane">
|
||||
<div class="pane-h ws-o" data-pane-head>
|
||||
<span data-pane-av></span><span class="ttl" data-pane-ttl></span><span data-pane-state></span>
|
||||
<label class="ws-sr" for="pane-${key}-pick">Page in the ${name} pane</label>
|
||||
<select id="pane-${key}-pick" class="pane-pick" data-pane-pick></select>
|
||||
<span class="tools"><a class="pane-tool" data-pane-full href="#" title="Open in full view"><span class="ws-sr" data-pane-full-label>Open in full view</span>${FULL}</a><button type="button" class="pane-tool" data-pane-close title="Close pane" aria-label="Close the ${name} pane">${CLOSE}</button></span>
|
||||
</div>
|
||||
<p class="pane-line" data-pane-line role="status"></p>
|
||||
<div class="pane-body" data-pane-body tabindex="0" aria-label="${name[0].toUpperCase()}${name.slice(1)} pane content"></div>
|
||||
</section>`;
|
||||
}
|
||||
function frame() {
|
||||
return `<section class="sbs" data-side-by-side aria-labelledby="sbs-title">
|
||||
<div class="panebar"><h1 id="sbs-title" tabindex="-1">Side by side</h1><span class="note" data-sbs-note></span>
|
||||
<span class="ws-seg pane-tabs" role="group" aria-label="Shown pane" data-sbs-tabs></span>
|
||||
<button type="button" class="ws-btn hide-m" data-pane-add>${PLUS}Add pane</button></div>
|
||||
<div class="panes" data-sbs-panes>${paneShell('a')}<div class="pane-div" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Resize panes" aria-controls="pane-a pane-b" aria-valuemin="${MIN}" aria-valuemax="${MAX}" data-sbs-divider></div>${paneShell('b')}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// Orgs in the picker: every organization on the list. A pane draws a valid one's dashboard, Agents or
|
||||
// any project the organization's project read lists; an invalid one is listed and cannot be chosen.
|
||||
async function pickerHtml(p, list) {
|
||||
const groups = await Promise.all((list || []).filter(o => o && ORG.test(o.id)).map(async org => {
|
||||
const name = ws().label(org);
|
||||
if (org.invalid) return `<optgroup label="${esc(name)} · invalid"><option disabled value="">Organization invalid</option></optgroup>`;
|
||||
const rows = await projectsOf(org.id);
|
||||
const opt = (route, text) => `<option value="${esc(`${org.id}${route}`)}">${esc(text)}</option>`;
|
||||
const projects = rows === null ? '<option disabled value="">Projects unavailable</option>' : rows.filter(r => !r.archived && PROJECT.test(`/projects/${r.id}`)).map(r => opt(`/projects/${r.id}`, `Project: ${r.name || r.id}`)).join('');
|
||||
return `<optgroup label="${esc(name)}${org.busHealthy ? '' : ' · bus unavailable'}">${opt('/organization', 'Dashboard')}${opt('/agents', 'Agents')}${projects}</optgroup>`;
|
||||
}));
|
||||
let html = groups.join('');
|
||||
const value = paneValue(p);
|
||||
// A project section, or a page the list no longer names, is still the pane's page: keep it selectable.
|
||||
if (!html.includes(`value="${esc(value)}"`)) html = `<option value="${esc(value)}">${esc(p.title || value)}</option>${html}`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---------- drawing one pane ----------
|
||||
const els = p => ({
|
||||
head: p.el.querySelector('[data-pane-head]'), av: p.el.querySelector('[data-pane-av]'), ttl: p.el.querySelector('[data-pane-ttl]'),
|
||||
state: p.el.querySelector('[data-pane-state]'), pick: p.el.querySelector('[data-pane-pick]'), full: p.el.querySelector('[data-pane-full]'),
|
||||
fullLabel: p.el.querySelector('[data-pane-full-label]'), close: p.el.querySelector('[data-pane-close]'), line: p.el.querySelector('[data-pane-line]'), body: p.el.querySelector('[data-pane-body]'),
|
||||
});
|
||||
const setHtml = (el, html) => { if (el && el.innerHTML !== html) el.innerHTML = html; };
|
||||
const setText = (el, text) => { if (el && el.textContent !== text) el.textContent = text; };
|
||||
|
||||
// Links a page draws are relative to the page's organization. In a pane they name the pane's
|
||||
// organization; reads the page links to (/api/...) name it too.
|
||||
function rewrite(p, root) {
|
||||
const prefix = `#/orgs/${enc(p.org)}`;
|
||||
for (const a of root.querySelectorAll('a[href^="#/"]')) if (!a.getAttribute('href').startsWith('#/orgs/')) a.setAttribute('href', prefix + a.getAttribute('href').slice(1));
|
||||
for (const a of root.querySelectorAll('a[href^="/api/"]')) if (!a.getAttribute('href').startsWith('/api/orgs/')) a.setAttribute('href', `/api/orgs/${enc(p.org)}/${a.getAttribute('href').slice(5)}`);
|
||||
// The pane's title is the page's heading; the side-by-side page keeps the one level-1 heading.
|
||||
for (const h of root.querySelectorAll('h1:not([aria-level])')) h.setAttribute('aria-level', '2');
|
||||
}
|
||||
// Focus and scroll survive a repaint of this pane only. Focus in the other pane is never taken.
|
||||
function capture(p, root) {
|
||||
const a = document.activeElement, inside = !!a && a !== root && root.contains(a);
|
||||
return {
|
||||
top: root.scrollTop,
|
||||
focus: inside ? { key: a.dataset?.focusKey || null, href: a.getAttribute('href'), index: [...root.querySelectorAll(FOCUSABLE)].indexOf(a), h1: a.tagName === 'H1' } : null,
|
||||
};
|
||||
}
|
||||
function restore(p, root, kept) {
|
||||
root.scrollTop = kept.top;
|
||||
const f = kept.focus, a = document.activeElement;
|
||||
if (!f || (a && a !== document.body && !root.contains(a))) return;
|
||||
const target = (f.key && root.querySelector(`[data-focus-key="${CSS.escape(f.key)}"]`))
|
||||
|| (f.href && [...root.querySelectorAll('a[href]')].find(x => x.getAttribute('href') === f.href))
|
||||
|| (f.h1 && root.querySelector('h1'))
|
||||
|| root.querySelectorAll(FOCUSABLE)[f.index];
|
||||
(target || root).focus({ preventScroll: true });
|
||||
}
|
||||
function paintBody(p, html) {
|
||||
const { body } = els(p);
|
||||
if (p.html === html && body.childElementCount) return;
|
||||
const kept = capture(p, body);
|
||||
body.innerHTML = html; p.html = html; p.project = null;
|
||||
rewrite(p, body);
|
||||
restore(p, body, kept);
|
||||
}
|
||||
|
||||
// A focus move that finishes after an asynchronous read happens only if the viewer has not
|
||||
// moved on: focus is still where it was when the action started, nowhere, or inside this pane.
|
||||
function mayFocus(p, before) {
|
||||
const a = document.activeElement;
|
||||
return !a || a === document.body || a === before || p.el.contains(a);
|
||||
}
|
||||
async function drawPane(p, { fresh = false, focusHead = false } = {}) {
|
||||
if (!mounted || !p.el.isConnected) return;
|
||||
const before = document.activeElement;
|
||||
const turn = ++p.turn, gen = mountGen, live = () => mounted && gen === mountGen && turn === p.turn && p.el.isConnected;
|
||||
clearTimeout(p.timer);
|
||||
const e = els(p), what = page(p.route);
|
||||
const list = await orgList(fresh);
|
||||
if (!live()) return;
|
||||
if (list) listCache = list;
|
||||
const org = list?.find(o => o?.id === p.org) || null;
|
||||
const name = org ? ws().label(org) : p.org;
|
||||
// Header: the organization's mark, the page's title, its state, the picker and the tools.
|
||||
if (org) e.head.setAttribute('style', ws().colours(org)); else e.head.removeAttribute('style');
|
||||
setHtml(e.av, org ? ws().av(org, 'md') : '<span class="ws-av md" aria-hidden="true">?</span>');
|
||||
const [scls, sglyph, sword] = org ? ws().state(org) : ['s-unknown', '?', 'not on your list'];
|
||||
setHtml(e.state, org?.busHealthy && !org.invalid ? '' : ws().dot(scls, sglyph, sword, true));
|
||||
const fullHref = `#/orgs/${enc(p.org)}${p.route}`;
|
||||
if (e.full.getAttribute('href') !== fullHref) e.full.setAttribute('href', fullHref);
|
||||
|
||||
let line = '', html = null;
|
||||
if (!list) line = 'The organization list could not be read. This pane shows what its own reads return.';
|
||||
else if (!org) { line = `${p.org} is not on your organization list.`; html = '<p class="ws-line">Nothing is read for an organization that is not on your list.</p>'; }
|
||||
else if (org.invalid) { line = `${name}: organization invalid${org.reason ? ` (${org.reason})` : ''}.`; html = '<p class="ws-line">Nothing is read from an invalid organization. Fix its file under Settings, Organizations.</p>'; }
|
||||
else if (!org.busHealthy) line = `${name}: bus unavailable. Reads from this organization may fail; the other pane is not affected.`;
|
||||
setText(e.line, line); e.line.hidden = !line;
|
||||
|
||||
let title = what?.kind === 'agents' ? 'Agents' : 'Dashboard';
|
||||
if (html === null && what?.kind === 'dashboard') html = await ws().dashboard(p.org, { pane: true, idPrefix: `pane-${p.key}-` }).catch(() => '<p class="ws-line">Dashboard unavailable.</p>');
|
||||
else if (html === null && what?.kind === 'agents') html = await ws().agentGrid('', p.org).catch(() => '<p class="ws-line">Agents unavailable.</p>');
|
||||
if (!live()) return;
|
||||
if (html === null && what?.kind === 'project') {
|
||||
title = await drawProject(p, e, what, fresh, live);
|
||||
if (title === null || !live()) return;
|
||||
} else if (html !== null) paintBody(p, html);
|
||||
p.title = title;
|
||||
setHtml(e.ttl, `${esc(title)}<small>${esc(name)}</small>`);
|
||||
p.el.setAttribute('aria-label', `${NAMES[p.key][0].toUpperCase()}${NAMES[p.key].slice(1)} pane: ${title}, ${name}`);
|
||||
setText(e.fullLabel, `Open ${title}, ${name}, in full view`);
|
||||
// The picker is rebuilt only while it is closed and unfocused, so a choice in progress stays.
|
||||
if (document.activeElement !== e.pick) {
|
||||
const options = await pickerHtml(p, list);
|
||||
if (!live()) return;
|
||||
if (document.activeElement !== e.pick) { setHtml(e.pick, options); e.pick.value = paneValue(p); }
|
||||
}
|
||||
paintBar(); fit();
|
||||
if (focusHead && mayFocus(p, before)) (e.body.querySelector('h1') || e.body).focus({ preventScroll: true });
|
||||
p.timer = setTimeout(() => tick(p), POLL);
|
||||
}
|
||||
function tick(p) {
|
||||
if (!mounted || !p.el.isConnected) return;
|
||||
if (paused()) { p.timer = setTimeout(() => tick(p), POLL); return; }
|
||||
void drawPane(p);
|
||||
}
|
||||
|
||||
// A project page: projects.js's header and content, mounted into the pane with the pane's
|
||||
// organization, read-only (a pane never sends).
|
||||
// Every write below happens only while this draw is still the pane's current one (live()), so a
|
||||
// slow read never paints an old page or organization over the one the viewer has since chosen.
|
||||
async function drawProject(p, e, what, fresh, live) {
|
||||
const org = p.org;
|
||||
const [projects, threadsRead] = await Promise.all([projectsOf(org, fresh), data.threads(org).catch(() => null)]);
|
||||
if (!live() || p.org !== org) return null;
|
||||
const project = projects?.find(r => r.id === what.id);
|
||||
const sectionName = SECTIONS[what.section];
|
||||
if (!project) {
|
||||
paintBody(p, projects === null ? '<p class="ws-line">Projects unavailable. The organization did not answer the project read.</p>' : '<p class="ws-line">This project is not in the organization\'s project list.</p>');
|
||||
return what.id;
|
||||
}
|
||||
const threads = Array.isArray(threadsRead?.rows) ? threadsRead.rows : [];
|
||||
const head = window.MosaicProjects.header(project, what.section, threads);
|
||||
const conv = what.section === 'conversations' ? conversations(project, threads, threadsRead) : null;
|
||||
const key = JSON.stringify([org, project.id, what.section, head, conv]);
|
||||
const { body } = e;
|
||||
if (p.project?.key !== key) {
|
||||
const kept = capture(p, body);
|
||||
body.innerHTML = `<div class="chat-area pane-project">${head}${conv ?? '<div data-project-content>Reading…</div>'}</div>`;
|
||||
p.html = null; p.project = { key };
|
||||
rewrite(p, body);
|
||||
restore(p, body, kept);
|
||||
}
|
||||
const content = body.querySelector('[data-project-content]');
|
||||
if (content) await window.MosaicProjects.mountContent({ container: content, project, threads, section: what.section, canSend: false, org, pane: true, views: viewsOf(p, org) });
|
||||
return what.section === 'overview' ? project.name || project.id : `${project.name || project.id} · ${sectionName}`;
|
||||
}
|
||||
// A pane's request choice and PRD disclosure, per organization, kept on the pane so a repaint
|
||||
// that replaces the project content keeps them.
|
||||
function viewsOf(p, org) {
|
||||
if (!p.views.has(org)) p.views.set(org, { selected: new Map(), expanded: new Set() });
|
||||
return p.views.get(org);
|
||||
}
|
||||
function conversations(project, threads, read) {
|
||||
if (!read) return '<p class="ws-line">Conversations unavailable.</p>';
|
||||
const own = threads.filter(t => t && !t.shared && t.project === project.id && typeof t.id === 'string');
|
||||
const rows = own.map(t => `<a class="project-conversation-row" href="#/projects/threads/${enc(t.id)}" data-focus-key="${esc(`conv:${t.id}`)}"><span class="project-conversation-icon" aria-hidden="true">◌</span><span><strong>${esc(t.name || t.id)}</strong><small>With ${esc(t.instance || 'no agent recorded')}</small></span><span aria-hidden="true">›</span></a>`).join('');
|
||||
return `<div class="project-section-title"><h2>Conversations</h2></div><div data-project-conversations>${rows || '<p>No conversations yet.</p>'}</div><p class="ws-line">A conversation opens in its full view.</p>`;
|
||||
}
|
||||
|
||||
// ---------- the bar, tabs, divider and layout ----------
|
||||
function paintBar() {
|
||||
if (!mounted) return;
|
||||
const { root, panes, show } = mounted, two = panes.length === 2;
|
||||
setText(root.querySelector('[data-sbs-note]'), `${panes.length} ${panes.length === 1 ? 'pane' : 'panes'} · each pane reads its own organization's bus and keeps its own state`);
|
||||
const tabs = root.querySelector('[data-sbs-tabs]'), focusedTab = tabs.contains(document.activeElement) ? document.activeElement.dataset.paneShow : null;
|
||||
const list = listCache;
|
||||
const html = panes.map(p => {
|
||||
const org = list?.find(o => o?.id === p.org);
|
||||
return `<button type="button" data-pane-show="${p.key}" aria-pressed="${show === p.key}" aria-controls="pane-${p.key}">${org ? ws().av(org, 'xs') : ''}<span>${esc(p.title || 'Reading…')}</span></button>`;
|
||||
}).join('');
|
||||
if (tabs.innerHTML !== html) { tabs.innerHTML = html; if (focusedTab) tabs.querySelector(`[data-pane-show="${focusedTab}"]`)?.focus({ preventScroll: true }); }
|
||||
const add = root.querySelector('[data-pane-add]');
|
||||
add.disabled = two; add.title = two ? 'Side by side holds two panes. Close one to choose another.' : 'Open a second pane';
|
||||
for (const p of panes) { const c = p.el.querySelector('[data-pane-close]'); c.hidden = !two; }
|
||||
}
|
||||
function layout() {
|
||||
if (!mounted) return;
|
||||
const { root, panes, w, show } = mounted, two = panes.length === 2;
|
||||
const box = root.querySelector('[data-sbs-panes]'), divider = root.querySelector('[data-sbs-divider]');
|
||||
box.classList.toggle('two', two);
|
||||
box.style.setProperty('--sbs-a', `${w}fr`); box.style.setProperty('--sbs-b', `${100 - w}fr`);
|
||||
divider.hidden = !two;
|
||||
divider.setAttribute('aria-valuenow', String(w));
|
||||
divider.setAttribute('aria-valuetext', `Left pane ${w} percent, right pane ${100 - w} percent`);
|
||||
root.querySelector('#pane-b').hidden = !two;
|
||||
for (const p of panes) p.el.classList.toggle('alt', show !== p.key);
|
||||
fit();
|
||||
}
|
||||
// Wide screens: the panes fill the window below the bar and each scrolls on its own.
|
||||
const wide = window.matchMedia('(min-width: 761px)');
|
||||
function fit() {
|
||||
if (!mounted) return;
|
||||
const { root } = mounted;
|
||||
if (!wide.matches) { root.style.height = ''; return; }
|
||||
const foot = document.querySelector('body > .foot, .foot')?.getBoundingClientRect().height || 0;
|
||||
const top = root.getBoundingClientRect().top;
|
||||
root.style.height = `${Math.max(360, Math.floor(window.innerHeight - Math.max(top, 0) - foot))}px`;
|
||||
}
|
||||
function setWidth(w, persist = true) {
|
||||
if (!mounted) return;
|
||||
const next = clampW(w);
|
||||
if (next === mounted.w) return;
|
||||
mounted.w = next; layout(); if (persist) writeUrl();
|
||||
}
|
||||
|
||||
// ---------- events (bound once to the mount root) ----------
|
||||
function bind(root) {
|
||||
const divider = root.querySelector('[data-sbs-divider]');
|
||||
divider.addEventListener('keydown', e => {
|
||||
const w = mounted?.w; if (w === undefined) return;
|
||||
const next = { ArrowLeft: w - STEP, ArrowDown: w - STEP, ArrowRight: w + STEP, ArrowUp: w + STEP, PageDown: w - 10, PageUp: w + 10, Home: MIN, End: MAX }[e.key];
|
||||
if (next === undefined) return;
|
||||
e.preventDefault(); setWidth(next);
|
||||
});
|
||||
divider.addEventListener('pointerdown', e => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault(); divider.focus({ preventScroll: true }); divider.setPointerCapture(e.pointerId); divider.classList.add('dragging');
|
||||
const box = root.querySelector('[data-sbs-panes]').getBoundingClientRect();
|
||||
const move = ev => setWidth(((ev.clientX - box.left) / box.width) * 100, false);
|
||||
const up = () => { divider.removeEventListener('pointermove', move); divider.removeEventListener('pointerup', up); divider.removeEventListener('pointercancel', up); divider.classList.remove('dragging'); writeUrl(); };
|
||||
divider.addEventListener('pointermove', move); divider.addEventListener('pointerup', up); divider.addEventListener('pointercancel', up);
|
||||
});
|
||||
root.addEventListener('change', e => {
|
||||
const pick = e.target.closest?.('[data-pane-pick]'); if (!pick || !mounted) return;
|
||||
const p = mounted.panes.find(x => x.el.contains(pick)), next = parsePane(pick.value);
|
||||
if (!p || !next) return;
|
||||
go(p, next, false);
|
||||
});
|
||||
root.addEventListener('click', e => {
|
||||
if (!mounted) return;
|
||||
const show = e.target.closest?.('[data-pane-show]');
|
||||
if (show) { mounted.show = show.dataset.paneShow === 'b' && mounted.panes.length === 2 ? 'b' : 'a'; layout(); paintBar(); writeUrl(); return; }
|
||||
if (e.target.closest?.('[data-pane-add]')) { void addPane(); return; }
|
||||
const close = e.target.closest?.('[data-pane-close]');
|
||||
if (close) { closePane(mounted.panes.find(x => x.el.contains(close))); return; }
|
||||
// A plain click on a link to a page a pane draws, in the pane's own organization, opens it in
|
||||
// the pane. Anything else (another organization, a page a pane does not draw, a new tab) is
|
||||
// an ordinary link to the full view.
|
||||
const a = e.target.closest?.('a[href^="#/orgs/"]');
|
||||
if (!a || a.hasAttribute('data-pane-full') || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
const p = mounted.panes.find(x => x.el.contains(a)); if (!p) return;
|
||||
const m = /^#\/orgs\/([^/?#]+)(\/[^?#]*)$/.exec(a.getAttribute('href'));
|
||||
let org, route;
|
||||
try { org = decodeURIComponent(m?.[1] ?? ''); route = decodeURIComponent(m?.[2] ?? ''); } catch { return; }
|
||||
if (org !== p.org || !page(route)) return;
|
||||
e.preventDefault();
|
||||
go(p, { org, route }, true);
|
||||
});
|
||||
}
|
||||
function go(p, next, focusHead) {
|
||||
p.org = next.org; p.route = next.route; p.html = null; p.project = null; p.title = null;
|
||||
els(p).body.scrollTop = 0;
|
||||
writeUrl();
|
||||
void drawPane(p, { focusHead });
|
||||
}
|
||||
async function addPane() {
|
||||
if (!mounted || mounted.panes.length !== 1) return;
|
||||
const before = document.activeElement;
|
||||
const [a] = mounted.panes, list = await orgList();
|
||||
if (!mounted || mounted.panes.length !== 1) return;
|
||||
const other = (list || []).find(o => o && !o.invalid && o.id !== a.org && ORG.test(o.id));
|
||||
const b = makePane('b', other ? { org: other.id, route: '/organization' } : { org: a.org, route: a.route === '/agents' ? '/organization' : '/agents' });
|
||||
mounted.panes.push(b); mounted.show = 'b';
|
||||
layout(); paintBar(); writeUrl();
|
||||
await drawPane(b);
|
||||
if (mounted?.panes.includes(b) && mayFocus(b, before)) els(b).pick.focus({ preventScroll: true });
|
||||
}
|
||||
function closePane(p) {
|
||||
if (!mounted || !p || mounted.panes.length !== 2) return;
|
||||
clearTimeout(p.timer); p.turn++;
|
||||
const keep = mounted.panes.find(x => x !== p);
|
||||
// The remaining pane becomes the first: its page moves into pane a's element.
|
||||
const a = mounted.panes[0];
|
||||
if (keep !== a) {
|
||||
const b = mounted.panes[1];
|
||||
clearTimeout(b.timer); b.turn++;
|
||||
a.org = keep.org; a.route = keep.route; a.html = null; a.project = null; a.title = keep.title; a.views = keep.views;
|
||||
}
|
||||
setHtml(els(mounted.panes[1]).body, ''); mounted.panes[1].html = null; mounted.panes[1].project = null;
|
||||
mounted.panes = [a]; mounted.show = 'a';
|
||||
layout(); paintBar(); writeUrl();
|
||||
// The close button just left the page; focus goes to the remaining pane's picker now, and again
|
||||
// after its draw only if the viewer has not moved elsewhere meanwhile.
|
||||
els(a).pick.focus({ preventScroll: true });
|
||||
const before = document.activeElement;
|
||||
void drawPane(a).then(() => { if (mounted?.panes[0] === a && mayFocus(a, before)) els(a).pick.focus({ preventScroll: true }); });
|
||||
}
|
||||
function makePane(key, at) {
|
||||
const el = mounted.root.querySelector(`#pane-${key}`);
|
||||
return { key, el, org: at.org, route: at.route, turn: 0, timer: null, html: null, project: null, title: null, views: new Map() };
|
||||
}
|
||||
|
||||
// ---------- public ----------
|
||||
async function mount(container, query, { navigated = false } = {}) {
|
||||
unmount();
|
||||
const gen = ++mountGen;
|
||||
container.innerHTML = frame();
|
||||
const root = container.querySelector('[data-side-by-side]');
|
||||
mounted = { root, panes: [], w: 50, show: 'a', gen, observers: [] };
|
||||
// projects.js paints a project's content after its own reads; its links are rewritten as they
|
||||
// land. One observer per pane element for the life of the mount, applying whichever pane object
|
||||
// holds that element now, so a closed and re-added pane never runs an old organization's rewrite.
|
||||
const m = mounted;
|
||||
for (const key of ['a', 'b']) {
|
||||
const body = root.querySelector(`#pane-${key} [data-pane-body]`);
|
||||
const observer = new MutationObserver(() => {
|
||||
if (mounted !== m) return;
|
||||
const p = m.panes.find(x => x.key === key);
|
||||
if (p) rewrite(p, body);
|
||||
});
|
||||
observer.observe(body, { childList: true, subtree: true });
|
||||
m.observers.push(observer);
|
||||
}
|
||||
bind(root);
|
||||
window.addEventListener('resize', fit);
|
||||
document.title = 'Side by side · Mosaic Console';
|
||||
if (navigated) root.querySelector('h1').focus({ preventScroll: true });
|
||||
let wanted = readQuery(query);
|
||||
if (!wanted.named) { const kept = readQuery(load() || ''); if (kept.a) wanted = { ...kept, named: true }; }
|
||||
let at = [wanted.a, wanted.b].filter(Boolean);
|
||||
if (!at.length) at = await defaults();
|
||||
if (!mounted || gen !== mountGen) return;
|
||||
listCache = await orgList();
|
||||
if (!mounted || gen !== mountGen) return;
|
||||
mounted.panes = at.slice(0, 2).map((x, i) => makePane(i ? 'b' : 'a', x));
|
||||
mounted.w = Number.isFinite(wanted.w) && wanted.w ? clampW(wanted.w) : 50;
|
||||
mounted.show = wanted.show === 'b' && mounted.panes.length === 2 ? 'b' : 'a';
|
||||
layout(); paintBar(); writeUrl();
|
||||
await Promise.all(mounted.panes.map(p => drawPane(p)));
|
||||
}
|
||||
function unmount() {
|
||||
if (!mounted) return;
|
||||
for (const p of mounted.panes) { clearTimeout(p.timer); p.turn++; }
|
||||
for (const o of mounted.observers) o.disconnect();
|
||||
window.removeEventListener('resize', fit);
|
||||
mounted = null; mountGen++;
|
||||
}
|
||||
// The Console's Refresh: both panes read again now, each keeping its own focus and scroll.
|
||||
function refresh() {
|
||||
if (!mounted) return;
|
||||
projectReads = new Map();
|
||||
void orgList(true).then(rows => { if (rows) listCache = rows; });
|
||||
for (const p of mounted.panes) void drawPane(p, { fresh: true });
|
||||
}
|
||||
// The list the tabs draw from follows each fresh read of the organization list.
|
||||
document.addEventListener('mosaic-org-list', e => { if (Array.isArray(e.detail?.rows)) listCache = e.detail.rows; });
|
||||
|
||||
window.MosaicPanes = Object.freeze({ mount, unmount, refresh, page });
|
||||
})();
|
||||
@@ -31,12 +31,15 @@
|
||||
let treeRows=[], treeThreads=[], treeGeneration=0, treeState, treeRead=null, treeReadAt=0;
|
||||
const treeFocus = new WeakMap();
|
||||
try { treeState=JSON.parse(localStorage.getItem(storageKey) || '{}'); if (!treeState || Array.isArray(treeState) || typeof treeState !== 'object') treeState={}; } catch { treeState={}; }
|
||||
// A side-by-side pane reads its own organization: its reads name that organization explicitly.
|
||||
const at=(org,url)=>org && url.startsWith('/api/') ? `/api/orgs/${encodeURIComponent(org)}/${url.slice(5)}` : url;
|
||||
async function read(url) {
|
||||
const response=await fetch(url,{cache:'no-store',redirect:'manual'});
|
||||
if(response.status===401 || response.type==='opaqueredirect') { document.dispatchEvent(new CustomEvent('mosaic-signed-out',{detail:{signIn:true}})); throw Error('signed-out'); }
|
||||
if(!response.ok) { const body=await response.json().catch(()=>null); const error=Error('unavailable'); error.status=response.status; error.code=body?.error; throw error; }
|
||||
return response.json();
|
||||
}
|
||||
const readAt=url=>read(url);
|
||||
function currentProject(route) {
|
||||
const id=route.match(/^\/(?:projects|chat)\/(?:projects\/|rooms\/)?([0-9a-f-]{36})/i)?.[1];
|
||||
if(id) return id;
|
||||
@@ -104,8 +107,9 @@
|
||||
const same=(a,b)=>JSON.stringify(canonical(a))===JSON.stringify(canonical(b));
|
||||
const material=(d,s)=>d && d.candidate && s?.candidate && same(d.candidate,s.candidate) && d.candidate.request===s.request && d.candidate.project===s.project && d.candidate.mode_revision===s.mode_revision && typeof d.prd==='string' && Array.isArray(d.requirements) && Array.isArray(d.candidate.requirements) && d.requirements.length===d.candidate.requirements.length && d.requirements.every((r,i)=>r && r.id===d.candidate.requirements[i]?.id && r.title===d.candidate.requirements[i]?.title && typeof r.text==='string') && d.snapshot && Number.isSafeInteger(d.snapshot.revision) && d.snapshot.revision>=0 && d.snapshot.id===s.snapshot && (d.snapshot.id===null || requestId(d.snapshot.id) && d.snapshot.revision>0);
|
||||
const ownThreads=(project,threads)=>threads.filter(t=>t.project===project.id && !t.shared && UUID.test(t.id));
|
||||
async function gather(project,threads) {
|
||||
const own=ownThreads(project,threads), key=project.id+':'+String(project.repo)+':'+own.map(t=>t.id).join(',');
|
||||
async function gather(project,threads,org=null) {
|
||||
const read=url=>readAt(at(org,url));
|
||||
const own=ownThreads(project,threads), key=(org?org+'|':'')+project.id+':'+String(project.repo)+':'+own.map(t=>t.id).join(',');
|
||||
const prior=cache.get(key); if(prior && Date.now()-prior.at<10000) return prior.promise;
|
||||
const started=epoch;
|
||||
const promise=(async()=>{
|
||||
@@ -133,7 +137,7 @@
|
||||
const [draft,workspace]=await Promise.all([read(base+'/draft').catch(()=>null),read(base+'/workspace').catch(()=>null)]);
|
||||
return {...item,state,draft:material(draft,state)?draft:null,materialFailed:!!state.candidate && !material(draft,state),workspace};
|
||||
}))).filter(Boolean);
|
||||
const [tasks,catalog,room]=await Promise.all([read('/api/bus/tasks').catch(()=>null),read('/api/chat/instances').catch(()=>null),readRoomStatus(project)]);
|
||||
const [tasks,catalog,room]=await Promise.all([read('/api/bus/tasks').catch(()=>null),read('/api/chat/instances').catch(()=>null),readRoomStatus(project,org)]);
|
||||
const members=Array.isArray(catalog?.rows)?catalog.rows.filter(m=>typeof m?.instance==='string' && own.some(t=>t.instance===m.instance && t.name===t.instance)).length:null;
|
||||
let docs=project.repo ? null : {files:[]}, north=null, recordsMissing=false, northMissing=false, northRecordsMissing=false;
|
||||
if(project.repo && /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/.test(project.repo)) {
|
||||
@@ -145,9 +149,9 @@
|
||||
})();
|
||||
cache.set(key,{at:Date.now(),promise}); return promise;
|
||||
}
|
||||
async function readRoomStatus(project) {
|
||||
async function readRoomStatus(project,org=null) {
|
||||
try {
|
||||
const response=await fetch(`/api/chat/rooms/${encodeURIComponent(project.id)}/messages?limit=100`,{cache:'no-store',redirect:'manual'});
|
||||
const response=await fetch(at(org,'/api/chat/rooms/')+`${encodeURIComponent(project.id)}/messages?limit=100`,{cache:'no-store',redirect:'manual'});
|
||||
if(response.status===401 || response.type==='opaqueredirect') { document.dispatchEvent(new CustomEvent('mosaic-signed-out',{detail:{signIn:true}})); return 'Unavailable'; }
|
||||
const body=await response.json();
|
||||
if(response.ok && Array.isArray(body?.rows) && typeof body.hasMore==='boolean') return 'Open';
|
||||
@@ -194,11 +198,18 @@
|
||||
return `<h2>Reviewed deliveries <span class="project-count">${f.reviewed.length}</span></h2>${f.reviewed.length?f.reviewed.map(({r,file})=>`<div class="project-panel"><span class="project-delivery-icon" aria-hidden="true">▤</span><strong>${clean(file.name)}</strong> <span class="project-chip success">Reviewed</span><p>Request ${clean(r.id)} · from ${clean(r.thread.name)} · Reviewed delivery. Not landed or deployed.</p><div class="project-delivery-actions"><a class="btn" target="_blank" rel="noopener noreferrer" href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}/text">Open as text</a> <a class="btn" download href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}">Download</a>${deliveryPr(r)?` <a class="btn" target="_blank" rel="noopener noreferrer" href="${esc(deliveryPr(r))}">Open pull request</a>`:''}</div><p>Open shows plain text in a new tab. Downloaded HTML can be opened locally after saving.</p></div>`).join(''):`<p>${data.disabled?'Files are unavailable.':data.failed?'Reviewed files are unavailable.':data.requests.length?'No reviewed file is available.':'No request files yet. Files appear when a request in this project produces them.'}</p>`}<h2>Draft workspace <span class="project-count">${f.drafts.length}</span></h2><p>Names only. Draft files may change and are not reviewed deliveries.</p><div class="project-panel project-file-list">${f.drafts.length?f.drafts.map(({r,name})=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><span>Request ${clean(r.id)} · <span class="project-chip">Draft</span></span></div>`).join(''):data.failed || data.requests.some(r=>r.workspace?.available!==true || !Array.isArray(r.workspace.entries))?'<p>Could not read draft workspace. Try again later.</p>':'<p>No draft files found.</p>'}${data.requests.some(r=>r.workspace?.truncated)?'<p>The draft list was truncated.</p>':''}</div><h2>Project documents ${docs?`<span class="project-count">${docs.length}</span>`:''}</h2><div class="project-panel project-file-list">${!project.repo?'No linked repository.':data.recordsMissing?window.MosaicRecords.markup(project.repo):!docs?'Project documents unavailable. Docs could not read this project’s repository.':docs.length?docs.map(name=>`<div><span><span aria-hidden="true">▤</span> <code>${clean(name)}</code></span><a href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(name)}">Open in Docs</a></div>`).join(''):'No project documents found.'}</div>`;
|
||||
}
|
||||
const expandedPrds = new Set();
|
||||
function requirementView(data,project,selected) {
|
||||
// U5: a project in a Side by side pane keeps its own request choice and PRD disclosure, so two
|
||||
// panes showing the same project (or two organizations' projects with one id) never share them.
|
||||
// The pane passes `views`, kept on the pane per organization, so the state outlives a repaint
|
||||
// that replaces the content element (a new conversation changes the header). Without it, the
|
||||
// state lives as long as the container.
|
||||
const paneViews = new WeakMap();
|
||||
const viewState=(container,pane,views)=>{if(!pane) return {selected:selectedRequests,expanded:expandedPrds}; if(views?.selected instanceof Map && views.expanded instanceof Set) return views; if(!paneViews.has(container)) paneViews.set(container,{selected:new Map(),expanded:new Set()}); return paneViews.get(container);};
|
||||
function requirementView(data,project,selected,expandedSet=expandedPrds) {
|
||||
const options=data.requests.filter(r=>r.draft), r=options.find(x=>x.id===selected)||options[0], d=r?.draft;
|
||||
const north=typeof data.north?.content==='string'?data.north.content:null;
|
||||
const select=options.length>1?`<label>Request <select id="project-request-select">${options.map(x=>`<option value="${clean(x.id)}"${x===r?' selected':''}>${clean(x.id)} · ${clean(x.thread.name)}</option>`).join('')}</select></label>`:'';
|
||||
const candidate=d?.candidate, expanded=r && expandedPrds.has(project.id+':'+r.id);
|
||||
const candidate=d?.candidate, expanded=r && expandedSet.has(project.id+':'+r.id);
|
||||
const docPath=candidate?.prd_path, docLink=project.repo && typeof docPath==='string' && /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(docPath) ? `#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(docPath)}` : null;
|
||||
const rows=candidate ? [['PRD version',candidate.prd_version],['Approved snapshot',d.snapshot.id || 'None'],['PRD file',docPath],['PRD blob',candidate.prd_blob],['Records commit',candidate.records_commit],['Confirmed intent commit',candidate.spec_commit],['Mode revision',candidate.mode_revision]].map(([label,value])=>`<div class="project-provenance-row"><span>${label}</span>${label==='PRD file' && docLink?`<a href="${docLink}"><code>${clean(value)}</code></a>`:`<code>${clean(value)}</code>`}</div>`).join('') : '<p>Unavailable until a PRD is read.</p>';
|
||||
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?`<p>${window.MosaicRecords.markup(project.repo)}</p>`:data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details data-restore-key="req:${clean(r.id)}:${clean(req.id)}"><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details data-restore-key="provenance"${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
|
||||
@@ -241,31 +252,32 @@
|
||||
const wanted=new Set((restore.disclosures || []).map(String)), closed=new Set((restore.closed || []).map(String));
|
||||
[...root.querySelectorAll('details')].forEach((el,i)=>{const key=restoreKey(el,i);if(wanted.has(key)) el.open=true;else if(el.dataset.restoreKey && closed.has(key)) el.open=false;});
|
||||
const f=restore.projectFocus; if(!f) return;
|
||||
const focus=f.id ? document.getElementById(f.id) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index];
|
||||
const focus=f.id ? root.querySelector('#'+CSS.escape(f.id)) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index];
|
||||
focus?.focus({preventScroll:true});
|
||||
}
|
||||
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt}) {
|
||||
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null,pane=false,views:paneState=null}) {
|
||||
if(!container || !project) return;
|
||||
const generation=epoch, run=(contentRuns.get(container) || 0)+1;
|
||||
contentRuns.set(container,run);
|
||||
if (!contentPaints.has(container)) container.textContent='Reading…';
|
||||
const data=await gather(project,threads).catch(()=>null);
|
||||
const data=await gather(project,threads,org).catch(()=>null);
|
||||
if(!container.isConnected || generation!==epoch || contentRuns.get(container)!==run || document.body.classList.contains('signed-out')) return;
|
||||
if(!data){contentPaints.delete(container);container.textContent='Project data unavailable. Refresh to try again.';return;}
|
||||
const views=viewState(container,pane,paneState), selectedRequests=views.selected, expandedPrds=views.expanded;
|
||||
const selected=selectedRequests.get(project.id);
|
||||
if(selected && !data.requests.some(r=>r.id===selected && r.draft)) selectedRequests.delete(project.id);
|
||||
const selection=selectedRequests.get(project.id);
|
||||
const view = section==='requirements'?requirementView(data,project,selection):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
const view = section==='requirements'?requirementView(data,project,selection,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
const selectedRequest=data.requests.find(r=>r.id===(selection||data.requests.find(x=>x.draft)?.id));
|
||||
// The generated presentation and separately mounted Markdown determine visible content.
|
||||
const signature=JSON.stringify([view, section==='requirements' ? selectedRequest?.draft?.prd : null, ['requirements','overview'].includes(section) ? data.north?.content : null, data.failed && ['files','tasks'].includes(section)]);
|
||||
const header=container.closest('.chat-area')?.querySelector('.project-tabs');
|
||||
if(header){const tasks=header.querySelector(`a[href="${path(project.id,'tasks')}"]`), files=header.querySelector(`a[href="${path(project.id,'files')}"]`);const f=filesData(data);if(tasks && !tasks.querySelector('.project-count') && !data.failed)tasks.insertAdjacentHTML('beforeend',` <span class="project-count">${taskRefs(data).length}</span>`);if(files && !files.querySelector('.project-count') && !data.failed && f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries)))files.insertAdjacentHTML('beforeend',` <span class="project-count">${f.reviewed.length+f.drafts.length+f.docs.length}</span>`);}
|
||||
if(header){const tab=key=>header.querySelector(`a[href$="${path(project.id,key).slice(1)}"]`), tasks=tab('tasks'), files=tab('files');const f=filesData(data);if(tasks && !tasks.querySelector('.project-count') && !data.failed)tasks.insertAdjacentHTML('beforeend',` <span class="project-count">${taskRefs(data).length}</span>`);if(files && !files.querySelector('.project-count') && !data.failed && f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries)))files.insertAdjacentHTML('beforeend',` <span class="project-count">${f.reviewed.length+f.drafts.length+f.docs.length}</span>`);}
|
||||
revealTab(header);
|
||||
// Retained controls (the request selector) draw from the newest accepted read, even when
|
||||
// this read changes nothing visible and the DOM is kept.
|
||||
contentModels.set(container,{data,project,section,canSend,roomReceipt});
|
||||
window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
|
||||
if(!pane) window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
|
||||
if (contentPaints.get(container) === signature) return;
|
||||
contentPaints.set(container, signature);
|
||||
// Capture at commit time: the user may have interacted while the read was pending.
|
||||
@@ -273,7 +285,7 @@
|
||||
const liveRestore=contentPaints.has(container) && container.children.length ? captureView(container) : null;
|
||||
const draw=selected=>{
|
||||
const {data,project,section,canSend,roomReceipt}=contentModels.get(container);
|
||||
container.innerHTML=section==='requirements'?requirementView(data,project,selected):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
container.innerHTML=section==='requirements'?requirementView(data,project,selected,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
|
||||
if(data.failed && ['files','tasks'].includes(section)) container.insertAdjacentHTML('afterbegin','<p role="status">Some request data could not be read; this list may be incomplete.</p>');
|
||||
const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id));
|
||||
const md=container.querySelector('[data-prd]'); if(md && selectedRequest?.draft) { md.replaceChildren(window.MosaicMarkdown?.render(selectedRequest.draft.prd) || document.createTextNode(selectedRequest.draft.prd)); const toggle=container.querySelector('.project-prd-toggle'); toggle.hidden=!md.classList.contains('expanded') && md.scrollHeight<=md.clientHeight; toggle.addEventListener('click',()=>{const key=project.id+':'+selectedRequest.id; if(expandedPrds.has(key)) expandedPrds.delete(key); else expandedPrds.add(key); const open=expandedPrds.has(key); md.classList.toggle('expanded',open); toggle.setAttribute('aria-expanded',String(open));toggle.textContent=open?'Show less':'Show full PRD';}); }
|
||||
@@ -285,7 +297,7 @@
|
||||
if (restore) {
|
||||
restoreView(container,restore);
|
||||
}
|
||||
const sectionTarget=new URLSearchParams(location.hash.split('?')[1] || '').get('section');
|
||||
const sectionTarget=pane ? null : new URLSearchParams(location.hash.split('?')[1] || '').get('section');
|
||||
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'});
|
||||
}
|
||||
// Cut at a word boundary, unless that would leave only a markdown marker (a long heading).
|
||||
@@ -334,7 +346,7 @@
|
||||
}
|
||||
const palette=()=>treeRows.filter(p=>!p.archived && UUID.test(p.id)).flatMap(p=>sections.map(([key,label])=>({href:path(p.id,key),label:key==='overview'?p.name:`${p.name}: ${label}`,group:'project'})));
|
||||
function breadcrumb(box, view) {
|
||||
const h=view.querySelector('.project-header'); if(!h)return false;
|
||||
const h=view.querySelector('.project-header'); if(!h||h.closest('[data-pane]'))return false;
|
||||
const id=h.dataset.projectId, name=h.dataset.projectName, section=h.dataset.projectSection, conversation=h.dataset.projectConversation==='true';
|
||||
const title=sections.find(([key])=>key===section)?.[1] || (section==='settings'?'Settings':'Overview');
|
||||
const leaf=conversation?view.querySelector('h1')?.textContent:title;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/* Phase P2 (#1873): System types on the organization dashboard (.ws-types, the chips and the
|
||||
Settings row's Open) and on a project's information page (.project-types). */
|
||||
html[data-organizations] .ws-chip{display:inline-flex;align-items:center;font-size:12px;font-weight:500;padding:1px 7px;border:1px solid var(--line);border-radius:4px;color:var(--text);white-space:nowrap}
|
||||
html[data-organizations] .ws-cfg button.go{border:0;background:none;padding:0;cursor:pointer;font:inherit;font-size:12.5px;font-weight:600;color:var(--action)}
|
||||
html[data-organizations] .ws-cfg button.go:hover{text-decoration:underline}
|
||||
html[data-organizations] .ws-types-list{display:grid;list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)}
|
||||
html[data-organizations] .ws-types-list li{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:4px 12px;padding:8px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
|
||||
html[data-organizations] .ws-types-list li:last-child{border-bottom:0}
|
||||
html[data-organizations] .ws-types-list .t{display:flex;flex-direction:column;min-width:0}
|
||||
html[data-organizations] .ws-types-list .t b{font-size:13px;font-weight:600;color:var(--strong)}
|
||||
html[data-organizations] .ws-types-list .t .d{font-size:12px;color:var(--muted);overflow-wrap:anywhere}
|
||||
html[data-organizations] .ws-types-list .ws-dot{font-size:12.5px}
|
||||
html[data-organizations] .ws-types-list .ws-btn[aria-disabled="true"]{color:var(--muted);cursor:progress}
|
||||
html[data-organizations] .ws-types>.note{margin:8px 0 0}
|
||||
html[data-organizations] .ws-types-status:empty{display:none}
|
||||
html[data-organizations] .ws-types-status code{overflow-wrap:anywhere}
|
||||
html[data-organizations] .ws-types h2:focus{outline:none}
|
||||
@media (max-width:759px){
|
||||
html[data-organizations] .ws-types-list li{grid-template-columns:minmax(0,1fr) auto}
|
||||
html[data-organizations] .ws-types-list .t{grid-column:1/-1}
|
||||
}
|
||||
|
||||
.project-types{border:1px solid var(--border, var(--line, #aaa));border-radius:4px;padding:1rem;margin-top:1rem;overflow-wrap:anywhere}
|
||||
.project-types fieldset{border:0;margin:0;padding:0;display:grid;gap:.5rem}
|
||||
.project-types legend{font-weight:600;margin-bottom:.25rem}
|
||||
.project-types-option{display:flex;align-items:flex-start;gap:.5rem;min-height:44px}
|
||||
.project-types-option input{margin-top:.2rem;width:20px;height:20px;flex:none}
|
||||
.project-types-option span{display:flex;flex-direction:column;min-width:0}
|
||||
.project-types-option small{color:var(--muted, inherit)}
|
||||
.project-types button{min-height:44px;margin-top:.75rem}
|
||||
.project-types-tag{font-size:.8em;font-weight:600;padding:0 .4em;border:1px solid currentColor;border-radius:4px;color:var(--warning, inherit)}
|
||||
.project-types [role="status"]:empty{display:none}
|
||||
.project-types [role="status"]:focus{outline:none}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Phase P2 (#1873): System types, the owner's control on the organization dashboard and on a project's
|
||||
// information page. The modules, their labels and descriptions come from the system-type plug-ins
|
||||
// through /api/system-types; nothing here lists a module. Writes go to the same route, which uses the
|
||||
// organization writer (revision, owner fence, receipt) or project.system-types.set; the server and the
|
||||
// bus recheck every write, so a control shown here grants nothing.
|
||||
(() => {
|
||||
'use strict';
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const enc = encodeURIComponent;
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
|
||||
const orgPath = (org, rest) => (org ? `/api/orgs/${enc(org)}/${rest}` : `/api/${rest}`);
|
||||
const LINES = {
|
||||
'sign-in-required': 'Sign in again to see or change system types.',
|
||||
'owner-only': "System types are the organization owner's.",
|
||||
'read-only': 'This Console only reads. Changing system types needs signed-in send mode.',
|
||||
'human-required': "Only the organization's owner can change system types.",
|
||||
'organization-system-types-human-only': "Only the organization's owner can change system types.",
|
||||
'revision-conflict': 'The organization file changed after this page read it. The list was read again; try again.',
|
||||
'organization-busy': 'The organization file is being written. Try again in a moment.',
|
||||
'system-type-not-enabled': 'A chosen type is not enabled in the organization any more. The list was read again.',
|
||||
'system-type-unknown': 'A chosen type is not installed in this tree.',
|
||||
'project-not-found': 'This project was not found.',
|
||||
'not-configured': 'System types are not configured on this Console.',
|
||||
'system-type-invalid': 'An installed system type module is invalid, so none can be read.',
|
||||
'no-bus-host': 'The bus is not running.',
|
||||
'invalid-request': 'The request was refused as invalid.',
|
||||
};
|
||||
const line = code => LINES[code] || 'The bus did not confirm the outcome. The list was read again; check it before you retry.';
|
||||
|
||||
// One request; a refusal keeps its code so the page can say why.
|
||||
async function call(path, body) {
|
||||
const init = body === undefined ? { cache: 'no-store', redirect: 'manual' }
|
||||
: { method: 'POST', cache: 'no-store', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) };
|
||||
let response;
|
||||
try { response = await fetch(path, init); } catch { return { ok: false, code: 'outcome-unknown' }; }
|
||||
if (response.type === 'opaqueredirect') return { ok: false, code: 'sign-in-required' };
|
||||
const value = await response.json().catch(() => null);
|
||||
if (!response.ok) return { ok: false, code: typeof value?.error === 'string' ? value.error : 'outcome-unknown' };
|
||||
return value && typeof value === 'object' ? { ok: true, value } : { ok: false, code: 'outcome-unknown' };
|
||||
}
|
||||
const read = (org, project = null) => call(orgPath(org, project === null ? 'system-types' : `system-types/projects/${enc(project)}`));
|
||||
const labelOf = (value, id) => value.installed.find(m => m.id === id)?.label || id;
|
||||
const valid = value => value && Array.isArray(value.installed) && Array.isArray(value.enabled);
|
||||
|
||||
// ---------- organization dashboard ----------
|
||||
// Per organization: the last read, the type a write is pending on, and the last outcome line.
|
||||
const orgs = new Map();
|
||||
const slot = org => { if (!orgs.has(org)) orgs.set(org, { value: null, code: null, pending: null, outcome: null }); return orgs.get(org); };
|
||||
function remember(org, result) {
|
||||
const s = slot(org);
|
||||
if (result.ok && valid(result.value)) { s.value = result.value; s.code = null; } else { s.value = null; s.code = result.ok ? 'outcome-unknown' : result.code; }
|
||||
return s;
|
||||
}
|
||||
// The chip row under the header: the enabled modules' labels.
|
||||
function chips(org) {
|
||||
const s = slot(org), v = s.value;
|
||||
const body = !v ? `<span class="note">${esc(s.code === 'owner-only' ? line('owner-only') : 'System types unavailable')}</span>`
|
||||
: !v.installed.length ? '<span class="note">No system type modules are installed.</span>'
|
||||
: v.enabled.length ? `${v.enabled.map(id => `<span class="ws-chip">${esc(labelOf(v, id))}</span>`).join('')}<span class="note">Each project picks from these; none is required.</span>`
|
||||
: '<span class="note">None enabled. Enable them under System types below; each project picks from the enabled ones.</span>';
|
||||
return `<div class="ws-typechips ws-chips" data-system-type-chips><span class="ws-label">System types</span>${body}</div>`;
|
||||
}
|
||||
// The Settings row's value.
|
||||
function summary(org) {
|
||||
const v = slot(org).value;
|
||||
return v ? `${v.enabled.filter(id => v.installed.some(m => m.id === id)).length} of ${v.installed.length} installed enabled` : 'Could not be read';
|
||||
}
|
||||
// A side-by-side pane (U5) passes { pane: true, idPrefix }: it shows the types without the switch,
|
||||
// which stays in the organization's full view, and its heading id carries the pane's prefix.
|
||||
function section(org, { pane = false, idPrefix = '' } = {}) {
|
||||
const s = slot(org), v = s.value;
|
||||
const head = `<div class="ws-sec-h"><h2 id="${esc(idPrefix)}ws-types-h" tabindex="-1" data-system-types-heading>System types</h2><span class="note">installed modules; each project picks from the enabled ones</span></div>`;
|
||||
const status = `<p class="note ws-types-status" role="status" data-system-types-status>${s.outcome ? s.outcome : ''}</p>`;
|
||||
if (!v) return `<div class="ws-types" data-system-types>${head}<p class="ws-panel tight note">${esc(s.code === 'owner-only' ? line('owner-only') : `System types unavailable. ${line(s.code)}`)}</p>${status}</div>`;
|
||||
if (!v.installed.length) return `<div class="ws-types" data-system-types>${head}<p class="ws-panel tight note">No system type modules are installed.</p>${status}</div>`;
|
||||
const rows = v.installed.map(m => {
|
||||
const on = v.enabled.includes(m.id), busy = s.pending !== null;
|
||||
const state = `<span class="ws-dot ${on ? 's-working' : 's-idle'}"><b aria-hidden="true">${on ? '●' : '–'}</b>${on ? 'enabled' : 'off'}</span>`;
|
||||
const control = v.writable && !pane
|
||||
? `<button type="button" class="ws-btn" data-system-type-toggle="${esc(m.id)}" data-on="${on}" data-focus-key="${esc(`st:${m.id}`)}" aria-label="${esc(`${on ? 'Disable' : 'Enable'} ${m.label}`)}"${busy ? ' aria-disabled="true"' : ''}>${s.pending === m.id ? 'Saving…' : on ? 'Disable' : 'Enable'}</button>`
|
||||
: '';
|
||||
return `<li data-system-type="${esc(m.id)}"><span class="t"><b>${esc(m.label)}</b><span class="d">${esc(m.description)}</span></span>${state}${control}</li>`;
|
||||
}).join('');
|
||||
const why = pane ? "<p class=\"note\">Change system types from this organization's full view.</p>"
|
||||
: v.writable ? '<p class="note">Disabling a type keeps it in the projects that pick it; they show it as inactive until it is enabled again.</p>'
|
||||
: `<p class="note">${esc(line('read-only'))}</p>`;
|
||||
return `<div class="ws-types" data-system-types>${head}<ul class="ws-types-list">${rows}</ul>${why}${status}</div>`;
|
||||
}
|
||||
// The dashboard asks for this before it paints; the result stays for chips(), summary() and section().
|
||||
const load = async org => remember(org, await read(org));
|
||||
// After a write, the three parts are repainted in place; the 10 s dashboard repaint reads again anyway.
|
||||
function repaint(org) {
|
||||
if (window.MosaicOrg?.id !== org) return;
|
||||
const view = [...document.querySelectorAll('[data-org-dashboard]')].find(v => !v.closest('[data-pane]')); if (!view) return;
|
||||
const a = document.activeElement, key = view.contains(a) ? a.dataset?.focusKey || null : null;
|
||||
const swap = (selector, html) => { const el = view.querySelector(selector); if (el) el.outerHTML = html; };
|
||||
swap('[data-system-type-chips]', chips(org));
|
||||
swap('[data-system-types]', section(org));
|
||||
const value = view.querySelector('[data-org-setting="System types"] .v'); if (value) { value.textContent = summary(org); value.title = summary(org); }
|
||||
if (key) view.querySelector(`[data-focus-key="${CSS.escape(key)}"]`)?.focus({ preventScroll: true });
|
||||
}
|
||||
async function toggle(button) {
|
||||
const org = window.MosaicOrg?.id, s = org ? slot(org) : null, type = button.dataset.systemTypeToggle;
|
||||
if (!s?.value?.writable || s.pending !== null || typeof s.value.revision !== 'string') return;
|
||||
const on = s.value.enabled.includes(type);
|
||||
const enabled = on ? s.value.enabled.filter(id => id !== type) : [...s.value.enabled, type];
|
||||
s.pending = type; s.outcome = null; repaint(org);
|
||||
const result = await call(orgPath(org, 'system-types'), { revision: s.value.revision, enabled });
|
||||
s.pending = null;
|
||||
const label = labelOf(s.value, type);
|
||||
if (result.ok && typeof result.value.transaction === 'string') {
|
||||
s.outcome = `${esc(label)} ${on ? 'disabled' : 'enabled'}. Receipt <code>${esc(result.value.transaction)}</code>.`;
|
||||
} else s.outcome = esc(result.ok ? line('outcome-unknown') : line(result.code));
|
||||
await load(org);
|
||||
repaint(org);
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const button = e.target.closest?.('[data-system-type-toggle]');
|
||||
// A pending write marks the buttons aria-disabled, not disabled, so focus stays on the one pressed.
|
||||
if (button) { e.preventDefault(); if (button.getAttribute('aria-disabled') !== 'true' && !button.closest('[data-pane]')) toggle(button); return; }
|
||||
const open = e.target.closest?.('[data-system-types-open]');
|
||||
if (open) { e.preventDefault(); const h = (open.closest('[data-org-dashboard]') || document).querySelector('[data-system-types-heading]'); h?.scrollIntoView({ block: 'start' }); h?.focus({ preventScroll: true }); }
|
||||
});
|
||||
|
||||
// ---------- project information page ----------
|
||||
let active = null;
|
||||
document.addEventListener('mosaic-signed-out', () => { active?.dispose(); active = null; });
|
||||
// The chat view mounts again on every repaint and keeps the section; leaving the page marks it stale,
|
||||
// so coming back (say, after the dashboard changed the enabled set) reads again.
|
||||
window.addEventListener('hashchange', () => { if (active) active.stale = true; });
|
||||
function mount({ container, projectId, archived = false }) {
|
||||
if (!(container instanceof Element)) return;
|
||||
const org = window.MosaicOrg?.id ?? null;
|
||||
if (active && active.org === org && active.projectId === projectId) {
|
||||
container.replaceChildren(active.section);
|
||||
if (active.archived !== archived) { active.archived = archived; active.paint(); }
|
||||
if (active.stale) { active.stale = false; active.refresh(); }
|
||||
return;
|
||||
}
|
||||
active?.dispose();
|
||||
const section = document.createElement('section');
|
||||
section.className = 'project-types';
|
||||
section.setAttribute('data-project-system-types', '');
|
||||
container.replaceChildren(section);
|
||||
let value = null, code = null, outcome = '', saving = false, disposed = false, generation = 0;
|
||||
const state = { org, projectId, section, archived, stale: false, paint, refresh: () => refresh(), dispose: () => { disposed = true; generation++; section.replaceChildren(); } };
|
||||
active = state;
|
||||
function paint() {
|
||||
if (disposed) return;
|
||||
const head = '<h2 tabindex="-1">System types</h2>';
|
||||
const status = `<p role="status" tabindex="-1" data-project-system-types-status>${outcome}</p>`;
|
||||
if (!ID.test(projectId ?? '')) { section.innerHTML = `${head}<p>System types unavailable.</p>`; return; }
|
||||
if (!value) { section.innerHTML = `${head}<p>${esc(code ? `System types unavailable. ${line(code)}` : 'Reading system types…')}</p>${status}`; return; }
|
||||
const sel = value.project, enabled = value.installed.filter(m => value.enabled.includes(m.id));
|
||||
const inactive = sel.inactive.length
|
||||
? `<div data-project-system-types-inactive><p>Inactive, because the organization has disabled them:</p><ul>${sel.inactive.map(id => `<li data-inactive-type="${esc(id)}">${esc(labelOf(value, id))} <span class="project-types-tag">inactive</span></li>`).join('')}</ul>${value.writable && !state.archived ? '<p class="muted">Saving keeps only enabled types, so it removes these from the project.</p>' : ''}</div>`
|
||||
: '';
|
||||
const intro = '<p>This project picks from the system types its organization has enabled. None is required.</p>';
|
||||
const canWrite = value.writable && !state.archived;
|
||||
if (!enabled.length) {
|
||||
// With nothing enabled the only change left is clearing what the project still selects.
|
||||
const clear = canWrite && sel.inactive.length
|
||||
? `<form data-project-system-types-form><button type="submit" class="btn"${saving ? ' disabled' : ''}>${saving ? 'Saving…' : 'Clear the inactive types'}</button></form>` : '';
|
||||
section.innerHTML = `${head}${intro}<p>The organization has no system types enabled. The owner enables them on the organization dashboard.</p>${inactive}${clear}${status}`;
|
||||
return;
|
||||
}
|
||||
const boxes = enabled.map(m => `<label class="project-types-option"><input type="checkbox" name="system-type" value="${esc(m.id)}"${sel.active.includes(m.id) ? ' checked' : ''}${canWrite && !saving ? '' : ' disabled'}><span><strong>${esc(m.label)}</strong><small>${esc(m.description)}</small></span></label>`).join('');
|
||||
const why = state.archived ? '<p class="muted">This project is archived; its selection is shown read only.</p>' : value.writable ? '' : `<p class="muted">${esc(line('read-only'))}</p>`;
|
||||
section.innerHTML = `${head}${intro}<form data-project-system-types-form><fieldset><legend>Selected system types</legend>${boxes}</fieldset>${inactive}${canWrite ? `<button type="submit" class="btn"${saving ? ' disabled' : ''}>${saving ? 'Saving…' : 'Save system types'}</button>` : ''}</form>${why}${status}`;
|
||||
}
|
||||
async function refresh() {
|
||||
const turn = ++generation;
|
||||
const result = await read(org, projectId);
|
||||
if (disposed || turn !== generation) return;
|
||||
if (result.ok && valid(result.value) && result.value.project) { value = result.value; code = null; } else { value = null; code = result.ok ? 'outcome-unknown' : result.code; }
|
||||
paint();
|
||||
}
|
||||
section.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
if (saving || !value?.writable || state.archived) return;
|
||||
const chosen = [...section.querySelectorAll('input[name="system-type"]:checked')].map(i => i.value);
|
||||
saving = true; outcome = ''; paint();
|
||||
const result = await call(orgPath(org, `system-types/projects/${enc(projectId)}`), { systemTypes: chosen });
|
||||
if (disposed) return;
|
||||
saving = false;
|
||||
outcome = result.ok ? esc(result.value.selected.length ? `Saved. Selected: ${result.value.selected.map(id => labelOf(value, id)).join(', ')}.` : 'Saved. No system types selected.') : esc(line(result.code));
|
||||
await refresh();
|
||||
section.querySelector('[data-project-system-types-status]')?.focus({ preventScroll: true });
|
||||
});
|
||||
paint();
|
||||
refresh();
|
||||
}
|
||||
|
||||
window.MosaicSystemTypes = Object.freeze({ load, chips, summary, section, mount });
|
||||
})();
|
||||
@@ -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) ----------
|
||||
@@ -737,6 +747,6 @@
|
||||
|
||||
// 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(), home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
|
||||
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
|
||||
refresh();
|
||||
})();
|
||||
@@ -18,6 +18,7 @@ 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';
|
||||
@@ -60,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', 'tasks-board.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', 'tasks-board.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']],
|
||||
@@ -76,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']]),
|
||||
]);
|
||||
@@ -507,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.
|
||||
@@ -773,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.
|
||||
@@ -796,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);
|
||||
@@ -818,7 +826,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// `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 }))) {
|
||||
@@ -832,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
|
||||
@@ -902,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;
|
||||
@@ -914,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();
|
||||
@@ -928,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) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -959,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.
|
||||
@@ -1016,7 +1024,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
return projectRemoteHTTP(req, res, { path, read: requestRead, write: send ? requestWrite : null,
|
||||
session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin });
|
||||
}
|
||||
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
|
||||
// Phase P2 (#1873): the owner's System types read and writes on the selected organization.
|
||||
if (systemTypesPath) {
|
||||
if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' });
|
||||
return systemTypesHTTP(req, res, { path, read: requestRead, write: send ? systemTypesWrite : null,
|
||||
session: ssoSession, live: () => sso.session(req), origin: localOrigin ?? sso.publicOrigin });
|
||||
}
|
||||
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/system-types.js', '/system-types.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
|
||||
// Row 194 (#1692, D4.1): the tester also launches and terminates.
|
||||
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
|
||||
return json(res, 403, { error: 'read-only', sent: false });
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Phase P2 (#1873): the owner's System types routes on a selected organization. Owner Console
|
||||
// transports only; no caller-supplied business, actor or subject reaches the bus.
|
||||
//
|
||||
// GET /api/system-types installed modules (label, description) and the enabled set,
|
||||
// plus the plugin revision when writes are on
|
||||
// POST /api/system-types {revision, enabled}: plugins.set {'system-type': enabled},
|
||||
// the organization writer's owner fence, revision and receipt
|
||||
// GET /api/system-types/projects/<id> the same, with the project's selection, active and inactive
|
||||
// POST /api/system-types/projects/<id> {systemTypes}: project.system-types.set
|
||||
//
|
||||
// `read` is the owner's Console call (consoleCall): it reaches only `system-types.console`, which the
|
||||
// broker answers to the business human's console cap. `write` is the owner's human transport limited
|
||||
// to SYSTEM_TYPE_VERBS, present only for the owner's own session under --send.
|
||||
export const SYSTEM_TYPES_ROUTE = /^\/api\/system-types(?:\/projects\/([^/]+))?$/;
|
||||
export const SYSTEM_TYPE_VERBS = Object.freeze(['plugins', 'plugins.set', 'project.system-types.set']);
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
|
||||
// A module name, as plugins/src/kinds.mjs NAME.
|
||||
const NAME = /^[a-z][a-z0-9-]{0,63}$/;
|
||||
const REVISION = /^[a-f0-9]{64}$/;
|
||||
const TRANSACTION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const MAX_TYPES = 64;
|
||||
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
|
||||
const codes = {
|
||||
'invalid-request': 400, 'invalid-plugins': 400, 'unknown-plugin': 400, 'system-type-unknown': 400,
|
||||
'project-not-found': 404, 'revision-conflict': 409, 'organization-busy': 409, 'system-type-not-enabled': 409,
|
||||
'human-required': 403, 'organization-system-types-human-only': 403, 'read-only': 403,
|
||||
'not-configured': 503, 'system-type-invalid': 503, 'no-bus-host': 503,
|
||||
};
|
||||
function json(res, status, value) {
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
const names = list => Array.isArray(list) && list.length <= MAX_TYPES && list.every(x => typeof x === 'string' && NAME.test(x)) && new Set(list).size === list.length;
|
||||
const text = value => typeof value === 'string' && value.trim() !== '' && value.length <= 400 && !/[\r\n]/.test(value);
|
||||
function consoleShape(result, project) {
|
||||
if (!result || !Array.isArray(result.installed) || !names(result.enabled)) return null;
|
||||
const installed = [];
|
||||
for (const m of result.installed) {
|
||||
if (!m || !NAME.test(m.id ?? '') || !text(m.label) || !text(m.description)) return null;
|
||||
installed.push({ id: m.id, label: m.label, description: m.description });
|
||||
}
|
||||
if (new Set(installed.map(m => m.id)).size !== installed.length) return null;
|
||||
let selection = null;
|
||||
if (project !== null) {
|
||||
const p = result.project;
|
||||
if (!p || p.project !== project || !names(p.selected) || !names(p.active) || !names(p.inactive)) return null;
|
||||
selection = { project, selected: p.selected, active: p.active, inactive: p.inactive };
|
||||
} else if (result.project !== null) return null;
|
||||
return { installed, enabled: result.enabled, project: selection };
|
||||
}
|
||||
const selectionShape = (r, project) => r && r.project === project && names(r.selected) && names(r.active) && names(r.inactive)
|
||||
? { project, selected: r.selected, active: r.active, inactive: r.inactive } : null;
|
||||
|
||||
async function body(req, keys) {
|
||||
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return null;
|
||||
const chunks = []; let size = 0;
|
||||
for await (const chunk of req) { size += chunk.length; if (size > 4096) return 'too-large'; chunks.push(chunk); }
|
||||
let value;
|
||||
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return null; }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length || !keys.every(k => Object.hasOwn(value, k))) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function systemTypesHTTP(req, res, { path, read, write, session, live, origin }) {
|
||||
const refuse = (status, error) => json(res, status, { error });
|
||||
const fail = error => { const code = codes[error?.code] ? error.code : 'outcome-unknown'; return refuse(codes[code] ?? 503, code); };
|
||||
if (!['GET', 'POST'].includes(req.method)) return refuse(405, 'method-not-allowed');
|
||||
if (!session) return refuse(401, 'sign-in-required');
|
||||
if (!read) return refuse(503, 'not-configured');
|
||||
if (req.method === 'POST' && !write) return refuse(403, 'read-only');
|
||||
const site = one(req, 'sec-fetch-site'), from = one(req, 'origin');
|
||||
if (req.method === 'POST' ? from !== origin || site !== 'same-origin'
|
||||
: (req.headersDistinct.origin && from !== origin) || (req.headersDistinct['sec-fetch-site'] && !['same-origin', 'none'].includes(site)))
|
||||
return refuse(403, 'cross-origin');
|
||||
let project = null;
|
||||
const match = SYSTEM_TYPES_ROUTE.exec(path);
|
||||
if (match?.[1] !== undefined) {
|
||||
try { project = decodeURIComponent(match[1]); } catch { return refuse(404, 'project-not-found'); }
|
||||
if (!ID.test(project)) return refuse(404, 'project-not-found');
|
||||
}
|
||||
let args = null;
|
||||
if (req.method === 'POST') {
|
||||
const value = await body(req, project === null ? ['revision', 'enabled'] : ['systemTypes']);
|
||||
if (value === 'too-large') return refuse(413, 'invalid-request');
|
||||
if (!value) return refuse(400, 'invalid-request');
|
||||
if (project === null ? typeof value.revision !== 'string' || !REVISION.test(value.revision) || !names(value.enabled) : !names(value.systemTypes))
|
||||
return refuse(400, 'invalid-request');
|
||||
args = value;
|
||||
}
|
||||
if (live() !== session) return refuse(401, 'sign-in-required');
|
||||
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
|
||||
// The read comes first on every request: a write names only modules this organization has installed.
|
||||
let current;
|
||||
try { current = consoleShape(await read('system-types.console', project === null ? {} : { project }, { subject }), project); }
|
||||
catch (error) { return fail(error); }
|
||||
if (!current) return refuse(503, 'outcome-unknown');
|
||||
const installed = new Set(current.installed.map(m => m.id));
|
||||
|
||||
if (req.method === 'GET') {
|
||||
let revision = null;
|
||||
if (write && project === null) {
|
||||
// The enabled set and its revision come from one plugins read. The page sends back a whole
|
||||
// replacement list, so a list older than its revision would undo a change made in between.
|
||||
let plugins;
|
||||
try { plugins = await write('plugins', {}); } catch (error) { return fail(error); }
|
||||
revision = plugins?.revision ?? null;
|
||||
const enabled = plugins?.plugins?.['system-type'] ?? [];
|
||||
if (!REVISION.test(revision ?? '') || !names(enabled)) return refuse(503, 'outcome-unknown');
|
||||
current.enabled = enabled;
|
||||
}
|
||||
if (live() !== session) return refuse(401, 'sign-in-required');
|
||||
return json(res, 200, { ...current, writable: Boolean(write), ...(project === null && write ? { revision } : {}) });
|
||||
}
|
||||
|
||||
// The session is checked again after the read: a sign-out while it ran dispatches no write.
|
||||
if (live() !== session) return refuse(401, 'sign-in-required');
|
||||
|
||||
if (project === null) {
|
||||
if (args.enabled.some(id => !installed.has(id))) return refuse(400, 'system-type-unknown');
|
||||
let result;
|
||||
try { result = await write('plugins.set', { revision: args.revision, plugins: { 'system-type': args.enabled } }); }
|
||||
catch (error) { return fail(error); }
|
||||
// A write admitted at spawn may already have committed: its receipt is returned even if the
|
||||
// session ended meanwhile, never reported as not done.
|
||||
const enabled = result?.plugins?.['system-type'] ?? [];
|
||||
if (!result || !REVISION.test(result.revision ?? '') || !TRANSACTION.test(result.transaction ?? '') || !names(enabled)
|
||||
|| JSON.stringify(enabled) !== JSON.stringify(args.enabled))
|
||||
return refuse(503, 'outcome-unknown');
|
||||
return json(res, 200, { enabled, revision: result.revision, transaction: result.transaction });
|
||||
}
|
||||
|
||||
// The broker refuses a type the organization has not enabled (system-type-not-enabled); a type that
|
||||
// is not installed is refused here first.
|
||||
if (args.systemTypes.some(id => !installed.has(id))) return refuse(400, 'system-type-unknown');
|
||||
let result;
|
||||
try { result = selectionShape(await write('project.system-types.set', { project, systemTypes: args.systemTypes }), project); }
|
||||
catch (error) { return fail(error); }
|
||||
if (!result || JSON.stringify(result.selected) !== JSON.stringify(args.systemTypes)) return refuse(503, 'outcome-unknown');
|
||||
return json(res, 200, result);
|
||||
}
|
||||
@@ -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, and U4b adds tasks-board.js after workspace.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', '/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/);
|
||||
});
|
||||
@@ -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 (U3, workspace-pages-browser.test.mjs) and Tasks (U4b, tasks-board-browser.test.mjs) are built.
|
||||
for (const [route, title] of [['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 });
|
||||
|
||||
Reference in new issue
Block a user