Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f98f278fbb | ||
|
|
c6e341e92e | ||
|
|
50be333552 | ||
|
|
3bb0f70acc | ||
|
|
52a82a4e56 | ||
|
|
5f3706e807 | ||
|
|
5f57008aed | ||
|
|
bc3e0a9f49 | ||
|
|
a71da41146 | ||
|
|
4c74e7ea49 | ||
|
|
ac3a863bec | ||
|
|
af7d330101 | ||
|
|
db75994f82 | ||
|
|
ac977d833b |
No files matched your search
@@ -881,6 +881,58 @@ OIDC session or an account. The rule:
|
||||
never on a deployed one.
|
||||
- Dev mode keeps every CLI verb as it is.
|
||||
|
||||
### Host trust boundary
|
||||
|
||||
The host is the trust boundary. The instance uid can read and write the
|
||||
instance's config, data root, bus files and credentials. That makes the uid
|
||||
equivalent to root for this instance, though not root on the host. A CLI
|
||||
token cannot protect the instance from someone who already has that uid:
|
||||
they can read the token file, the bus database and every key, or change the
|
||||
files directly. A token could help a remote admin CLI that has no host
|
||||
access. That is a possible later feature, not part of Phase M.
|
||||
|
||||
Phase M narrows who is assigned the instance uid. In the current managed
|
||||
host setup, both Case 1 and Case 2 use `mosaic`: the host run sheet creates
|
||||
it as a system account with a non-login shell and no supplementary groups
|
||||
or sudo rule (`docs/guides/host-run-sheet.md:19-26`). Case 1 acceptance
|
||||
checks that the Console and bus run as the configured `--user`
|
||||
(`scripts/case1-accept.sh:102-116,457-469`). Case 2's unit renderer uses
|
||||
its `--user` for both services (`scripts/case2-unit.sh:136-145,514`); the
|
||||
host setup runs that path as `mosaic`
|
||||
(`docs/plans/2026-10-20_case2-rows.md:1197-1200`). These are today's
|
||||
configured identities, not an installer guarantee: `case2-unit.sh` accepts
|
||||
any existing non-root user with a matching home. The run sheet, rather than
|
||||
the renderer, selects the dedicated account.
|
||||
|
||||
There is also a separate Console path. `webui-service.sh` installs a
|
||||
systemd user unit, then asks the operator to start it with
|
||||
`systemctl --user` (`scripts/webui-service.sh:1-7,19,413-418`). That Console
|
||||
runs as the account whose user manager starts it, not as the managed
|
||||
`mosaic` service account. It is a single-user development path, not a safe
|
||||
accounts-mode deployment path. M3 follow-up 1 is to refuse a deployed or
|
||||
accounts-mode Console service through this user-unit path.
|
||||
|
||||
The stack's own managed agent sessions run as the instance uid inside a
|
||||
bubblewrap sandbox, not in containers (`scripts/case1-accept.sh:480-496`,
|
||||
`docs/plans/2026-10-20_case2-rows.md:1031-1034,1197-1199`). The sandbox
|
||||
limits their filesystem view. A sandbox escape has the same authority as
|
||||
holding the instance uid. This is different from a development agent seat:
|
||||
Phase M requires the instance uid to differ from every human login and
|
||||
development seat uid. A non-login shell prevents direct login, but root or a
|
||||
human with sudo or equivalent authority can still act as `mosaic`.
|
||||
|
||||
M2's accounts loader refuses a file with the wrong uid or group or other
|
||||
write permission. M2 does not currently specify the config-directory owner
|
||||
check for `mosaic accounts` or an OS-uid receipt. M3 pins both as integration
|
||||
expectations. The recorded uid identifies the process, not
|
||||
necessarily the person who invoked it. The receipt is writable by the
|
||||
instance uid and is not tamper-evident against that uid. Stamped builds and
|
||||
`.deployed` also refuse dev flags in M3.
|
||||
|
||||
The residual risk is accepted: host root, or the instance uid, owns the
|
||||
instance. Accounts protect users from one another through the Console and
|
||||
bus. They do not protect an instance from its host owner.
|
||||
|
||||
### Locks
|
||||
|
||||
Three locks exist after this design: the per-org lifecycle lock
|
||||
@@ -1295,6 +1347,7 @@ Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off.
|
||||
| Identity confusion across providers | Identity is `(provider, sub)` with the issuer from config. Email matches invites only. A subject belongs to one account. |
|
||||
| Local CLI acting outside the check | In accounts mode `mosaic org` and `decide --org` refuse. `mosaic accounts` runs as the instance uid, which can already read every bus file, and has no rights verbs. |
|
||||
| Tampering with accounts.json on disk | The file must belong to the instance uid and not be group or other writable. Anyone who can write it already runs as the instance user and can read every bus. That is outside this design, as it is for access.json. |
|
||||
| Host root or the instance uid bypassing accounts | Accepted residual risk: either can read and change the instance config, data root, bus files and credentials. An administrator who can become the instance uid has the same authority. An OS-uid receipt identifies the process, not the human, and the instance uid can change the receipt. |
|
||||
|
||||
Not covered: a compromised identity provider, a stolen session cookie
|
||||
before its 1 hour idle limit, and a compromised Console process, which holds
|
||||
@@ -1397,6 +1450,21 @@ Acceptance tests, against a fake OIDC provider on port 0:
|
||||
- `--dev-local-owner` from a checkout refuses with accounts.json present,
|
||||
with `console.sso`, and with `.deployed`.
|
||||
- `webui-service.sh render` and `case2-unit.sh` refuse a dev flag.
|
||||
- `case2-unit.sh` accepts the host run sheet's `mosaic` account fixture only
|
||||
when its shell is non-login, its primary group has no other members, and
|
||||
it owns the config directory and data root. Fixtures for a human login and
|
||||
a development seat with interactive shells are refused. Both rendered
|
||||
system units use that same uid.
|
||||
- `case1-accept.sh` F1 continues to verify that the running Console and bus
|
||||
processes have the configured instance uid.
|
||||
- M3 integration tests verify that `mosaic accounts` refuses when its
|
||||
effective uid does not own the config directory and that its receipt
|
||||
records the effective OS uid. The test documents that this is process
|
||||
identity, not human attribution or tamper evidence.
|
||||
- **M3 follow-up 1:** accounts mode refuses the `webui-service.sh` systemd
|
||||
user-unit path. Managed accounts instances start through the system units
|
||||
rendered by `case2-unit.sh`. Tests show the user-unit path cannot start a
|
||||
deployed accounts instance while development mode remains available.
|
||||
- The existing sso and dev-owner suites stay green.
|
||||
|
||||
Grant needed: `stack-copy.sh` writes a new file into every copy, and the
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
# Roles, rails and hiring
|
||||
|
||||
Date: 2026-11-10. Lane org-roles-design, issue #1873. Status: proposed, doc
|
||||
only. Nothing here changes `AGENTS.md`, `roles/`, `contracts/`, rights policy
|
||||
or code. The slices in section 12 that touch role or rights policy land only
|
||||
with Jason's approval.
|
||||
|
||||
Read with `docs/plans/2026-11-10_agent-levels-design.md` (levels, isolation,
|
||||
Phase A) and `docs/plans/2026-11-10_accounts-design.md` (rights, host trust
|
||||
boundary). This design replaces agent-levels section 7 and the A1 roles patch,
|
||||
which widened the chief's `hireable` list. That patch is withdrawn and nothing
|
||||
here extends it.
|
||||
|
||||
Line numbers are against `origin/refactor` at `50be33355`.
|
||||
|
||||
## 1. Rulings
|
||||
|
||||
Jason, 2026-11-10:
|
||||
|
||||
1. Who hires: the chief asks, a human approves. A hire is a decision, not a direct write.
|
||||
2. Which roles, verbatim: "Chief should be capable of hiring ANY role with any capacity. This will include custom roles invented by the user and the chief at any time."
|
||||
3. No cap on the number of hires.
|
||||
4. Timing: lands with A4.
|
||||
5. Templates, verbatim: "Mosaic Stack should ship role templates for the most common roles. These stay within the system as templates. Chief and user have the capability to import the roles from template and customize them as needed. New roles outside of the supplied templates must follow a set of rules from within the system to prevent an absolute destruction of the system's workings. We need a set of primitives available to the agent roles to allow for adherence. A new role constructed by chief and the user must plugin and follow the rules with the RBAC and other tools available. The agent will have an arbitrary set of tools specified by chief and/or the user. The agent will also have a personality and other traits specified by the user. The system must remain unopinionated upon the opinionated basal code provided. We provide the operational rails and allow an open system to build upon it."
|
||||
6. Ceiling: the approval is the ceiling. A hire may carry grants above the chief's own, but the decision lists those grants separately and flags them.
|
||||
7. Approver: a new grantable right `org.hire` (owner yes, member no) in the M1 rights table.
|
||||
8. Only the chief raises hires. No hire chains: a hired agent cannot hire.
|
||||
9. Rails, summarized (full text in the lead tracker; ask nothing, design from this): a base of inalienable rails the system needs to function, with everything else customizable on top. The smallest working system is user, assistant and chief; the chief staffs the rest from the user's plan. Start with the fewest agents, add agents as roles are needed, and keep configurations apart with organizations and projects. Full extensibility: modules and plugins, preconfigured systems for any use (software factory, writing workshop, video production, business, home, health), installed from GitHub, a zip or another source, and maybe a marketplace later. Flexibility comes from a set of primitives controlled by routing, agent configuration and plugins. Jason: "The Venn diagram for usability and security/rails needs to be understood."
|
||||
10. Credentials: the hire decision lists each credential and scope the agent needs; approving the decision binds them.
|
||||
11. Template updates: an imported role is a frozen copy. When the shipped template changes, the system offers a diff; it never changes the copy by itself.
|
||||
12. Personality: the chief may draft personality and traits inside the hire decision. After the hire only a human changes them.
|
||||
|
||||
Also binding, Jason 2026-11-07: the system is modules joined by documented
|
||||
calls. Swapping or disabling one must not break core, and adding one must not
|
||||
need a core rewrite (`docs/plans/2026-11-07_organizations-owner-rulings.md:43-47`).
|
||||
|
||||
## 2. Today
|
||||
|
||||
- A role is `roles/<name>.json` plus its `.md` contract. Version 2 allows
|
||||
only known keys, so an unknown field such as `personality` refuses
|
||||
(`packages/organization/src/role.mjs:21,190`). Tools come from one closed
|
||||
core list (`role.mjs:24-29`, `packages/organization/src/vocabulary.mjs:142`).
|
||||
Actions come from `ACTIONS` (`vocabulary.mjs:11-65`); an unknown one
|
||||
refuses, and so does one outside `ROUTED`, because those are hard waits the
|
||||
human always decides (`role.mjs:42-43`, `vocabulary.mjs:109`).
|
||||
- The chief hires by writing directly. `instance.hire` checks that the caller
|
||||
is the instance named in `launch.by` (`packages/bus/src/broker.mjs:6193`)
|
||||
and holds `role.hire`, refuses kept names (`broker.mjs:6200`), then runs
|
||||
`#hireable` (`broker.mjs:6291-6306`). That needs a version 2 definition whose
|
||||
every action is on `HIRE_ALLOWED` (`vocabulary.mjs:114-121`, checked by
|
||||
`unhireableAction` at `vocabulary.mjs:127`), named in the chief's own
|
||||
`hireable` list, and not in `launch.noHire`.
|
||||
- The shipped chief's list is empty (`roles/chief-of-staff.json:13`), so the
|
||||
chief can hire nothing today. Its contract says so
|
||||
(`roles/chief-of-staff.md:17`).
|
||||
- Creative definitions are human-only. The writer refuses them for the chief
|
||||
(`packages/organization/src/instance-settings.mjs:365-367`) and the role
|
||||
check refuses them in a `hireable` list (`role.mjs:86-97`).
|
||||
- A hire writes `roles[name] = { definition, vars? }` and appends the name to
|
||||
`launch.instances` (`instance-settings.mjs:354-391`). Its vars may set only
|
||||
`model` and `limits.*` (`broker.mjs:216-221`). So `launch` refuses the new
|
||||
instance with `no-route` until a human sets harness, model and thinking
|
||||
(`packages/harness/src/bundle.mjs:96`, `roles/chief-of-staff.md:53-58`).
|
||||
- Fire revokes the instance's service credentials and delists it
|
||||
(`instance-settings.mjs:396-414`).
|
||||
- Rights for people are a separate table: `RIGHTS`, the `ROLE_RIGHTS`
|
||||
bundles and `canGrant` (`packages/organization/src/org-rights.mjs:6-17,21-26,122-129`).
|
||||
It has no hire right yet.
|
||||
|
||||
## 3. The rails
|
||||
|
||||
A rail is a rule no role, template, plugin, approval or setting can change
|
||||
while the system runs. Only a reviewed commit changes one. Each row says where
|
||||
the code enforces it today and which slice closes the gap.
|
||||
|
||||
| # | Rail | Enforced today | Gap, and the slice that closes it |
|
||||
|---|---|---|---|
|
||||
| R1 | Secrets never enter the repository, images, role files, decision text or the trail. A credential reaches an agent only through a human-approved binding, scoped and revocable. | Message and record bodies pass `credentials.assertClean` (`packages/bus/src/runtime.mjs:833`, `broker.mjs:840,1313,2081`). Credential verbs are human-only (`broker.mjs:6144`). `credential.mint` decisions route to the human (`broker.mjs:2266-2268,2368`). Fire revokes (`instance-settings.mjs:396`). | Role definitions, personality text and hire decisions are not scanned. P1 runs the same check on every string in a composition and a hire decision. |
|
||||
| R2 | The trail and run records are write-once. | `events` and `decision_events` refuse update, delete and replace (`packages/bus/src/migrations/3b.sql:131-132,137-138,144,147`). | None. Hire, deny, expiry and binding entries use the same tables (A4). |
|
||||
| R3 | Fail closed: an unknown action, tool, right, plugin kind or role field refuses. | Role keys, actions and tools (`role.mjs:190,42,24-29`). Rights (`org-rights.mjs:122-129`). Plugin kinds (`packages/plugins/src/kinds.mjs:147-148`). | Inline roles and plugin-declared tools and actions have no check yet. P1 and PL1. |
|
||||
| R4 | Hard waits stay with the human. A role can never hold an action outside `ROUTED`. | `vocabulary.mjs:109`, `role.mjs:43`. This covers `git.push.protected`, `git.push.remote`, `host.sudo`, `credential.mint`, `spend`, `deploy` and `data.delete` (`vocabulary.mjs:22,26,29,49,50,51,55`). | Added: the brief's list did not name it, and "any capacity" must not dissolve it. P1 keeps it for every composition. |
|
||||
| R5 | Human-only acts: approving a hire, fire or role change (`org.hire`), binding a credential, changing personality after hire, installing or enabling a plugin, changing a rail, and resolving a merge or policy decision. No agent approves its own request, and no agent approves a request that changes authority. | No agent resolves its own arbitrated raise: it goes to the human (`broker.mjs:7075`). Plugin enabling needs a human session (`broker.mjs:6008`). Hard waits always go to the human (R4). | Corrected. The brief's "no agent approves another agent's request" is false today. A cross-role raise goes to the domain arbiter, the chief by default, and an agent of that role resolves it (`broker.mjs:7074,7212-7215`, `packages/organization/src/org-template.json:5`). PRD 1.5 REQ-DEC-2 put `git.merge.protected` and `policy.change` on that path (`vocabulary.mjs:69-70,83,93`), and merge-gate holds merge cross-role (`roles/merge-gate.json:10`, `broker.mjs:5198-5201`). This design keeps agent arbitration only for one-off routed work that grants no lasting authority: task, review, record and message actions. A4 routes every decision whose action is `role.hire`, `role.fire`, `credential.mint`, `git.merge.protected` or `policy.change` to the human, whatever its class. That reverses part of REQ-DEC-2, so it needs Jason's approval. The dev tester may enable some plugin kinds (`broker.mjs:6013`); that stays dev-only. Personality has no field yet (T1). |
|
||||
| R6 | Every agent runs sandboxed as the instance uid, with only the tools its role names. A tool exists only if core or an installed, enabled plugin declares it. | bwrap with user, pid and ipc namespaces and a read-only root (`packages/seat/src/sandbox.mjs:76,176`). Tools are a closed core list (`vocabulary.mjs:142`) and the typed bus tools are fixed in core (`packages/harness/src/tools.mjs:96`). | Corrected: no plugin declares a tool today. PL1 adds plugin-declared tools. A sandbox escape holds the instance uid, which equals root for the instance (accounts design, "Host trust boundary"). |
|
||||
| R7 | Organizations are isolated. Cross-organization reads go only through the assistant's Console read broker. | Each organization has its own bus and every non-assistant agent's scope is one organization (agent-levels design section 4). | The read broker is not built. It is A6. Until then nothing reads across organizations. |
|
||||
| R8 | No grant beyond the granter, except through a flagged line in a human-approved decision. | People: `canGrant` subset (`org-rights.mjs:122-129`). Agents: a hire is bounded by `HIRE_ALLOWED` and the chief's list (`broker.mjs:6303-6305`). | A4 replaces that bound with the approval itself. The broker computes the flagged lines; the chief cannot omit them. |
|
||||
| R9 | No hire chains. Only the chief raises hires, and a hired agent cannot hire. | `role-not-chief` (`broker.mjs:6193`). `role.hire` is not on `HIRE_ALLOWED` (`vocabulary.mjs:114-121`). | Added from ruling 8. P1 refuses `role.hire` and `role.fire` in any hired composition, so the rail survives `HIRE_ALLOWED` going away. |
|
||||
| R10 | Plugin code runs with the host's authority, so installing one is an instance-level human act. | The loader reads only its own tree and takes no path from config (`packages/plugins/src/loader.mjs:1-5`). | Added: the loader says a module "runs with the host's authority" (`loader.mjs:5`). Install from GitHub or a zip has to keep that visible. No hash pin exists. PL1. |
|
||||
| R11 | System config is written only by bootstrap. | `scripts/bootstrap.sh:5`, AGENTS.md invariant 2. | None. Nothing in this design writes system config. |
|
||||
|
||||
Dropped: none. Two items changed meaning. R5 now says "no agent approves its
|
||||
own request" and lists what always goes to a human, because the code routes
|
||||
cross-role work to an agent arbiter by design. R6 now says "core or a plugin"
|
||||
declares a tool, because core declares all of them today.
|
||||
|
||||
## 4. The primitives
|
||||
|
||||
A role is a composition of these parts and nothing else.
|
||||
|
||||
**Core set.** Shipped in `vocabulary.mjs`, versioned, changed only by reviewed
|
||||
commit:
|
||||
|
||||
- `tools`: core built-ins and `web` (`vocabulary.mjs:142`).
|
||||
- `network`: one of `NETWORKS`.
|
||||
- `authority.withinRole` and `authority.crossRole`: actions from `ROUTED`
|
||||
(`vocabulary.mjs:73-102`).
|
||||
- `credentials`: a known service with grantable scopes. Gitea `admin` and
|
||||
`all` are never granted (`GITEA_SCOPE_CATEGORIES`).
|
||||
- `messaging.sendTo` and `messaging.receiveFrom`.
|
||||
- `skills`.
|
||||
- `instructions`: the contract text, today the `.md` file.
|
||||
- `personality`: new. Bounded text fields (summary, traits, voice), no
|
||||
authority. Ruling 5 and ruling 12.
|
||||
|
||||
**Plugin-declared set.** From an enabled plugin's manifest (section 7):
|
||||
tools, actions and credential services. A plugin action is always routed. A
|
||||
plugin cannot declare a hard wait, cannot reuse a core name, and every name
|
||||
carries the plugin's prefix (`<plugin>.<name>`).
|
||||
|
||||
**The validator.** P1 adds `checkComposition(role, registry, chief)`. It
|
||||
replaces `HIRE_ALLOWED` and `unhireableAction` at hire time. It:
|
||||
|
||||
1. refuses any unknown key, as `role.mjs:190` does;
|
||||
2. refuses any action, tool, network, service or scope the registry lacks,
|
||||
where the registry is the core set plus the plugins enabled in that
|
||||
organization;
|
||||
3. refuses any action outside `ROUTED` or a plugin's routed set (R4);
|
||||
4. refuses `role.hire` and `role.fire` (R9);
|
||||
5. allows `git.merge.protected` and `policy.change` only in `crossRole`, so
|
||||
each use is its own decision that a human resolves (R5);
|
||||
6. refuses any string that fails `assertClean` (R1), and any field over its
|
||||
length bound;
|
||||
7. returns the flagged lines: every action, tool, network, credential scope
|
||||
and messaging target the chief's own resolved role does not hold (R8).
|
||||
|
||||
The validator accepts every composition that passes. There is no allowlist of
|
||||
roles and no list of hireable definitions.
|
||||
|
||||
**What "any role with any capacity" means.** The chief can propose any name,
|
||||
any mix of registered tools and routed actions, any credential scope a service
|
||||
grants, any personality, any number of agents. A human approves each one.
|
||||
|
||||
**What it cannot mean.** A hired agent cannot hold a hard wait, hire, approve
|
||||
a human decision, read another organization, name a tool nothing declares,
|
||||
carry a secret in its text, or run outside the sandbox. A new capability that
|
||||
needs new enforcement, for example a new kind of external write, needs a
|
||||
plugin that declares and enforces it. A name with no code behind it gives an
|
||||
agent nothing.
|
||||
|
||||
## 5. Templates and organization roles
|
||||
|
||||
**Templates.** The shipped files in `roles/` become templates. They stay
|
||||
there, versioned by reviewed commit. A template's identity is its name plus
|
||||
the sha256 of its JSON and contract together, so no new field goes into
|
||||
`roles/`. Plugins may ship templates too (section 7).
|
||||
|
||||
**Organization roles.** An organization's own definitions live in its file
|
||||
under a new `definitions` key, one entry per name:
|
||||
|
||||
```json
|
||||
"definitions": {
|
||||
"release-writer": {
|
||||
"rev": 3,
|
||||
"from": { "template": "coder", "sha256": "<64 hex>" },
|
||||
"role": { "tools": ["read", "edit"], "authority": { "withinRole": ["record.write"] } },
|
||||
"instructions": "...",
|
||||
"personality": { "summary": "...", "traits": ["..."], "voice": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
An instance names `definition: "org:release-writer"` instead of a template
|
||||
name. Project agents use the organization's definitions. Root agents keep
|
||||
their own `definitions` in `<configDir>/agents.json`.
|
||||
|
||||
- **Import** copies a template into `definitions` with `from` set. The copy is
|
||||
frozen: nothing changes it except an approved change.
|
||||
- **Customize** edits the copy. **Invent** writes a definition with no `from`.
|
||||
Both pass `checkComposition` before they are written.
|
||||
- **Who writes.** A human holding `org.hire` drafts or edits a definition in
|
||||
the Console. A draft no agent uses does nothing. A change to a definition a
|
||||
hired agent uses takes effect only through a change decision (section 6).
|
||||
The chief proposes a definition only inside a decision.
|
||||
- **Template changes.** When a template's current hash differs from a copy's
|
||||
`from.sha256`, the Console shows a diff and offers apply or keep. Apply is a
|
||||
change decision (section 6). Nothing applies by itself (ruling 11).
|
||||
- **Versioning.** `rev` counts up on every write. Every hire and change
|
||||
decision records the definition's `rev` and hash.
|
||||
- **A running agent.** Its sandbox, tools and contract are fixed at launch
|
||||
from the bundle. A change that narrows authority takes effect at the broker
|
||||
on the next call. Anything else takes effect at the next launch, and the
|
||||
Console shows the agent as running an older `rev`.
|
||||
- **Creative templates** are templates like the others. They still get no
|
||||
host grant (`broker.mjs:2415`).
|
||||
|
||||
## 6. Hire as a decision
|
||||
|
||||
One decision action covers hire, fire and change, at every level.
|
||||
|
||||
**Fields.** The chief supplies:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `op` | `hire`, `fire` or `change` |
|
||||
| `level` | `org`, `project` (with the project name) or `root` |
|
||||
| `name` | the agent's instance name, unused, not kept or retired |
|
||||
| `role` | `{ template, sha256 }`, `{ definition, rev }` or `{ inline }` |
|
||||
| `credentials` | each service and scope the agent needs |
|
||||
| `personality` | the chief's draft |
|
||||
| `route` | a proposed harness, model and thinking level |
|
||||
| `reason` | 1 to 500 characters |
|
||||
|
||||
The broker adds, and the chief cannot set or edit:
|
||||
|
||||
- `flagged`: the lines `checkComposition` returns above the chief's own role;
|
||||
- `credentialLines`: one line per service and scope;
|
||||
- `digest`: a hash over the whole decision, as the Console resolution path
|
||||
already checks (`broker.mjs:7200-7204`);
|
||||
- `expiresAt`: seven days.
|
||||
|
||||
**Raise.** Among agents, only the instance in `launch.by` may raise one, as
|
||||
today (`broker.mjs:6193`). The decision always routes to the human, whatever its
|
||||
class, so the arbiter path at `broker.mjs:7074` never applies. A hire needs no
|
||||
approval count and has no cap (ruling 3). Spend stays bounded by the
|
||||
organization's limits.
|
||||
|
||||
**Approve.** A human whose membership in that organization holds `org.hire`
|
||||
approves in the Console, against the current digest. Two limits in the MVP:
|
||||
|
||||
- a decision with any flagged line or any credential line needs the owner.
|
||||
This mirrors `decide` stopping short of credential mints (accounts design,
|
||||
section 3 "Rights"). A member with `org.hire` approves plain hires.
|
||||
- a root-level decision needs the instance owner, since the root file covers
|
||||
every organization (agent-levels section 7).
|
||||
|
||||
The human may change the route, narrow the role or drop credential lines
|
||||
before approving. Widening means a new decision.
|
||||
|
||||
**What approval writes.** One writer call, with the human as actor and the
|
||||
decision id in the context:
|
||||
|
||||
- org level: the organization writer, extended from `prepareHire`
|
||||
(`instance-settings.mjs:354`). It writes the definition, the instance with
|
||||
its route, and `launch.instances`;
|
||||
- project level: the A2 project-agent writer, behind the projects fence;
|
||||
- root level: the A2 root writer.
|
||||
|
||||
Credential bindings then go through the existing human-only credential path
|
||||
(`broker.mjs:6144`). Until every binding succeeds, `launch` refuses the agent
|
||||
with `credential-unbound` and names the missing one, as A3 does for unbound
|
||||
copies. Because approval sets the route, launch no longer refuses `no-route`
|
||||
for an approved hire.
|
||||
|
||||
**Deny, expire, withdraw.** Deny and expiry write nothing but the trail entry.
|
||||
The chief may withdraw its own decision (`broker.mjs:7205-7211`).
|
||||
|
||||
**Fire and change** are the same decision with `op` set. Fire revokes and
|
||||
delists as today (`instance-settings.mjs:396`).
|
||||
|
||||
**A person starting a hire.** A human holding `org.hire` may start a hire,
|
||||
fire or change in the Console. It is still a decision, with the same fields,
|
||||
broker-computed lines, digest and trail, raised and resolved in one step by
|
||||
that person. Flagged and credential lines still need the owner. I read ruling
|
||||
8 as limiting which agent raises hires, not as stopping a person from hiring.
|
||||
|
||||
**Trail.** Raise, each resolution, the writer receipt and each binding are
|
||||
events in the write-once tables (R2).
|
||||
|
||||
**What this replaces.** The direct `instance.hire` and `instance.fire` paths
|
||||
(`broker.mjs:6185-6280`) and the `hireable` check (`broker.mjs:6291-6306`) go
|
||||
away. The chief's `hire` tool (`packages/harness/src/tools.mjs:225-226`)
|
||||
raises the decision instead of writing. Agent-levels section 7 had three
|
||||
paths, one per level. Now there is one decision with a `level` field, so A4
|
||||
and A5 become one slice, with the root level waiting for A2's root writer.
|
||||
|
||||
## 7. Plugins and modules
|
||||
|
||||
**Today.** Plugin kinds are a closed core list (`kinds.mjs:4-5,22-105`). The
|
||||
loader reads only `packages/plugins/<kind>/<name>/index.mjs` in its own tree,
|
||||
checks symlinks, exports and interface version, and pins no hash
|
||||
(`loader.mjs:1-5,41-72`). A refused module keeps the kind's last good set
|
||||
(`loader.mjs:7-9`). Enabling is `plugins.set` under `org.setup`
|
||||
(`org-rights.mjs:74`), owner-only.
|
||||
|
||||
**Manifest.** Each plugin gets a `plugin.json`: name, version, kinds,
|
||||
interface versions, a sha256 over its files, and what it declares: tools
|
||||
(name, description, argument schema, whether it runs in the sandbox or on the
|
||||
host), routed actions, credential services, and role templates.
|
||||
|
||||
**Install.** Sources are a GitHub URL at a commit, a zip, or a local
|
||||
directory. The instance owner installs at the host with the expected hash.
|
||||
The install refuses a hash mismatch, a core name, a hard-wait action or an
|
||||
unknown kind. Installed plugins live read-only in an instance-level plugin
|
||||
root, listed with their hashes in a lock file. That reads a path outside the
|
||||
loader's own tree, which changes lead decision 143 (`loader.mjs:3-5`). Jason
|
||||
decides that in PL1.
|
||||
|
||||
**Pin and load.** Every load checks the hash in the lock file. A mismatch
|
||||
refuses the plugin and logs it. The loader's existing refusal handling keeps
|
||||
core running.
|
||||
|
||||
**Enable.** Per organization, by the owner, through `plugins.set`. Install is
|
||||
instance-wide because the code runs as the instance uid for every
|
||||
organization (R10).
|
||||
|
||||
**A disabled or missing plugin.** Core keeps running. An agent whose role
|
||||
names the plugin's tools or actions refuses at launch with
|
||||
`plugin-disabled` and the plugin's name. A running agent's calls to those
|
||||
tools refuse with the same code. Nothing is fired or rewritten.
|
||||
|
||||
**Preconfigured systems** such as a software factory or a writing workshop
|
||||
are plugins that ship templates, a system type and tools. Installing one hires
|
||||
nobody. The chief still raises a decision for each agent.
|
||||
|
||||
**Marketplace.** Out of scope. The design must not preclude one, so the
|
||||
manifest keeps the source URL, version and hash, and reserves a `signature`
|
||||
field. The install screen already lists every tool that runs on the host.
|
||||
|
||||
## 8. The minimal base
|
||||
|
||||
A new organization already starts with the human, a chief and an assistant
|
||||
(`org-template.json:4,6`). That matches ruling 9. A6 later moves the
|
||||
assistant to the root level (agent-levels section 9).
|
||||
|
||||
The chief reads the user's plan and raises one hire decision per agent it
|
||||
needs, each citing the plan in its reason. It starts with the fewest agents
|
||||
and adds one when a task needs a role nobody holds. Separate concerns go in
|
||||
separate organizations or projects.
|
||||
|
||||
Every other shipped organization role becomes a template only: coder, pm,
|
||||
reviewer, security-reviewer, merge-gate, cto, the five chiefs (executive,
|
||||
financial, information, information security, marketing) and the six creative
|
||||
roles (companion, continuity, fact-checker, ideas, proofreader, research).
|
||||
`researcher.json` is a version 1 worker role (`roles/researcher.json:2`), which
|
||||
a hire refuses (`broker.mjs:6303`), and `conductor-policy.json` is not a role.
|
||||
Neither changes.
|
||||
|
||||
## 9. Usability and security
|
||||
|
||||
Read this first. It says who can change what.
|
||||
|
||||
Everything in the first two columns is done by a person. No agent can do any
|
||||
of it.
|
||||
|
||||
| A person changes it directly | It needs an approved decision | Only a reviewed code change |
|
||||
|---|---|---|
|
||||
| Organizations and projects (owner) | Hiring, firing or changing an agent's role (`org.hire`) | The rails in section 3 |
|
||||
| Drafting a role copy, or inventing a role from known parts. A draft does nothing until a decision uses it (`org.hire`) | Any agent power above the chief's own, shown flagged (owner approves) | The core list of tools and actions |
|
||||
| An agent's model, limits and route (`org.hire`) | Giving an agent a password, key or token (owner approves) | The shipped role templates |
|
||||
| An agent's personality and traits after hire | Applying a template update to your copy | The rights table, including `org.hire` |
|
||||
| Enabling a plugin in an organization (owner) | Each merge or policy change an agent asks for | Plugin kinds |
|
||||
| Installing a plugin on the instance (instance owner, at the host) | Adding an agent at the root level (instance owner) | System config, which only bootstrap writes |
|
||||
|
||||
What never moves, whoever asks:
|
||||
|
||||
- An agent cannot push to a protected branch, push to a remote, deploy, spend,
|
||||
mint a credential, delete data or use sudo by itself. A person decides each
|
||||
of those every time.
|
||||
- An agent cannot hire. Only the chief asks, and a person says yes.
|
||||
- An agent cannot merge into a protected branch or change policy unless a
|
||||
person approves that one merge or change.
|
||||
- An agent cannot see another organization's work. The assistant's read
|
||||
across organizations is planned (A6) and not built yet.
|
||||
- A plugin runs with the same power as the Mosaic service itself. Install
|
||||
only code you trust.
|
||||
|
||||
## 10. Threat model
|
||||
|
||||
| Threat | What stops it | What is left |
|
||||
|---|---|---|
|
||||
| A prompt-injected chief proposes a hostile role | The broker computes the flagged and credential lines; the chief cannot hide them. Hard waits, hiring and unknown parts refuse (R4, R9, R3). A human approves against the digest. | A human can approve a hostile role. Flagged and credential lines need the owner. |
|
||||
| A custom role names an unknown tool | `checkComposition` refuses it at the decision and at the writer. Launch refuses it again (R3). | None. |
|
||||
| A plugin from an untrusted source | Instance owner only; hash pin; the install screen lists host-run tools. | Approved plugin code runs as the instance uid (R10). A pin does not make code safe. |
|
||||
| A credential scope wider than the task | Each scope is its own line; owner-only approval; the approver may drop lines; admin and all never granted. | The service's own scope granularity. |
|
||||
| An arbiter agent approves another agent's merge or policy change | A4 routes those decisions to the human whatever their class (R5). | Other routed work, such as task scope, still has an agent arbiter by design. |
|
||||
| Approval fatigue on flagged grants | Flagged lines shown apart from plain ones; credential and flagged decisions go to the owner; no batch approval; seven-day expiry. | A busy owner who approves without reading. A later slice could limit how many flagged decisions one approval screen holds. |
|
||||
| An imported template drifts from its source | The copy is frozen with its source hash. A changed template produces a diff offer, never a silent change. | An owner who keeps an old copy keeps its old behaviour. That is the ruling. |
|
||||
|
||||
## 11. Invariant 7 amendment
|
||||
|
||||
Proposed only. Jason approves it through a reviewed commit.
|
||||
|
||||
Replace AGENTS.md invariant 7 (`AGENTS.md:45-47`) with:
|
||||
|
||||
> 7. **Policy**: missions govern tasks; a task narrows, never widens. The
|
||||
> rails, the core vocabulary of tools and actions, and the shipped role
|
||||
> templates in `roles/` change only by reviewed commit. An organization's
|
||||
> own roles are copies or compositions of known parts. One takes effect
|
||||
> only through a decision a human with `org.hire` approves, which lists
|
||||
> every grant above the chief's own as a flagged line. Only the chief
|
||||
> raises hires, and a hired agent cannot hire.
|
||||
|
||||
Touched role-model lines:
|
||||
|
||||
- `AGENTS.md:107`: keep "policy/role expansion" as a protected act, and add
|
||||
"an approved hire decision is that authority for the one agent it names".
|
||||
- `AGENTS.md:209`: change to "`roles/`: shipped role templates and the
|
||||
conductor policy".
|
||||
|
||||
## 12. Slices
|
||||
|
||||
Phase A after A3, in order. A6 and A7 stay as agent-levels section 9 has
|
||||
them.
|
||||
|
||||
**R1. The `org.hire` right.** Add `'org.hire': flag(true)` to `RIGHTS` and a
|
||||
verb entry for the hire resolution in `VERB_RIGHTS`. The owner bundle picks it
|
||||
up from `Object.keys(RIGHTS)` (`org-rights.mjs:22`); member, collaborator and
|
||||
approver do not get it. Rights policy, so Jason's approval
|
||||
(accounts design, `Grant needed` at line 1391). Tests: the owner has it; the
|
||||
three templates lack it; `canGrant` lets an owner grant it to a member; an
|
||||
unknown right still refuses.
|
||||
|
||||
**P1. Primitives validator.** `checkComposition` and the registry, beside
|
||||
`role.mjs`. `HIRE_ALLOWED` stays until A4 switches over. It changes no role
|
||||
file but it defines authority policy, so Jason's approval. Tests: an unknown
|
||||
key, action, tool, service or scope refuses; each hard wait refuses;
|
||||
`role.hire` and `role.fire` refuse; a secret in instructions or personality
|
||||
refuses; `git.merge.protected` or `policy.change` in `withinRole` refuses;
|
||||
flagged lines match a hand-built expected set for coder against the
|
||||
shipped chief.
|
||||
|
||||
**T1. Organization definitions and templates.** The `definitions` key,
|
||||
`org:` references, import, the human edit verbs under `org.hire`, the
|
||||
personality fields, the template hash and the diff read. Tests: import pins
|
||||
the hash; editing a template file changes no copy; the diff read shows the
|
||||
change; an `org:` reference to a missing definition refuses at launch; an
|
||||
agent cannot call the edit verbs.
|
||||
|
||||
**A4. Hire as a decision, all levels.** The decision action, raise, Console
|
||||
approval, the writer calls, credential lines, expiry, fire and change, the
|
||||
Console path that raises and resolves in one step, human routing for
|
||||
`role.hire`, `role.fire`, `credential.mint`, `git.merge.protected` and
|
||||
`policy.change` decisions (R5), and removal of the direct path and
|
||||
`#hireable`. Org and project land first; root
|
||||
waits for A2's root writer. This edits `roles/chief-of-staff.json` and `.md`
|
||||
(drops `hireable`, rewords hire), the vocabulary and AGENTS.md invariant 7,
|
||||
and reverses part of PRD 1.5 REQ-DEC-2, so Jason's approval. Tests: a hired agent's raise refuses `role-not-chief`; a
|
||||
chief decision cannot carry its own `flagged`; a member with `org.hire`
|
||||
approving a credential or flagged decision refuses; a stale digest refuses;
|
||||
deny and expiry write nothing; approval writes the role, the route and the
|
||||
trail, and launch refuses `credential-unbound` until binding; fire revokes;
|
||||
the old `instance.hire` verb refuses; a cross-role `git.merge.protected` or
|
||||
`policy.change` decision routes to the human and an arbiter's resolve refuses
|
||||
`not-resolver`; a hire a person starts in the Console leaves a decision record
|
||||
with its flagged lines.
|
||||
|
||||
**PL1. Plugin manifest, install and declared tools.** Manifest, pinned
|
||||
install root, lock file, the registry merge, and `plugin-disabled` refusals.
|
||||
Changes lead decision 143 and the loader's trust rule, so Jason's approval.
|
||||
Tests: a hash mismatch refuses and core keeps running; a manifest naming a
|
||||
core tool or a hard wait refuses; disabling a plugin makes its agents refuse
|
||||
at launch with its name; a running agent's call to a disabled tool refuses.
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/accounts.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/accounts.mjs";
|
||||
@@ -1298,3 +1298,32 @@ shows selected, active and inactive ids. The owner replaces the selection with
|
||||
`mosaic project system-types-set PROJECT --types coding,montage --org ID`; an
|
||||
empty `--types ''` clears it. Disabled selections stay recorded but become
|
||||
inactive. Project settings changes are append-only and require human proof.
|
||||
|
||||
## Accounts
|
||||
|
||||
`mosaic accounts` administers the instance-level accounts store locally, as the
|
||||
effective uid owning the config directory. It requires a human shell, rejects `--tester`,
|
||||
and takes no business selector or bus connection. It has no organization rights
|
||||
verbs. Console sign-in, sessions and membership evaluation are later Phase M
|
||||
slices; these commands alone do not turn accounts mode on.
|
||||
|
||||
```sh
|
||||
mosaic accounts init --name Operator
|
||||
mosaic accounts list --json
|
||||
mosaic accounts user add --name Pat --subject pocket:verified-subject --revision 1
|
||||
mosaic accounts user disable u-0123456789ab --revision 2
|
||||
mosaic accounts user enable u-0123456789ab --revision 3
|
||||
mosaic accounts user signout u-0123456789ab --revision 4
|
||||
mosaic accounts user remove u-0123456789ab --revision 5
|
||||
```
|
||||
|
||||
Init creates one operator account containing every configured `console.sso`
|
||||
subject and refuses if accounts.json already exists. Add mints its id and defaults
|
||||
to instance role `user`; `--role operator` creates another operator. Writes require
|
||||
the file revision from list. Disable, enable and signout advance `authGen`, and
|
||||
the last active operator cannot be disabled or removed. Removal scans ownership
|
||||
while holding the registry then accounts locks, refuses if the account owns an
|
||||
organization (including archived organizations) or any registry entry lacks its
|
||||
owner metadata during creation, and removes its memberships in
|
||||
the same write. List reports rollback rows needing repair; its JSON omits invite
|
||||
bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets.
|
||||
@@ -0,0 +1,109 @@
|
||||
// Phase M2: the local, instance-uid account administration exception. No org
|
||||
// rights commands and no bus transport: account init must work before orgs exist.
|
||||
import { basename, join } from 'node:path';
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { accountsRepairs, assertAccountsOwner, loadAccounts, mintAccountId, withAccounts } from '../../organization/src/accounts.mjs';
|
||||
import { withRegistry, readRegistryLocked } from '../../organization/src/org-registry.mjs';
|
||||
import { readJsonFile, rejectUnknownKeys, requireString } from '../../organization/src/util.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const ACCOUNTS_USAGE = [
|
||||
'mosaic accounts init [--name NAME]',
|
||||
'mosaic accounts list [--json]',
|
||||
'mosaic accounts user add --name NAME --subject PROVIDER:SUB [--role operator|user] --revision N',
|
||||
'mosaic accounts user disable|enable|signout|remove ID --revision N',
|
||||
].join('\n');
|
||||
function parse(args) {
|
||||
const words = [], values = {}, flags = new Set();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (!arg.startsWith('--')) { words.push(arg); continue; }
|
||||
if (flags.has(arg) || Object.hasOwn(values, arg)) throw new CliError(`duplicate ${arg}`, 4);
|
||||
if (arg === '--tester') throw new CliError('refused: accounts takes the human shell, not --tester', 3);
|
||||
if (arg === '--json') flags.add(arg);
|
||||
else if (['--name', '--subject', '--role', '--revision'].includes(arg) && args[i + 1] && !args[i + 1].startsWith('--')) values[arg] = args[++i];
|
||||
else throw new CliError(ACCOUNTS_USAGE, 4);
|
||||
}
|
||||
const verb = words[0] === 'user' ? `user.${words[1]}` : words[0];
|
||||
const valid = ['init', 'list'].includes(verb) ? words.length === 1 : verb === 'user.add' ? words.length === 2 : ['user.disable', 'user.enable', 'user.signout', 'user.remove'].includes(verb) && words.length === 3;
|
||||
if (!valid) throw new CliError(ACCOUNTS_USAGE, 4);
|
||||
const allowed = verb === 'init' ? ['--name'] : verb === 'list' ? [] : verb === 'user.add' ? ['--name', '--subject', '--role', '--revision'] : ['--revision'];
|
||||
if (Object.keys(values).some(k => !allowed.includes(k)) || (flags.size && verb !== 'list')) throw new CliError(ACCOUNTS_USAGE, 4);
|
||||
const revision = verb === 'init' ? 0 : Number(values['--revision']);
|
||||
if (verb !== 'list' && verb !== 'init' && (!/^[1-9][0-9]*$/.test(values['--revision'] ?? '') || !Number.isSafeInteger(revision))) throw new CliError('--revision requires the revision from accounts list', 4);
|
||||
return { verb, values, flags, revision, id: words[2] };
|
||||
}
|
||||
function owns(user, owner, providers) {
|
||||
return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss);
|
||||
}
|
||||
function readOwner(root) {
|
||||
const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => {
|
||||
if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3);
|
||||
});
|
||||
rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner');
|
||||
for (const key of ['provider', 'iss', 'sub']) requireString(owner[key], `owner.${key}`, { max: 2048 });
|
||||
return owner;
|
||||
}
|
||||
export async function accountsCommand(args, io, deps) {
|
||||
refuseInsideAgent(io.env);
|
||||
try {
|
||||
const parsed = parse(args);
|
||||
const dir = deps.configDir ?? configDir(io.env);
|
||||
// This is deliberately before withRegistry as well as withAccounts.
|
||||
assertAccountsOwner(dir, { uid: deps.uid ?? process.geteuid() });
|
||||
const providers = deps.system.console?.sso?.providers ?? [];
|
||||
const ctx = { configDir: dir, instance: deps.instance ?? basename(dir).replace(/^mosaic-/, ''), providers };
|
||||
if (parsed.verb === 'list') {
|
||||
const doc = loadAccounts(join(dir, 'accounts.json'), ctx);
|
||||
const repairs = accountsRepairs(doc);
|
||||
// Invites are not administered here. Never expose bindings or token hashes.
|
||||
const listed = { ...doc, invites: doc.invites.map(({ tokenHash, bind, ...invite }) => invite), repairs };
|
||||
io.stdout.write(parsed.flags.has('--json') ? JSON.stringify(listed, null, 2) + '\n' : [`accounts revision ${doc.revision}`, ...doc.users.map(u => `${u.id} ${u.name} ${u.instanceRole} ${u.state} authGen ${u.authGen}`), ...repairs.map(r => `${r.kind} ${r.org} needs repair: ${[r.unknownRole, ...r.unknownRights].filter(Boolean).join(', ')}`)].join('\n') + '\n');
|
||||
return;
|
||||
}
|
||||
let added;
|
||||
const mutate = doc => {
|
||||
if (parsed.verb === 'init') {
|
||||
const subjects = providers.flatMap(p => (p.subjects ?? []).map(s => ({ provider: p.id, sub: s.sub })));
|
||||
if (!subjects.length) throw new CliError('refused: init requires console.sso subjects', 3);
|
||||
added = { id: mintAccountId(), name: parsed.values['--name'] ?? 'Operator', subjects, instanceRole: 'operator', state: 'active', authGen: 1, created: new Date().toISOString() };
|
||||
doc.users.push(added); return;
|
||||
}
|
||||
if (parsed.verb === 'user.add') {
|
||||
const value = parsed.values['--subject'] ?? '', colon = value.indexOf(':');
|
||||
if (!parsed.values['--name'] || colon < 1 || colon === value.length - 1) throw new CliError('user add needs --name and --subject PROVIDER:SUB', 4);
|
||||
added = { id: mintAccountId(), name: parsed.values['--name'], subjects: [{ provider: value.slice(0, colon), sub: value.slice(colon + 1) }], instanceRole: parsed.values['--role'] ?? 'user', state: 'active', authGen: 1, created: new Date().toISOString() };
|
||||
doc.users.push(added); return;
|
||||
}
|
||||
const u = doc.users.find(u => u.id === parsed.id);
|
||||
if (!u) throw new CliError('refused: account not found', 2);
|
||||
if (parsed.verb === 'user.remove') { doc.users = doc.users.filter(x => x !== u); doc.memberships = doc.memberships.filter(m => m.user !== u.id); }
|
||||
else if (parsed.verb === 'user.signout') u.authGen++;
|
||||
else { const state = parsed.verb === 'user.disable' ? 'disabled' : 'active'; if (u.state === state) throw new CliError('refused: account already has that state', 2); u.state = state; }
|
||||
};
|
||||
const opts = { revision: parsed.revision, verb: parsed.verb, init: parsed.verb === 'init' };
|
||||
let result;
|
||||
if (parsed.verb === 'user.remove') {
|
||||
result = await withRegistry(ctx, registry => withAccounts(ctx, { ...opts, registry }, async doc => {
|
||||
const user = doc.users.find(u => u.id === parsed.id);
|
||||
if (!user) throw new CliError('refused: account not found', 2);
|
||||
// Even disabled accounts keep ownership. A real create reserves before
|
||||
// writing owner.json, so any missing owner must block removal too.
|
||||
for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) {
|
||||
let owner;
|
||||
try { owner = readOwner(entry.root); }
|
||||
catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; }
|
||||
if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3);
|
||||
}
|
||||
mutate(doc);
|
||||
}), { write: false });
|
||||
} else result = await withAccounts(ctx, opts, mutate);
|
||||
io.stdout.write(`accounts ${parsed.verb.replace('.', ' ')}: ${added?.id ?? parsed.id ?? ''}, revision ${result.revision}\n`);
|
||||
} catch (e) {
|
||||
if (e instanceof CliError) throw e;
|
||||
const error = new CliError(`refused: ${e.code ? e.code + ': ' : ''}${e.message}${e.committed ? '; accounts write committed; durability or receipt step failed; inspect before retrying' : ''}`, 3);
|
||||
if (e.committed) error.committed = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
|
||||
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
|
||||
import { instancesCommand, orgInstancesCommand, ORG_INSTANCES_USAGE } from "./instances.mjs";
|
||||
import { accessCommand, accessContextFor } from "./access.mjs";
|
||||
import { accountsCommand, ACCOUNTS_USAGE } from "./accounts.mjs";
|
||||
import { createChannelCaller } from "../../plugins/src/channel.mjs";
|
||||
import { PluginError } from "../../plugins/src/kinds.mjs";
|
||||
import { CliError, CliExit } from "./errors.mjs";
|
||||
@@ -52,6 +53,7 @@ import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from
|
||||
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
|
||||
|
||||
export const USAGE = `usage:
|
||||
${ACCOUNTS_USAGE}
|
||||
${ORG_USAGE}
|
||||
${USAGE_USAGE}
|
||||
${LIMITS_USAGE}
|
||||
@@ -745,6 +747,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
now: deps.now,
|
||||
});
|
||||
}
|
||||
if (verb === 'accounts') return accountsCommand(rest, io, { system: deps.system ?? loadSystem({ env: io.env }), configDir: deps.accountsConfigDir });
|
||||
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
||||
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { accountsCommand } from '../src/accounts.mjs';
|
||||
import { main } from '../src/cli.mjs';
|
||||
import { withRegistry, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
const providers = [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }, { sub: 'other-owner' }] }, { id: 'second', issuer: 'https://second.example', subjects: [{ sub: 'owner' }] }];
|
||||
function setup(t) {
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(configDir, 0o700); t.after(() => rmSync(configDir, { recursive: true, force: true }));
|
||||
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } }; let output = '';
|
||||
const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
|
||||
return { configDir, deps, io, run: args => accountsCommand(args, io, deps), read: () => JSON.parse(readFileSync(join(configDir, 'accounts.json'))), output: () => output };
|
||||
}
|
||||
test('init makes exactly one operator from all configured subjects, then refuses rerun', async t => {
|
||||
const s = setup(t); await s.run(['init']); const d = s.read(); assert.equal(d.users.length, 1); assert.equal(d.users[0].instanceRole, 'operator'); assert.equal(d.users[0].subjects.length, 3); assert.match(d.users[0].id, /^u-[0-9a-f]{12}$/); assert.equal(d.revision, 1);
|
||||
await assert.rejects(s.run(['init']), /already exists/); await s.run(['list', '--json']); assert.match(s.output(), /authGen/);
|
||||
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'init'); assert.ok(!JSON.stringify(r).includes('other-owner'));
|
||||
});
|
||||
test('CLI add, disable, enable, signout and remove; last operator guard and stale revisions', async t => {
|
||||
const s = setup(t); await s.run(['init']); const op = s.read().users[0].id;
|
||||
await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat:sub', '--revision', '1']); const id = s.read().users[1].id;
|
||||
for (const [verb, revision, generation] of [['disable', 2, 2], ['enable', 3, 3], ['signout', 4, 4]]) { await s.run(['user', verb, id, '--revision', String(revision)]); assert.equal(s.read().users[1].authGen, generation); }
|
||||
await assert.rejects(s.run(['user', 'disable', op, '--revision', '5']), /active operator/);
|
||||
await assert.rejects(s.run(['user', 'signout', id, '--revision', '1']), /revision-conflict/);
|
||||
await s.run(['user', 'remove', id, '--revision', '5']); assert.equal(s.read().users.length, 1); assert.equal(s.read().revision, 6);
|
||||
assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').length, 6);
|
||||
});
|
||||
test('host uid refusal happens before any lock, even on list or remove', async t => {
|
||||
const s = setup(t); for (const args of [['list'], ['init'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
|
||||
await assert.rejects(accountsCommand(args, s.io, { ...s.deps, uid: process.getuid() + 1 }), /uid/);
|
||||
assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false); assert.equal(existsSync(join(s.configDir, 'org-runtime.lock')), false);
|
||||
}
|
||||
});
|
||||
test('remove holds registry before accounts, refuses ownership of active or archived orgs, including a preceding create', async t => {
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
||||
const root = join(s.configDir, 'acme'); mkdirSync(root, { mode: 0o700 });
|
||||
let release, entered; const ready = new Promise(r => entered = r), hold = new Promise(r => release = r);
|
||||
const create = withRegistry(s.deps, async registry => { entered(); await hold; registry.orgs.acme = { id: 'acme', root, state: 'archived', revision: 1, created: '2026-11-10T00:00:00Z' }; writeFileSync(join(root, 'owner.json'), JSON.stringify({ provider: 'pocket', iss: 'https://id.example', sub: 'pat' }), { mode: 0o600 }); });
|
||||
await ready; const removing = s.run(['user', 'remove', id, '--revision', '2']); release(); await create; await assert.rejects(removing, /user-owns-org/); assert.equal(s.read().revision, 2);
|
||||
});
|
||||
test('remove drops memberships atomically, and malformed ownership metadata refuses', async t => {
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const d = s.read(), id = d.users[1].id;
|
||||
d.memberships.push({ org: 'acme', incarnation: 'a'.repeat(32), user: id, role: 'member', rights: ['org.view'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, by: d.users[0].id, added: '2026-11-10T00:00:00Z', via: 'invite:abc' }); writeFileSync(join(s.configDir, 'accounts.json'), JSON.stringify(d));
|
||||
await s.run(['user', 'remove', id, '--revision', '2']); assert.deepEqual(s.read().memberships, []);
|
||||
const root = join(s.configDir, 'broken'); mkdirSync(root, { mode: 0o700 });
|
||||
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{}', { mode: 0o600 }); });
|
||||
await assert.rejects(s.run(['user', 'remove', s.read().users[0].id, '--revision', '3']), /owner/);
|
||||
});
|
||||
test('mosaic main wires accounts without a bus; tester, agent and unexpected args refuse', async t => {
|
||||
const s = setup(t); await main(['accounts', 'init'], s.io, { system: s.deps.system, accountsConfigDir: s.configDir }); assert.equal(s.read().users.length, 1);
|
||||
for (const args of [['list', '--tester'], ['list', '--subject', 'pocket:x'], ['user', 'add', 'chosen-id', '--name', 'Pat', '--subject', 'pocket:x', '--revision', '1'], ['list', '--json', '--json']]) await assert.rejects(s.run(args));
|
||||
await assert.rejects(accountsCommand(['list'], { ...s.io, env: { MOSAIC_RUN_ID: 'run' } }, s.deps), /human shell/);
|
||||
});
|
||||
|
||||
|
||||
test('review P2-2: real reservation wins before owner.json exists, and removal refuses', async t => {
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
||||
const dataRoot = join(s.configDir, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir: s.configDir, instance: 'test', dataRoot };
|
||||
const org = await reserveOrg(ctx, 'acme'); assert.equal(existsSync(join(org.root, 'owner.json')), false);
|
||||
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), /org-create-in-progress/); assert.equal(s.read().users.length, 2); assert.equal(s.read().revision, 2);
|
||||
});
|
||||
|
||||
|
||||
test('host boundary and receipt use effective uid, not real uid (current design clarification)', async t => {
|
||||
const s = setup(t), realUid = process.getuid, effectiveUid = process.geteuid();
|
||||
process.getuid = () => effectiveUid + 1;
|
||||
try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); }
|
||||
finally { process.getuid = realUid; }
|
||||
});
|
||||
@@ -7,8 +7,9 @@ stay as the old names: each module there re-exports the module of the same name
|
||||
here (`src/business.mjs` re-exports `src/organization-config.mjs`), and its
|
||||
`src/cli.mjs` runs this package's `src/cli.mjs`. Brief: `docs/plans/2026-10-04_slice-1.md`,
|
||||
row S1. Design: `agents/darkwing/work/slice1-data-model-2026-10-04.md` and its
|
||||
addenda A and B. The package reads files and refuses on any problem. It
|
||||
never writes, and it never opens a token file.
|
||||
addenda A and B. Configuration readers refuse on any problem. Runtime
|
||||
organization and accounts stores provide explicit locked writes; this package
|
||||
never opens a token file.
|
||||
|
||||
```sh
|
||||
scripts/mosaic org config validate mosaic-stack
|
||||
@@ -583,3 +584,44 @@ The optional `plugins.system-type` list enables installed system-type modules fo
|
||||
an organization. Missing means none. Unknown ids refuse validation. Changes
|
||||
are owner-only; project selections are separate bus settings and a selection
|
||||
whose type is disabled remains recorded as inactive.
|
||||
|
||||
## Instance accounts (Phase M2)
|
||||
|
||||
`src/accounts.mjs` validates `<configDir>/accounts.json` without a dependency or
|
||||
JSON schema engine, as `access.mjs` does. `checkAccounts(document, {providers})`
|
||||
and `loadAccounts(file, {providers})` return frozen documents. Providers are the
|
||||
configured `console.sso.providers` objects (including issuer). Reads refuse an
|
||||
unsafe config directory, unsafe file, unknown field or malformed row. Account
|
||||
ids may be organization humans. `accountsRepairs(document)` reports unknown
|
||||
roles and rights retained on read for rollback; these rows must grant nothing.
|
||||
M4 supplies the authorization evaluator; M2 alone grants no Console or bus access.
|
||||
|
||||
`withAccounts(ctx, {revision, verb, actor?, registry?, init?}, mutate)` takes
|
||||
`accounts.lock`, reloads, checks the expected file revision, awaits `mutate` on a
|
||||
copy, maintains `authGen` and membership `rev`, validates and atomically replaces
|
||||
0600 bytes. Init publishes with a no-clobber hard link; replacements use rename.
|
||||
Both fsync the config directory before the receipt is appended. `ctx` contains `configDir`, `instance`, and `providers`. Init requires
|
||||
revision 0 and an absent file. Other writes require an existing valid file. Writes
|
||||
refuse unknown rights/roles on new or changed rows. Unchanged rollback rows pass
|
||||
through exactly, so they cannot block account revocation or another row's repair. Role templates are never evaluated as authority by this store.
|
||||
|
||||
For a caller already holding accounts.lock,
|
||||
`withAccountsLock(ctx, handle => withAccountsLocked(handle, options, mutate))`
|
||||
reuses that live opaque handle. Never acquire an earlier lock from the callback.
|
||||
The config path must have no symlink ancestors, including the home directory.
|
||||
The order is lifecycle, registry, accounts. Registry-dependent writes pass the
|
||||
live document from `withRegistry`; `readRegistryLocked(registry, ctx?)` verifies
|
||||
the lock and rereads its persisted snapshot without relocking. A changed registry
|
||||
or an instance mismatch refuses before account publication. Callbacks cannot
|
||||
reuse a released handle, or run two writes at once through one handle.
|
||||
|
||||
Each committed write appends and fsyncs one receipt under the accounts lock in
|
||||
`accounts-receipts.jsonl`: effective OS uid, actor id, verb, old/new file revisions, changed
|
||||
account ids with changed field names, and before/after membership rights, scope
|
||||
and revisions. No account
|
||||
name, subject, invite binding, hash, raw token or cookie enters the receipt. The
|
||||
store preflights the receipt destination before replacing accounts. As with
|
||||
other two-file journals, a crash or IO failure between replacement and receipt
|
||||
append can leave committed state without its receipt; an observed append failure
|
||||
has `committed: true` and must be inspected before retrying. It never rolls back
|
||||
committed authority silently or fabricates a success receipt.
|
||||
@@ -0,0 +1,266 @@
|
||||
// Phase M2 (#1873): instance accounts, independent of Console sessions and bus authority.
|
||||
import { constants, lstatSync } from 'node:fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { dirname, join, parse, resolve } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { OrgError, readRegistryLocked } from './org-registry.mjs';
|
||||
import { readJsonFile, requireObject, rejectUnknownKeys, requireString, requirePositiveInt, requireId, requireDistinctList, deepFreeze, canonicalJson } from './util.mjs';
|
||||
import { RIGHTS, ROLE_RIGHTS, normalizeRights } from './org-rights.mjs';
|
||||
|
||||
const fail = (code, message = code) => { throw new OrgError(code, message); };
|
||||
const bad = message => fail('accounts-invalid', message);
|
||||
const USER_ID = /^u-[0-9a-f]{12}$/;
|
||||
const HEX32 = /^[0-9a-f]{32}$/;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const same = (a, b) => canonicalJson(a) === canonicalJson(b);
|
||||
const array = (x, at) => { if (!Array.isArray(x)) bad(`${at} must be an array`); return x; };
|
||||
const text = (x, at) => {
|
||||
requireString(x, at, { max: 255 });
|
||||
if (/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/.test(x)) bad(`${at} must not contain control or bidi formatting characters`);
|
||||
return x;
|
||||
};
|
||||
const keys = (x, allowed, at) => { requireObject(x, at); rejectUnknownKeys(x, allowed, at); };
|
||||
const accountId = (x, at) => { if (typeof x !== 'string' || !USER_ID.test(x)) bad(`${at} must be u- plus 12 lowercase hex characters`); };
|
||||
function time(x, at) {
|
||||
if (typeof x !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(x) || !Number.isFinite(Date.parse(x)) || new Date(x).toISOString().slice(0, 19) !== x.slice(0, 19)) bad(`${at} must be a real ISO time`);
|
||||
}
|
||||
function providerMap(context) {
|
||||
const providers = new Map();
|
||||
for (const p of array(context?.providers, 'accounts context.providers')) {
|
||||
text(p.id, 'provider.id');
|
||||
if (p.id === 'local-dev' || typeof p.issuer !== 'string' || !p.issuer.startsWith('https://')) bad('provider must have an https issuer and must not be local-dev');
|
||||
try { if (new URL(p.issuer).protocol !== 'https:') bad('invalid issuer'); } catch { bad('invalid issuer'); }
|
||||
if (providers.has(p.id)) bad('duplicate configured provider');
|
||||
providers.set(p.id, p);
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
function subject(s, at, providers) {
|
||||
keys(s, ['provider', 'sub'], at);
|
||||
if (s.provider === 'local-dev' || !providers.has(s.provider)) bad(`${at}.provider must be a configured provider, never local-dev`);
|
||||
text(s.sub, `${at}.sub`);
|
||||
}
|
||||
function scope(s, at) {
|
||||
keys(s, ['projects', 'instances', 'expires'], at);
|
||||
for (const key of ['projects', 'instances']) {
|
||||
const entries = requireDistinctList(s[key], `${at}.${key}`, x => {
|
||||
if (x === '*') return;
|
||||
if (key === 'projects') { if (typeof x !== 'string' || !UUID.test(x)) bad(`${at}.projects must contain UUIDs`); }
|
||||
else requireId(x, `${at}.instances`);
|
||||
}, { nonEmpty: true });
|
||||
if (entries.includes('*') && entries.length !== 1) bad(`${at}.${key} must use "*" alone`);
|
||||
}
|
||||
if (s.expires !== undefined) time(s.expires, `${at}.expires`);
|
||||
}
|
||||
function rowRights(row, at) {
|
||||
requireId(row.role, `${at}.role`);
|
||||
if (row.role === 'owner') bad(`${at}.role is not grantable`);
|
||||
const rights = requireDistinctList(row.rights, `${at}.rights`, r => {
|
||||
text(r, `${at}.rights`);
|
||||
if (r === 'owner' || (Object.hasOwn(RIGHTS, r) && !RIGHTS[r].grantable)) bad(`${at}.rights is not grantable`);
|
||||
if (!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)*$/.test(r)) bad(`${at}.rights contains an invalid name`);
|
||||
}, { nonEmpty: true });
|
||||
if (!rights.includes('org.view') || !same(rights, [...rights].sort())) bad(`${at}.rights must be sorted and contain org.view`);
|
||||
scope(row.scope, `${at}.scope`);
|
||||
requireId(row.org, `${at}.org`);
|
||||
if (typeof row.incarnation !== 'string' || !HEX32.test(row.incarnation)) bad(`${at}.incarnation must be 32 lowercase hex characters`);
|
||||
}
|
||||
|
||||
// Pure whole-file validation. Unknown, well-formed rights/roles survive a rollback;
|
||||
// accountsRepairs exposes them without changing the persisted schema.
|
||||
export function checkAccounts(document, context) {
|
||||
const providers = providerMap(context);
|
||||
keys(document, ['accountsVersion', 'revision', 'users', 'memberships', 'invites'], 'accounts file');
|
||||
if (document.accountsVersion !== 1) bad('accountsVersion must be 1');
|
||||
requirePositiveInt(document.revision, 'revision');
|
||||
const users = new Set(), subjects = new Set(); let operators = 0;
|
||||
array(document.users, 'users').forEach((u, i) => {
|
||||
const at = `users[${i}]`;
|
||||
keys(u, ['id', 'subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'], at);
|
||||
accountId(u.id, `${at}.id`); if (users.has(u.id)) bad('duplicate account id'); users.add(u.id);
|
||||
text(u.name, `${at}.name`);
|
||||
if (!['operator', 'user'].includes(u.instanceRole)) bad(`${at}.instanceRole must be operator or user`);
|
||||
if (!['active', 'disabled'].includes(u.state)) bad(`${at}.state must be active or disabled`);
|
||||
requirePositiveInt(u.authGen, `${at}.authGen`); time(u.created, `${at}.created`);
|
||||
if (u.instanceRole === 'operator' && u.state === 'active') operators++;
|
||||
array(u.subjects, `${at}.subjects`).forEach((s, j) => {
|
||||
subject(s, `${at}.subjects[${j}]`, providers); const key = JSON.stringify([s.provider, s.sub]);
|
||||
if (subjects.has(key)) bad('subject appears more than once in the file'); subjects.add(key);
|
||||
});
|
||||
if (!u.subjects.length) bad(`${at}.subjects must not be empty`);
|
||||
});
|
||||
if (!operators) bad('at least one active operator must exist');
|
||||
const memberships = new Set();
|
||||
array(document.memberships, 'memberships').forEach((m, i) => {
|
||||
const at = `memberships[${i}]`;
|
||||
keys(m, ['org', 'incarnation', 'user', 'role', 'rights', 'scope', 'rev', 'added', 'by', 'via'], at);
|
||||
rowRights(m, at); accountId(m.user, `${at}.user`); if (!users.has(m.user)) bad(`${at}.user must name an account`);
|
||||
accountId(m.by, `${at}.by`); text(m.via, `${at}.via`); time(m.added, `${at}.added`); requirePositiveInt(m.rev, `${at}.rev`);
|
||||
const key = JSON.stringify([m.org, m.incarnation, m.user]); if (memberships.has(key)) bad('duplicate membership'); memberships.add(key);
|
||||
});
|
||||
const invites = new Set(), hashes = new Set();
|
||||
array(document.invites, 'invites').forEach((invite, i) => {
|
||||
const at = `invites[${i}]`;
|
||||
keys(invite, ['id', 'org', 'incarnation', 'role', 'rights', 'scope', 'bind', 'tokenHash', 'by', 'created', 'expires', 'state', 'user', 'used'], at);
|
||||
rowRights(invite, at); text(invite.id, `${at}.id`); if (!UUID.test(invite.id)) bad(`${at}.id must be a UUID`);
|
||||
if (invites.has(invite.id)) bad('duplicate invite id'); invites.add(invite.id);
|
||||
if (typeof invite.tokenHash !== 'string' || !/^[0-9a-f]{64}$/.test(invite.tokenHash) || hashes.has(invite.tokenHash)) bad(`${at}.tokenHash must be a unique sha256 hex hash`); hashes.add(invite.tokenHash);
|
||||
accountId(invite.by, `${at}.by`); time(invite.created, `${at}.created`); time(invite.expires, `${at}.expires`);
|
||||
if (Date.parse(invite.expires) <= Date.parse(invite.created)) bad(`${at}.expires must follow created`);
|
||||
const ttl = Date.parse(invite.expires) - Date.parse(invite.created);
|
||||
if (ttl < 3600000 || ttl > 168 * 3600000) bad(`${at} validity must be between 1 and 168 hours`);
|
||||
if (!['open', 'used', 'revoked', 'declined', 'expired'].includes(invite.state)) bad(`${at}.state is invalid`);
|
||||
requireObject(invite.bind, `${at}.bind`);
|
||||
if (Object.hasOwn(invite.bind, 'email')) { keys(invite.bind, ['email'], `${at}.bind`); text(invite.bind.email, `${at}.bind.email`); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(invite.bind.email)) bad(`${at}.bind.email must be one address`); }
|
||||
else subject(invite.bind, `${at}.bind`, providers);
|
||||
if (invite.state === 'used') { accountId(invite.user, `${at}.user`); time(invite.used, `${at}.used`); }
|
||||
else if (invite.user !== undefined || invite.used !== undefined) bad(`${at} unused invite has usage fields`);
|
||||
});
|
||||
return deepFreeze(structuredClone(document));
|
||||
}
|
||||
export function accountsRepairs(doc) {
|
||||
const result = [];
|
||||
for (const [kind, rows] of [['membership', doc.memberships], ['invite', doc.invites]]) for (const row of rows) {
|
||||
const unknownRole = Object.hasOwn(ROLE_RIGHTS, row.role) ? undefined : row.role;
|
||||
const unknownRights = row.rights.filter(r => !Object.hasOwn(RIGHTS, r));
|
||||
if (unknownRole || unknownRights.length) result.push({ kind, org: row.org, ...(kind === 'membership' ? { user: row.user } : { id: row.id }), ...(unknownRole ? { unknownRole } : {}), unknownRights });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check the host boundary before any read or lock. Every ancestor must be a real
|
||||
// directory; configDir itself belongs to this uid and is not publicly writable.
|
||||
export function assertAccountsOwner(configDir, { uid = process.geteuid() } = {}) {
|
||||
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('accounts-unsafe', 'config directory must be an absolute normalized path');
|
||||
let current = parse(configDir).root;
|
||||
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
|
||||
current = join(current, part); const st = lstatSync(current);
|
||||
if (!st.isDirectory() || st.isSymbolicLink()) fail('accounts-unsafe', 'config path contains a symlink or non-directory');
|
||||
}
|
||||
const st = lstatSync(configDir);
|
||||
if (st.uid !== uid) fail('accounts-unsafe', `config directory must belong to uid ${uid}: ${configDir}`);
|
||||
if (st.mode & 0o022) fail('accounts-unsafe', 'config directory must not be writable by group or other');
|
||||
}
|
||||
export function loadAccounts(file, context, { uid = process.geteuid() } = {}) {
|
||||
assertAccountsOwner(dirname(file), { uid });
|
||||
const doc = readJsonFile(file, 'accounts file', st => {
|
||||
if (st.uid !== uid) fail('accounts-unsafe', `accounts file must belong to uid ${uid}: ${file}`);
|
||||
if (st.mode & 0o022) fail('accounts-unsafe', 'accounts file must not be writable by group or other');
|
||||
});
|
||||
return checkAccounts(doc, context);
|
||||
}
|
||||
export const mintAccountId = () => `u-${randomBytes(6).toString('hex')}`;
|
||||
const heldAccounts = new WeakMap();
|
||||
|
||||
// Low-level helper for callers already holding the earlier lifecycle/registry
|
||||
// locks. A handle is valid only within this callback and serializes all writes.
|
||||
export async function withAccountsLock(ctx, fn) {
|
||||
assertAccountsOwner(ctx.configDir);
|
||||
const lock = join(ctx.configDir, 'accounts.lock'); let fd;
|
||||
for (let n = 0; n < 200; n++) {
|
||||
try { fd = await fs.open(lock, 'wx', 0o600); break; }
|
||||
catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); }
|
||||
}
|
||||
if (!fd) fail('accounts-locked', 'accounts lock is held; inspect it before retrying');
|
||||
const handle = Object.freeze({}); heldAccounts.set(handle, { ctx, writing: false });
|
||||
try { return await fn(handle); }
|
||||
finally { heldAccounts.delete(handle); await fd.close(); await fs.unlink(lock); }
|
||||
}
|
||||
export async function withAccounts(ctx, options, fn) {
|
||||
if (options.registry) await readRegistryLocked(options.registry, ctx);
|
||||
return withAccountsLock(ctx, handle => withAccountsLocked(handle, options, fn));
|
||||
}
|
||||
const VERBS = new Set(['init', 'user.add', 'user.disable', 'user.enable', 'user.signout', 'user.remove', 'user.update', 'membership.add', 'membership.rights', 'membership.remove', 'membership.leave', 'invite.create', 'invite.accept', 'invite.decline', 'invite.revoke', 'org.delete', 'orphans.sweep']);
|
||||
function generations(next, before, verb) {
|
||||
for (const u of next.users) {
|
||||
const old = before?.users.find(x => x.id === u.id);
|
||||
if (!old) { u.authGen = 1; continue; }
|
||||
const changed = u.state !== old.state || u.instanceRole !== old.instanceRole || !same(u.subjects, old.subjects);
|
||||
if (changed) u.authGen = old.authGen + 1;
|
||||
else if (u.authGen !== old.authGen && !(verb === 'user.signout' && u.authGen === old.authGen + 1)) bad('authGen may only advance one generation for signout or an authentication change');
|
||||
if (u.created !== old.created) bad('account created time is immutable');
|
||||
}
|
||||
for (const m of next.memberships) {
|
||||
const old = before?.memberships.find(x => x.org === m.org && x.incarnation === m.incarnation && x.user === m.user);
|
||||
// A rollback row grants nothing, but must not block unrelated account
|
||||
// revocation or the repair of another row. Preserve unchanged rows exactly.
|
||||
if (old && same(m, old)) continue;
|
||||
if (!Object.hasOwn(ROLE_RIGHTS, m.role) || m.role === 'owner') fail('unknown-role', 'unknown or ungrantable membership role');
|
||||
m.rights = [...normalizeRights(m.rights)];
|
||||
m.rev = old ? old.rev + (!same(old.rights, m.rights) || !same(old.scope, m.scope) ? 1 : 0) : 1;
|
||||
}
|
||||
for (const i of next.invites) {
|
||||
const old = before?.invites.find(x => x.id === i.id);
|
||||
if (old && same(i, old)) continue;
|
||||
if (!Object.hasOwn(ROLE_RIGHTS, i.role) || i.role === 'owner') fail('unknown-role', 'unknown or ungrantable invite role');
|
||||
i.rights = [...normalizeRights(i.rights)];
|
||||
}
|
||||
}
|
||||
// Receipt construction has no free-form text: no names, subjects, bindings,
|
||||
// tokens, hashes, caller-supplied objects, or cookies can enter the log.
|
||||
function receipt(before, after, { verb, actor }, at) {
|
||||
if (!VERBS.has(verb)) bad('unsupported receipt verb');
|
||||
if (actor !== undefined && actor !== 'human') accountId(actor, 'receipt actor');
|
||||
const membership = m => ({ org: m.org, incarnation: m.incarnation, user: m.user, role: Object.hasOwn(ROLE_RIGHTS, m.role) ? m.role : 'unknown', rights: m.rights, scope: m.scope, rev: m.rev });
|
||||
return { at, uid: process.geteuid(), actor: actor ?? 'human', verb, revisionBefore: before?.revision ?? 0, revisionAfter: after.revision,
|
||||
users: after.users.filter(u => !before || !same(u, before.users.find(x => x.id === u.id))).map(u => u.id),
|
||||
userChanges: after.users.flatMap(u => {
|
||||
const old = before?.users.find(x => x.id === u.id);
|
||||
const changed = ['subjects', 'name', 'instanceRole', 'state', 'authGen', 'created'].filter(key => !old || !same(u[key], old[key]));
|
||||
return changed.length ? [{ id: u.id, changed }] : [];
|
||||
}),
|
||||
removedUsers: (before?.users ?? []).filter(u => !after.users.some(x => x.id === u.id)).map(u => u.id),
|
||||
memberships: { before: (before?.memberships ?? []).map(membership), after: after.memberships.map(membership) } };
|
||||
}
|
||||
async function syncDirectory(dir) {
|
||||
const fd = await fs.open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
try { await fd.sync(); } finally { await fd.close(); }
|
||||
}
|
||||
|
||||
// Re-read under the lock, compare expected revision, validate and atomically
|
||||
// replace 0600 bytes, then append and fsync exactly one receipt while still held.
|
||||
// A post-rename receipt failure reports committed=true: retrying is not safe.
|
||||
export async function withAccountsLocked(handle, options, fn) {
|
||||
const held = heldAccounts.get(handle);
|
||||
if (!held || held.writing) fail('accounts-locked', 'a live, unused accounts lock handle is required');
|
||||
held.writing = true;
|
||||
const { ctx } = held; const file = join(ctx.configDir, 'accounts.json');
|
||||
let temp, receiptFd, committed = false;
|
||||
try {
|
||||
if (options.registry) await readRegistryLocked(options.registry, ctx);
|
||||
assertAccountsOwner(ctx.configDir);
|
||||
let before;
|
||||
if (options.init) {
|
||||
try { await fs.lstat(file); fail('accounts-exists', 'accounts.json already exists'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
if (options.revision !== 0) fail('revision-conflict');
|
||||
} else before = loadAccounts(file, ctx);
|
||||
if (!Number.isSafeInteger(options.revision) || options.revision !== (before?.revision ?? 0)) fail('revision-conflict', `revision-conflict: accounts revision is ${before?.revision ?? 0}`);
|
||||
const next = before ? structuredClone(before) : { accountsVersion: 1, revision: 0, users: [], memberships: [], invites: [] };
|
||||
await fn(next);
|
||||
generations(next, before, options.verb); next.revision = (before?.revision ?? 0) + 1;
|
||||
const checked = checkAccounts(next, ctx);
|
||||
const line = receipt(before, checked, options, new Date().toISOString());
|
||||
// Refuse unsafe receipt destinations before publishing the account change.
|
||||
receiptFd = await fs.open(join(ctx.configDir, 'accounts-receipts.jsonl'), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
||||
const st = await receiptFd.stat();
|
||||
if (!st.isFile() || st.uid !== process.geteuid() || (st.mode & 0o022) || st.nlink !== 1) fail('accounts-unsafe', 'unsafe accounts receipt file');
|
||||
temp = join(ctx.configDir, `.accounts-${randomBytes(12).toString('hex')}`);
|
||||
const fd = await fs.open(temp, 'wx', 0o600);
|
||||
try { await fd.writeFile(JSON.stringify(checked, null, 2) + '\n'); await fd.sync(); } finally { await fd.close(); }
|
||||
await options.beforeReplace?.();
|
||||
if (options.registry) await readRegistryLocked(options.registry, ctx);
|
||||
// Defend against a non-cooperating writer even though cooperative writers
|
||||
// already serialize through accounts.lock.
|
||||
if (before) { if (!same(loadAccounts(file, ctx), before)) fail('revision-conflict', 'accounts changed before replace'); }
|
||||
// Init must never replace a target, including one created by a writer that
|
||||
// ignores accounts.lock. link publishes the complete inode or fails EEXIST.
|
||||
if (before) await fs.rename(temp, file);
|
||||
else await fs.link(temp, file);
|
||||
committed = true;
|
||||
if (!before) await fs.unlink(temp);
|
||||
await (options.syncDirectory ?? syncDirectory)(ctx.configDir);
|
||||
await receiptFd.writeFile(JSON.stringify(line) + '\n'); await receiptFd.sync();
|
||||
return checked;
|
||||
} catch (e) { if (committed) e.committed = true; throw e; }
|
||||
finally { if (temp) await fs.rm(temp, { force: true }); if (receiptFd) await receiptFd.close(); held.writing = false; }
|
||||
}
|
||||
@@ -36,14 +36,29 @@ export async function readRegistry({ configDir }) {
|
||||
return validateRegistry(doc, file);
|
||||
}
|
||||
export async function listOrgs(ctx) { const doc = await readRegistry(ctx); return doc ? Object.values(doc.orgs) : []; }
|
||||
const heldRegistries = new WeakMap();
|
||||
// Only a live withRegistry document may be reused by a later accounts-lock
|
||||
// holder. Recheck the persisted snapshot without reacquiring this lock.
|
||||
export async function readRegistryLocked(registry, ctx) {
|
||||
const held = heldRegistries.get(registry);
|
||||
if (!held) fail('org-locked', 'a live registry lock document is required');
|
||||
if (ctx && (ctx.configDir !== held.ctx.configDir || ctx.instance !== held.ctx.instance)) fail('org-mismatch', 'registry lock belongs to another instance');
|
||||
const current = await readRegistry(held.ctx);
|
||||
if (JSON.stringify(current) !== held.persisted) fail('registry-changed', 'registry-changed before accounts replace');
|
||||
return validateRegistry(registry, join(held.ctx.configDir, 'org-runtime.json'));
|
||||
}
|
||||
export async function withRegistry(ctx, fn, { write = true } = {}) {
|
||||
await safeDir(ctx.configDir); await privateDir(ctx.configDir); const lock = join(ctx.configDir, 'org-runtime.lock'); let fd;
|
||||
for (let n = 0; n < 200; n++) { try { fd = await fs.open(lock, 'wx', 0o600); break; } catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); } }
|
||||
if (!fd) fail('org-locked', 'registry lock is held', lock);
|
||||
try {
|
||||
const registry = await readRegistry(ctx) ?? { version: 1, instance: ctx.instance, orgs: {} };
|
||||
const persisted = await readRegistry(ctx);
|
||||
const registry = persisted ?? { version: 1, instance: ctx.instance, orgs: {} };
|
||||
if (registry.instance !== ctx.instance) fail('org-mismatch', 'registry instance mismatch');
|
||||
const result = await fn(registry);
|
||||
heldRegistries.set(registry, { ctx, persisted: JSON.stringify(persisted) });
|
||||
let result;
|
||||
try { result = await fn(registry); }
|
||||
finally { heldRegistries.delete(registry); }
|
||||
if (write) {
|
||||
const file = join(ctx.configDir, 'org-runtime.json'); const temp = join(ctx.configDir, `.org-runtime-${randomBytes(12).toString('hex')}`);
|
||||
try { await fs.writeFile(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); await fs.rename(temp, file); } finally { await fs.rm(temp, { force: true }); }
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { checkAccounts, loadAccounts, accountsRepairs, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs';
|
||||
import { templateRender } from '../src/org-service.mjs';
|
||||
import { loadBusiness } from '../src/organization-config.mjs';
|
||||
import { REPO_ROLES } from './helpers.mjs';
|
||||
import { withRegistry, readRegistryLocked } from '../src/org-registry.mjs';
|
||||
|
||||
export const OP = 'u-000000000001', USER = 'u-000000000002';
|
||||
export const context = { providers: [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }] }] };
|
||||
export const user = (id = OP, sub = 'owner', instanceRole = 'operator') => ({ id, subjects: [{ provider: 'pocket', sub }], name: 'Person', instanceRole, state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' });
|
||||
export const member = () => ({ org: 'acme', incarnation: 'a'.repeat(32), user: USER, role: 'member', rights: ['board.read', 'org.view', 'thread.read'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, added: '2026-11-10T00:00:00Z', by: OP, via: 'invite:abc' });
|
||||
export const document = () => ({ accountsVersion: 1, revision: 1, users: [user(), user(USER, 'member', 'user')], memberships: [], invites: [] });
|
||||
function setup(t, doc = document()) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(dir, 0o700); t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const file = join(dir, 'accounts.json'); writeFileSync(file, JSON.stringify(doc), { mode: 0o600 });
|
||||
return { configDir: dir, instance: 'test', ...context, file };
|
||||
}
|
||||
const write = (ctx, revision, fn, rest = {}) => withAccounts(ctx, { revision, verb: 'user.signout', ...rest }, fn);
|
||||
|
||||
test('loader refuses every malformed document as a whole, including nested unknown keys', t => {
|
||||
const cases = [d => d.accountsVersion = 2, d => d.revision = 0, d => d.cookie = 'secret', d => d.users[0].id = 'human', d => d.users[0].authGen = 0, d => d.users[0].authGen = 1.5, d => d.users[0].instanceRole = 'owner', d => d.users[0].state = 'disabled', d => d.users[0].subjects[0].provider = 'local-dev', d => d.users[0].subjects[0].provider = 'missing', d => d.users[1].subjects = d.users[0].subjects, d => d.users[0].subjects[0].iss = 'https://evil', d => d.users[0].created = '2026-02-30T00:00:00Z', d => d.memberships.push({}), d => { const m = member(); m.rights.push('org.setup'); d.memberships.push(m); }, d => { const m = member(); m.rights.reverse(); d.memberships.push(m); }, d => { const m = member(); m.scope.instances = ['*', 'coder']; d.memberships.push(m); }, d => { const m = member(); m.user = 'u-000000000003'; d.memberships.push(m); }];
|
||||
for (const mutate of cases) { const d = document(); mutate(d); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
|
||||
assert.throws(() => checkAccounts(document(), { providers: [{ id: 'pocket', issuer: 'local-dev' }] }));
|
||||
const s = setup(t); assert.throws(() => loadAccounts(s.file, s, { uid: process.getuid() + 1 }), /uid/);
|
||||
for (const mode of [0o620, 0o602]) { chmodSync(s.file, mode); assert.throws(() => loadAccounts(s.file, s), /writable/); }
|
||||
chmodSync(s.file, 0o600); const target = join(s.configDir, 'real'); writeFileSync(target, JSON.stringify(document()), { mode: 0o600 }); unlinkSync(s.file); symlinkSync(target, s.file); assert.throws(() => loadAccounts(s.file, s), /symbolic/);
|
||||
});
|
||||
|
||||
test('unknown role and right load as repair rows; account may be a business human', () => {
|
||||
const d = document(); const m = member(); m.role = 'future'; m.rights.push('zz.future'); d.memberships.push(m);
|
||||
const loaded = checkAccounts(d, { ...context, businesses: { acme: { human: OP } } });
|
||||
assert.equal(loaded.users[0].id, OP); assert.ok(Object.isFrozen(loaded.users[0]));
|
||||
assert.deepEqual(accountsRepairs(loaded), [{ kind: 'membership', org: 'acme', user: USER, unknownRole: 'future', unknownRights: ['zz.future'] }]);
|
||||
});
|
||||
|
||||
test('writes refuse stale revision, unknown role/right and reserved rights without a receipt', async t => {
|
||||
const s = setup(t); const before = readFileSync(s.file, 'utf8');
|
||||
await assert.rejects(write(s, 9, () => {}), /revision-conflict/);
|
||||
for (const change of [m => m.role = 'future', m => m.rights.push('zz.future'), m => m.rights.push('org.setup')]) {
|
||||
await assert.rejects(write(s, 1, d => { const m = member(); change(m); d.memberships.push(m); }));
|
||||
}
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(existsSync(join(s.configDir, 'accounts-receipts.jsonl')), false);
|
||||
});
|
||||
|
||||
test('rights/scope revisions and security generations are maintained by the writer', async t => {
|
||||
const d = document(); d.memberships.push(member()); const s = setup(t, d);
|
||||
let rev = 1;
|
||||
for (const change of [u => u.state = 'disabled', u => u.state = 'active', u => u.instanceRole = 'operator', u => u.subjects[0].sub = 'new-sub']) {
|
||||
const result = await write(s, rev++, next => change(next.users[1])); assert.equal(result.users[1].authGen, rev);
|
||||
}
|
||||
let result = await write(s, rev++, next => next.users[1].authGen++); assert.equal(result.users[1].authGen, rev);
|
||||
result = await write(s, rev++, next => next.memberships[0].rights.push('task.write')); assert.equal(result.memberships[0].rev, 2);
|
||||
result = await write(s, rev++, next => next.memberships[0].scope.instances = ['coder']); assert.equal(result.memberships[0].rev, 3);
|
||||
assert.equal(result.users[1].authGen, 6);
|
||||
const lines = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.equal(lines.length, 7); for (const r of lines) { assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'user.signout'); }
|
||||
assert.equal(statSync(s.file).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
test('concurrent writes serialize and the later stale write changes nothing', async t => {
|
||||
const s = setup(t); let release, entered; const ready = new Promise(r => entered = r); const wait = new Promise(r => release = r);
|
||||
const first = write(s, 1, async d => { entered(); await wait; d.users[1].authGen++; }); await ready;
|
||||
const second = write(s, 1, d => { d.users[1].authGen++; }); release();
|
||||
const results = await Promise.allSettled([first, second]); assert.equal(results[0].status, 'fulfilled'); assert.equal(results[1].status, 'rejected'); assert.match(results[1].reason.message, /revision-conflict/);
|
||||
assert.equal(loadAccounts(s.file, s).revision, 2);
|
||||
});
|
||||
|
||||
test('locked helpers require live handles; registry dependency is rechecked before replace', async t => {
|
||||
const s = setup(t); let held;
|
||||
await assert.rejects(readRegistryLocked({}), /lock/);
|
||||
await assert.rejects(withAccountsLocked({}, { revision: 1, verb: 'user.signout' }, () => {}), /lock/);
|
||||
await withRegistry(s, async registry => {
|
||||
assert.equal(await readRegistryLocked(registry), registry);
|
||||
await withAccountsLock(s, async handle => { held = handle; await withAccountsLocked(handle, { revision: 1, verb: 'user.signout', registry }, d => d.users[1].authGen++); });
|
||||
});
|
||||
await assert.rejects(withAccountsLocked(held, { revision: 2, verb: 'user.signout' }, () => {}), /lock/);
|
||||
const before = readFileSync(s.file, 'utf8');
|
||||
await withRegistry(s, async registry => {
|
||||
await assert.rejects(write(s, 2, d => { d.users[1].authGen++; writeFileSync(join(s.configDir, 'org-runtime.json'), JSON.stringify({ version: 1, instance: 'test', orgs: { changed: { id: 'changed', root: '/elsewhere', state: 'active', revision: 1, created: 'now' } } }), { mode: 0o600 }); }, { registry }), /registry-changed/);
|
||||
}, { write: false });
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before);
|
||||
});
|
||||
|
||||
test('failure before replace leaves old bytes, no receipt line and no temporary file', async t => {
|
||||
const s = setup(t); const before = readFileSync(s.file, 'utf8');
|
||||
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { throw new Error('injected failure'); } }), /injected failure/);
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before); assert.deepEqual(readdirSync(s.configDir).filter(f => f.startsWith('.accounts-')), []); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
|
||||
});
|
||||
|
||||
test('receipts never serialize subject secrets, tokens, cookies, names or arbitrary actor text', async t => {
|
||||
const s = setup(t); const secret = 'subject-secret';
|
||||
await write(s, 1, d => { d.users[1].subjects[0].sub = secret; d.users[1].name = 'cookie-secret'; }, { actor: OP });
|
||||
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(secret)); assert.ok(!raw.includes('cookie-secret'));
|
||||
await assert.rejects(write(s, 2, () => {}, { verb: 'token-secret' }));
|
||||
await assert.rejects(write(s, 2, () => {}, { actor: 'cookie-secret' }));
|
||||
});
|
||||
|
||||
test('malformed membership rows refuse all of the file; reserved rights never load', () => {
|
||||
const mutations = [m => m.role = 'owner', m => m.rev = 0, m => m.incarnation = 'old', m => m.rights = ['thread.read'], m => m.rights = ['org.view', 'org.view'], m => m.scope = { projects: [], instances: ['*'] }, m => m.scope.projects = ['not-a-uuid'], m => m.scope.expires = 'not-a-time', m => m.extra = true, m => m.by = 'human', m => m.via = '', m => m.added = 'bad'];
|
||||
for (const mutate of mutations) { const d = document(), m = member(); mutate(m); d.memberships.push(m); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
|
||||
for (const right of ['members.manage', 'org.setup', 'org.lifecycle', 'request.write', 'owner']) { const d = document(), m = member(); m.rights = ['org.view', right].sort(); d.memberships.push(m); assert.throws(() => checkAccounts(d, context)); }
|
||||
});
|
||||
function invitation() {
|
||||
const { user: ignored, rev, added, via, ...row } = member();
|
||||
return { ...row, id: '11111111-2222-3333-4444-555555555555', bind: { email: '[email protected]' }, tokenHash: 'b'.repeat(64), created: '2026-11-10T00:00:00Z', expires: '2026-11-13T00:00:00Z', state: 'open' };
|
||||
}
|
||||
test('invite schema refuses raw secrets, domain/bearer bindings, malformed hashes and unknown fields', () => {
|
||||
const d = document(); d.invites.push(invitation()); assert.equal(checkAccounts(d, context).invites.length, 1);
|
||||
for (const mutate of [i => i.token = 'raw-token', i => i.cookie = 'raw-cookie', i => i.bind = { domain: 'example.com' }, i => i.bind = {}, i => i.bind = { email: '@example.com' }, i => i.bind.sub = 'extra-binding', i => i.tokenHash = 'raw-token', i => i.state = 'other', i => i.rights = ['org.setup', 'org.view'], i => i.scope.level = 'collaborator']) {
|
||||
const next = structuredClone(d); mutate(next.invites[0]); assert.throws(() => checkAccounts(next, context));
|
||||
}
|
||||
d.invites[0].role = 'future'; d.invites[0].rights.push('zz.future'); assert.equal(accountsRepairs(checkAccounts(d, context))[0].kind, 'invite');
|
||||
});
|
||||
test('unsafe receipt destination refuses before publication and leaves its target intact', async t => {
|
||||
const s = setup(t), target = join(s.configDir, 'target'); writeFileSync(target, 'untouched', { mode: 0o600 }); symlinkSync(target, join(s.configDir, 'accounts-receipts.jsonl'));
|
||||
const before = readFileSync(s.file, 'utf8'); await assert.rejects(write(s, 1, d => d.users[1].authGen++)); assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(target, 'utf8'), 'untouched');
|
||||
});
|
||||
test('account lock allows sequential writes and registry handles cannot cross instance boundaries', async t => {
|
||||
const s = setup(t), other = setup(t);
|
||||
await withAccountsLock(s, async handle => {
|
||||
await withAccountsLocked(handle, { revision: 1, verb: 'user.signout' }, d => d.users[1].authGen++);
|
||||
await withAccountsLocked(handle, { revision: 2, verb: 'user.signout' }, d => d.users[1].authGen++);
|
||||
});
|
||||
await withRegistry(other, async registry => { await assert.rejects(write(s, 3, () => {}, { registry }), /another instance/); });
|
||||
assert.equal(loadAccounts(s.file, s).revision, 3);
|
||||
});
|
||||
test('invite token hashes and subject bindings never reach receipts', async t => {
|
||||
const s = setup(t); const invite = invitation(); invite.bind = { provider: 'pocket', sub: 'subject-secret' };
|
||||
await write(s, 1, d => d.invites.push(invite), { verb: 'invite.create' });
|
||||
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(invite.tokenHash)); assert.ok(!raw.includes('subject-secret')); assert.equal(JSON.parse(raw).verb, 'invite.create');
|
||||
});
|
||||
|
||||
test('a failed rename preserves published bytes and appends no receipt', async t => {
|
||||
const s = setup(t), before = readFileSync(s.file, 'utf8');
|
||||
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { const temp = readdirSync(s.configDir).find(f => f.startsWith('.accounts-')); unlinkSync(join(s.configDir, temp)); } }), { code: 'ENOENT' });
|
||||
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), ''); assert.ok(!existsSync(join(s.configDir, 'accounts.lock')));
|
||||
});
|
||||
|
||||
|
||||
test('review P2-1: rollback rows survive unrelated disable/signout but cannot be added or edited', async t => {
|
||||
const d = document(), m = member(), invite = invitation(); m.role = 'future'; m.rights.push('zz.future'); invite.rights.push('zz.future'); d.memberships.push(m); d.invites.push(invite); const s = setup(t, d);
|
||||
const disabled = await write(s, 1, next => next.users[1].state = 'disabled', { verb: 'user.disable' });
|
||||
assert.deepEqual(disabled.memberships, d.memberships); assert.deepEqual(disabled.invites, d.invites);
|
||||
const signedOut = await write(s, 2, next => next.users[0].authGen++); assert.deepEqual(signedOut.memberships, d.memberships); assert.equal(signedOut.users[0].authGen, 2);
|
||||
await assert.rejects(write(s, 3, next => next.memberships[0].scope.instances = ['coder']), /unknown/);
|
||||
await assert.rejects(write(s, 3, next => next.invites[0].state = 'revoked'), /unknown/);
|
||||
const repaired = await write(s, 3, next => { next.memberships[0].role = 'member'; next.memberships[0].rights = ['org.view']; }); assert.equal(repaired.memberships[0].rev, 2); assert.deepEqual(repaired.invites, d.invites);
|
||||
});
|
||||
test('review P3-1: control and bidi characters refuse in names, subjects and provenance', () => {
|
||||
for (const control of ['\x1b', '\x07', '\r', '\n', '\x7f', '\u0085', '\u202e', '\u2066']) for (const field of ['name', 'sub', 'via']) {
|
||||
const d = document(); d.memberships.push(member()); if (field === 'via') d.memberships[0].via += control; else if (field === 'sub') d.users[1].subjects[0].sub += control; else d.users[1].name += control;
|
||||
assert.throws(() => checkAccounts(d, context));
|
||||
}
|
||||
});
|
||||
test('review P3-2: invite validity is between one and 168 hours inclusive', () => {
|
||||
for (const hours of [0.5, 169, 87600]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.throws(() => checkAccounts(d, context)); }
|
||||
for (const hours of [1, 168]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.equal(checkAccounts(d, context).invites.length, 1); }
|
||||
});
|
||||
test('review P3-3: receipts record actual changed field names even for a misleading verb', async t => {
|
||||
const s = setup(t); await write(s, 1, d => d.users[1].instanceRole = 'operator');
|
||||
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.deepEqual(r.userChanges, [{ id: USER, changed: ['instanceRole', 'authGen'] }]);
|
||||
});
|
||||
test('review P3-4: directory durability failure reports committed state and appends no success receipt', async t => {
|
||||
const s = setup(t); let error;
|
||||
try { await write(s, 1, d => d.users[1].authGen++, { syncDirectory: async () => { throw new Error('injected directory sync failure'); } }); } catch (e) { error = e; }
|
||||
assert.match(error?.message ?? '', /injected directory sync failure/); assert.equal(error.committed, true); assert.equal(loadAccounts(s.file, s).revision, 2); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
|
||||
});
|
||||
test('review P3-5: init cannot clobber a file created after its last absence check', async t => {
|
||||
const s = setup(t); unlinkSync(s.file); const intruder = 'concurrent file';
|
||||
await assert.rejects(withAccounts(s, { revision: 0, verb: 'init', init: true, beforeReplace: () => writeFileSync(s.file, intruder, { mode: 0o600 }) }, d => d.users.push(user())), { code: 'EEXIST' });
|
||||
assert.equal(readFileSync(s.file, 'utf8'), intruder); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
|
||||
});
|
||||
test('review P3-6: a real business file and accounts file load with the same human id', async t => {
|
||||
const s = setup(t), doc = await templateRender('acme'); doc.human = OP; mkdirSync(join(s.configDir, 'businesses')); writeFileSync(join(s.configDir, 'businesses', 'acme.json'), JSON.stringify(doc), { mode: 0o600 });
|
||||
assert.equal(loadBusiness('acme', { dir: s.configDir, rolesDir: REPO_ROLES }).human, OP); assert.equal(loadAccounts(s.file, s).users[0].id, OP);
|
||||
});
|
||||
@@ -12,6 +12,9 @@ export const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.
|
||||
export const PROXY_VERBS = Object.freeze(['proxy.accounts', 'proxy.login.start', 'proxy.login.submit',
|
||||
'proxy.login.status', 'proxy.login.cancel', 'proxy.apikey.add', 'proxy.priority.set', 'proxy.group.models.set', 'proxy.keys', 'plugins', 'plugins.set']);
|
||||
export const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request'];
|
||||
// U4b (#1873): the task board's writes, the person's built-in store verbs (broker tasks.*). The
|
||||
// caller refuses any other, so the board's taskWrite never reaches unarchive or delete.
|
||||
export const TASK_WRITE_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive']);
|
||||
export const agentCaller = (call, verbs) => async (verb, args) => {
|
||||
if (!verbs.includes(verb)) throw new Error(`the Console does not call ${verb}`);
|
||||
return call(verb, args);
|
||||
@@ -36,13 +39,14 @@ export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
|
||||
projectFiles: args => call('project.files', args),
|
||||
projectFile: args => call('project.file', args),
|
||||
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
|
||||
send: null, librarySync: null, libraryRevert: null, librarySave: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
|
||||
send: null, librarySync: null, libraryRevert: null, librarySave: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null, taskWrite: null,
|
||||
};
|
||||
if (sendOn) {
|
||||
o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.libraryRevert = args => call('library.revert', args); o.settingsWrite = args => call('limits.set', args);
|
||||
o.librarySave = args => call('library.file.save', args);
|
||||
o.projectSave = args => call('project.file.save', args); o.nameWrite = args => call('name.set', args); o.assistantStop = args => call('assistant.stop', args);
|
||||
o.instancesWrite = args => call('instances.set', args); o.routesWrite = args => call('routes.set', args); o.agentWrite = agentCaller(call, AGENT_VERBS);
|
||||
o.taskWrite = agentCaller(call, TASK_WRITE_VERBS);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
@@ -1442,12 +1442,6 @@
|
||||
// ---------------------------------------------------------------- router
|
||||
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
|
||||
const routeOf = () => window.MosaicOrg?.route() ?? (location.hash.startsWith('#/') ? location.hash.slice(1) : '/');
|
||||
// A one-line scope note right under a view's page heading.
|
||||
function afterHead(html, note) {
|
||||
const start = html.indexOf('<div class="page-head">'), end = start < 0 ? -1 : html.indexOf('</div></div>', start);
|
||||
const line = `<p class="small muted ws-scope" data-not-built>${esc(note)}</p>`;
|
||||
return end < 0 ? `${line}${html}` : `${html.slice(0, end + 12)}${line}${html.slice(end + 12)}`;
|
||||
}
|
||||
function view(get, path, q, opts, current) {
|
||||
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
|
||||
if (parts.includes(null)) return notFound();
|
||||
@@ -1455,9 +1449,8 @@
|
||||
if (n === 0 && window.MosaicOrg) return window.MosaicWorkspace.home();
|
||||
if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q);
|
||||
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
|
||||
if (a === 'side-by-side' && n === 1 && window.MosaicOrg) return `${head('Side by side', 'Not built yet.')}${empty('Side by side is not built yet.', 'It arrives with a later unified workspace update. Projects and Agents work today.')}`;
|
||||
if (a === 'inbox' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.inboxPage(q);
|
||||
if (a === 'tasks' && n === 1 && window.MosaicOrg) return tasksView(get).then(html => afterHead(html, 'Tasks across all organizations are not built yet. These are the selected organization\'s tasks.'));
|
||||
if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view();
|
||||
if (a === 'inbox' && n === 1) return inboxView(get);
|
||||
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
|
||||
if (a === 'messages' && n === 1) return messagesView(get, q);
|
||||
@@ -1548,6 +1541,15 @@
|
||||
window.MosaicChat?.leave(chatPath);
|
||||
if (path === '/business') { location.replace('#/settings/business'); return; }
|
||||
if (path === '/settings/display') { location.replace('#/settings/appearance'); return; }
|
||||
// U5: Side by side draws its own two panes (panes.js); every other page stops their polling.
|
||||
if (path !== '/side-by-side') window.MosaicPanes?.unmount();
|
||||
else if (window.MosaicOrg && window.MosaicPanes) {
|
||||
const fresh = navigated || shown !== path;
|
||||
shown = path;
|
||||
if (fresh) { unmountEditor(); await window.MosaicPanes.mount($('bus-view'), query, { navigated }); }
|
||||
else window.MosaicPanes.refresh();
|
||||
return;
|
||||
}
|
||||
if (chatPath === '/chat' || chatPath.startsWith('/chat/') || /^\/projects\/[0-9a-f-]+\/(?:settings|conversations|tasks|requirements|files)$/i.test(chatPath)) {
|
||||
// Re-read the scoped capability as the Console and bus recover. A failed read
|
||||
// withdraws the control; the chat renderer retains the draft across repaint.
|
||||
|
||||
@@ -33,8 +33,9 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
|
||||
return;
|
||||
}
|
||||
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
|
||||
// /api/workspace reads and writes across organizations (U1, U4b), so it keeps its own path.
|
||||
window.fetch = (input, init) => {
|
||||
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|workspace(?:\?|$)|agents\/levels(?:\?|$))/.test(input))
|
||||
if (typeof input === 'string' && input.startsWith('/api/') && !/^\/api\/(?:workspace(?:\/|\?|$)|orgs(?:\/|$)|themes(?:\?|$)|org-template(?:\?|$)|org-presets(?:\?|$)|org-admin(?:\?|$)|org-validate(?:\?|$)|agents\/levels(?:\?|$))/.test(input))
|
||||
input = `/api/orgs/${id}/${input.slice('/api/'.length)}`;
|
||||
return nativeFetch(input, init);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-design="console" data-palette="harbor">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Dashboard · Mosaic Console</title>
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/inbox.css"><link rel="stylesheet" href="/requests.css"><link rel="stylesheet" href="/files.css"><link rel="stylesheet" href="/project-remote.css"><link rel="stylesheet" href="/shell.css">
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/inbox.css"><link rel="stylesheet" href="/requests.css"><link rel="stylesheet" href="/files.css"><link rel="stylesheet" href="/project-remote.css"><link rel="stylesheet" href="/shell.css"><link rel="stylesheet" href="/tasks-board.css">
|
||||
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
|
||||
<link rel="stylesheet" href="/project-archive.css"><link rel="stylesheet" href="/projects.css">
|
||||
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
|
||||
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script>
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/panes.css">
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><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,97 @@
|
||||
/* U4b (#1873): the task board across organizations, ported from the U0 sample 06 (workspace.css
|
||||
EXT 7 and the board rules). Organization mode only; the org colour comes from .ws-o (shell.css). */
|
||||
html[data-organizations] .tb .ws-vh .ws-seg{margin-left:0}
|
||||
html[data-organizations] .tb-trk{display:inline-flex;align-items:center;gap:4px;font:600 10.5px var(--mono);letter-spacing:.02em;padding:0 5px;border-radius:3px;border:1px solid var(--line);color:var(--muted);white-space:nowrap}
|
||||
html[data-organizations] .tb-trk.vik{border-style:dashed;color:var(--accent);border-color:color-mix(in srgb,var(--accent) 60%,var(--line))}
|
||||
html[data-organizations] .tb-trackers{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center;font-size:12.5px;color:var(--text);padding:8px 12px;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface);margin-bottom:12px}
|
||||
html[data-organizations] .tb-trackers>span{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||||
html[data-organizations] .tb-trackers .note{font-size:12.5px}
|
||||
html[data-organizations] .tb-tracker .ws-dot{font-weight:600}
|
||||
html[data-organizations] .tb-reason{color:var(--muted)}
|
||||
html[data-organizations] .tb-notice{margin:0 0 10px;font-size:13px;color:var(--muted)}
|
||||
html[data-organizations] .tb-notice:empty{display:none}
|
||||
html[data-organizations] .tb-notice.is-error{color:var(--danger);font-weight:600}
|
||||
|
||||
/* Filter bar. */
|
||||
html[data-organizations] .tb-toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;margin-bottom:12px}
|
||||
html[data-organizations] .tb-right{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
html[data-organizations] .tb-chips{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
|
||||
html[data-organizations] .tb-f{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:999px;padding:2px 10px 2px 4px;font:600 12.5px/1.5 var(--font);color:var(--text);white-space:nowrap;cursor:pointer;box-shadow:none}
|
||||
html[data-organizations] .tb-f.plain{padding-left:10px}
|
||||
html[data-organizations] .tb-f[aria-pressed=true]{background:var(--strong);color:var(--surface);border-color:var(--strong)}
|
||||
html[data-organizations] .tb-f .ws-av{width:18px;height:18px;border-radius:999px;font-size:8.5px;letter-spacing:0}
|
||||
html[data-organizations] .tb-f .n{margin-left:2px;color:inherit;opacity:.8}
|
||||
html[data-organizations] .tb-sel{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 0 0 8px;font-size:13px;color:var(--strong);white-space:nowrap}
|
||||
html[data-organizations] .tb-sel .ws-label{font-size:10px}
|
||||
html[data-organizations] .tb-sel select{border:0;background:none;color:var(--strong);font:500 13px/1.5 var(--font);padding:3px 6px;min-height:0;max-width:16em;box-shadow:none}
|
||||
html[data-organizations] .tb-search{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:0 0 0 8px;color:var(--muted);min-width:180px}
|
||||
html[data-organizations] .tb-search svg.i,html[data-organizations] .tb-who svg.i{width:13px;height:13px}
|
||||
html[data-organizations] .tb-search input{border:0;background:none;color:var(--strong);font:13px/1.5 var(--font);padding:3px 8px 3px 0;min-height:0;width:100%;min-width:0;box-shadow:none}
|
||||
html[data-organizations] .tb-sel:focus-within,html[data-organizations] .tb-search:focus-within{outline:2px solid var(--action);outline-offset:1px}
|
||||
html[data-organizations] .tb-sel select:focus,html[data-organizations] .tb-search input:focus{outline:none}
|
||||
html[data-organizations] .tb-count{margin:0 0 8px;font-size:13px;color:var(--muted)}
|
||||
|
||||
/* Kanban. */
|
||||
html[data-organizations] .tb-board{display:grid;grid-template-columns:repeat(var(--tb-cols,5),minmax(0,1fr));gap:12px;align-items:start}
|
||||
html[data-organizations] .tb-col{background:color-mix(in srgb,var(--raised) 55%,var(--canvas));border:1px solid var(--line);border-radius:var(--r-lg);padding:8px;display:grid;gap:8px;min-width:0}
|
||||
html[data-organizations] .tb-col.is-over{outline:2px dashed var(--action);outline-offset:-2px}
|
||||
html[data-organizations] .tb-col-h{display:flex;align-items:center;gap:8px;padding:2px 4px}
|
||||
html[data-organizations] .tb-col-h h3{margin:0;font-size:13px;font-weight:700;color:var(--strong)}
|
||||
html[data-organizations] .tb-col-h .n{font-size:12px;font-weight:600;color:var(--muted)}
|
||||
html[data-organizations] .tb-col-h .mix{margin-left:auto;display:flex;gap:2px}
|
||||
html[data-organizations] .tb-colnote,html[data-organizations] .tb-empty{margin:0;padding:2px 4px;font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .tb-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-table);padding:8px 10px 8px 12px;display:grid;gap:5px;min-width:0;box-shadow:inset 3px 0 0 var(--org)}
|
||||
html[data-organizations] .tb-card[draggable=true]{cursor:grab}
|
||||
html[data-organizations] .tb-card.is-dragging{opacity:.5}
|
||||
html[data-organizations] .tb-card .top{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
|
||||
html[data-organizations] .tb-card .top .id{font:500 11.5px var(--mono);color:var(--muted)}
|
||||
html[data-organizations] .tb-card .top .tb-trk{margin-left:auto}
|
||||
html[data-organizations] .tb-card .tt{color:var(--strong);font-weight:600;font-size:13px;line-height:1.35;min-width:0}
|
||||
html[data-organizations] .tb-card .bot{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);flex-wrap:wrap}
|
||||
html[data-organizations] .tb-card.dim .tt{color:var(--text)}
|
||||
html[data-organizations] .tb-open{all:unset;cursor:pointer;overflow-wrap:anywhere}
|
||||
html[data-organizations] .tb-open:hover{text-decoration:underline}
|
||||
html[data-organizations] .tb-open:focus-visible{outline:2px solid var(--action);outline-offset:2px;border-radius:2px}
|
||||
html[data-organizations] .tb-who{display:inline-flex;align-items:center;gap:4px}
|
||||
html[data-organizations] .tb-chip{display:inline-flex;align-items:center;font-size:11.5px;font-weight:600;padding:0 6px;border-radius:4px;background:var(--raised);color:var(--strong);white-space:nowrap}
|
||||
html[data-organizations] .tb-chip.warn{background:color-mix(in srgb,var(--warning) 16%,var(--surface));color:var(--warning)}
|
||||
|
||||
/* List. */
|
||||
html[data-organizations] .tb-row td:first-child{box-shadow:inset 3px 0 0 var(--org)}
|
||||
html[data-organizations] .tb-row.dim td{color:var(--muted)}
|
||||
html[data-organizations] .tb-list .tb-open.t1{display:inline;color:var(--strong)}
|
||||
html[data-organizations] .tb-list .t2.show-m{display:none}
|
||||
html[data-organizations] .tb-move{font:500 13px/1.4 var(--font);color:var(--strong);background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:2px 4px;min-height:0}
|
||||
|
||||
/* The create and edit dialog (on <body>, outside the repainted view). */
|
||||
html[data-organizations] .tb-dialog{border:1px solid var(--line);border-radius:12px;background:var(--surface);color:var(--text);padding:0;width:min(520px,calc(100vw - 32px));max-height:calc(100vh - 48px);box-shadow:0 12px 40px rgba(0,0,0,.25)}
|
||||
html[data-organizations] .tb-dialog::backdrop{background:rgba(0,0,0,.35)}
|
||||
html[data-organizations] .tb-form{display:grid;gap:10px;padding:18px 20px;font-size:14px}
|
||||
html[data-organizations] .tb-form h2{margin:0;font-size:17px;font-weight:700;color:var(--strong);overflow-wrap:anywhere}
|
||||
html[data-organizations] .tb-form label{display:grid;gap:4px;font-size:12.5px;font-weight:600;color:var(--strong)}
|
||||
html[data-organizations] .tb-form input:not([type=checkbox]),html[data-organizations] .tb-form select{font:14px/1.4 var(--font);color:var(--strong);background:var(--surface);border:1px solid var(--line);border-radius:var(--r);padding:5px 8px;min-width:0}
|
||||
html[data-organizations] .tb-row2{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
|
||||
html[data-organizations] .tb-roles{border:1px solid var(--line);border-radius:var(--r);padding:6px 10px 8px;margin:0;display:flex;flex-wrap:wrap;gap:4px 14px}
|
||||
html[data-organizations] .tb-roles legend{font-size:12.5px;font-weight:600;color:var(--strong);padding:0 4px}
|
||||
html[data-organizations] .tb-roles label{display:inline-flex;align-items:center;gap:6px;font-weight:500;color:var(--text)}
|
||||
html[data-organizations] .tb-field-note,html[data-organizations] .tb-facts{margin:0;font-size:12.5px;color:var(--muted)}
|
||||
html[data-organizations] .tb-facts{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
|
||||
html[data-organizations] .tb-dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:4px 14px;margin:0;font-size:13px}
|
||||
html[data-organizations] .tb-dl dt{color:var(--muted)}
|
||||
html[data-organizations] .tb-dl dd{margin:0;color:var(--strong)}
|
||||
html[data-organizations] .tb-form-status{margin:0;font-size:13px;color:var(--danger);font-weight:600}
|
||||
html[data-organizations] .tb-form-status:empty{display:none}
|
||||
html[data-organizations] .tb-actions{display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end;align-items:center}
|
||||
html[data-organizations] .tb-gap{flex:1}
|
||||
html[data-organizations] .tb-form .ws-btn.primary{background:var(--action);color:var(--onAction);border-color:var(--action)}
|
||||
html[data-organizations] .tb-form .ws-btn.primary:hover:not(:disabled){background:color-mix(in srgb,var(--action) 88%,var(--strong))}
|
||||
html[data-organizations] .tb-form .ws-btn.danger{color:var(--danger)}
|
||||
|
||||
@media (max-width:759px){
|
||||
html[data-organizations] .tb-board{grid-template-columns:minmax(0,1fr)}
|
||||
html[data-organizations] .tb-right{margin-left:0;flex:1 1 100%}
|
||||
html[data-organizations] .tb-search{min-width:0;flex:1}
|
||||
html[data-organizations] .tb-list .t2.show-m{display:block}
|
||||
html[data-organizations] .tb-row2{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
|
||||
html[data-organizations] .tb-sel select{max-width:11em}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
// U4b (#1873, PRD 1.20): one task board across every organization (sample 06).
|
||||
//
|
||||
// Reads: GET /api/workspace?sections=tasks,projects, where each org's tasks come from its own tracker
|
||||
// module (the built-in store or Vikunja), merged and tagged with the org. The project, state, assignee
|
||||
// and label filters go to the server, which applies them before its per-org cap, so a filter reaches
|
||||
// every task an org holds; the org chips, the search, Nobody and No project narrow in the page. An org whose bus is down,
|
||||
// slow or invalid keeps its U1 state on the Trackers line and the others still render.
|
||||
// Writes: POST /api/workspace/tasks, one org per request, through that org's bus and its person
|
||||
// verbs (tasks.create, tasks.edit, tasks.archive). The bus runs its own human check; a refusal is
|
||||
// shown as the bus answered it. Vikunja tasks are read only here: the bus refuses them
|
||||
// (tracker-not-store), so the board offers no control for them.
|
||||
// bus.js calls view() for #/tasks in organization mode and repaints it on its own 10 s poll.
|
||||
(() => {
|
||||
'use strict';
|
||||
if (!document.documentElement.hasAttribute('data-organizations')) return;
|
||||
const data = window.MosaicWorkspaceData;
|
||||
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const LAYOUT = 'mosaic-tasks-layout-v1';
|
||||
const load = key => { try { return localStorage.getItem(key); } catch { return null; } };
|
||||
const save = (key, value) => { try { localStorage.setItem(key, value); } catch {} };
|
||||
const STATES = [['todo', 'To do'], ['in-progress', 'In progress'], ['in-review', 'Review'], ['blocked', 'Blocked'], ['done', 'Done']];
|
||||
const STATE_LABEL = Object.fromEntries(STATES);
|
||||
const UNREAD = 'unread';
|
||||
const REQUIREMENT = '^REQ-[A-Z]+-[1-9][0-9]*$';
|
||||
const ORG_STATE = { down: ['s-offline', '○', 'bus down'], timeout: ['s-waiting', '◐', 'no answer in time'], invalid: ['s-error', '▲', 'invalid answer'], starting: ['s-waiting', '◐', 'starting'] };
|
||||
const phone = () => window.matchMedia('(max-width:759px)').matches;
|
||||
|
||||
// The layout is remembered per viewer and per width: a phone opens on List until its viewer picks
|
||||
// Kanban, a wide screen on Kanban until its viewer picks List.
|
||||
const layouts = (() => { try { const v = JSON.parse(load(LAYOUT) || '{}'); return v && typeof v === 'object' ? v : {}; } catch { return {}; } })();
|
||||
const layoutNow = () => { const v = layouts[phone() ? 'phone' : 'wide']; return v === 'kanban' || v === 'list' ? v : phone() ? 'list' : 'kanban'; };
|
||||
const filter = { org: '', project: '', assignee: '', state: '', label: '', q: '' };
|
||||
// `opened` is the task as the edit dialog showed it: Save diffs against it and sends its digest, so a
|
||||
// poll that reads a newer task while the dialog is open turns a stale save into a task-conflict.
|
||||
let snap = null, canWrite = false, notice = null, dragging = null, dragEnded = null, opened = null;
|
||||
|
||||
const mark = () => window.MosaicWorkspace?.orgMark;
|
||||
const orgOf = id => snap?.orgs.find(o => o.id === id) || { id, label: id, theme: null };
|
||||
const av = (id, size = '') => mark()?.av(orgOf(id), size) ?? '';
|
||||
const colours = id => mark()?.colours(orgOf(id)) ?? '';
|
||||
const icon = id => window.MosaicWorkspace?.icon(id) ?? '';
|
||||
const key = t => `${t.org}|${t.ref}`;
|
||||
// Ids keep their tracker's form: #N for the built-in store, V-N for Vikunja.
|
||||
const shortId = ref => { let m = /^task:(\d+)$/.exec(ref); if (m) return `#${m[1]}`; m = /^vikunja:\d+\/(\d+)$/.exec(ref); return m ? `V-${m[1]}` : ref; };
|
||||
const trk = tracker => (tracker === 'vikunja' ? '<span class="tb-trk vik">Vikunja</span>' : '<span class="tb-trk">built-in</span>');
|
||||
const stateOf = t => (STATE_LABEL[t.state] ? t.state : UNREAD);
|
||||
// An org can answer while its tasks read fails (errors.tasks): its tasks are unavailable, not zero.
|
||||
const tasksRead = o => o?.state === 'ok' && !o.errors?.tasks;
|
||||
const orgWritable = o => canWrite && tasksRead(o) && o.tracker === 'built-in';
|
||||
const taskWritable = t => t.writable === true && typeof t.digest === 'string' && orgWritable(orgOf(t.org));
|
||||
const projectName = t => t.project?.name || t.project?.key || null;
|
||||
const dueText = iso => {
|
||||
const d = new Date(iso);
|
||||
if (!Number.isFinite(d.getTime())) return null;
|
||||
const opts = { day: 'numeric', month: 'short', timeZone: 'UTC', ...(d.getUTCFullYear() !== new Date().getUTCFullYear() ? { year: 'numeric' } : {}) };
|
||||
return d.toLocaleDateString('en-GB', opts);
|
||||
};
|
||||
const overdue = t => t.dueDate && t.state !== 'done' && Date.parse(t.dueDate) < Date.now();
|
||||
|
||||
// ---------- reads ----------
|
||||
const PROJECT_SELECTOR = /^[a-z0-9][a-z0-9._-]{0,63}:[A-Za-z0-9._-]{1,128}$/, ROLE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
function readQuery() {
|
||||
const q = new URLSearchParams({ sections: 'tasks,projects' });
|
||||
if (PROJECT_SELECTOR.test(filter.project)) q.set('project', filter.project);
|
||||
if (filter.state) q.set('task-state', filter.state === UNREAD ? 'open' : filter.state);
|
||||
if (ROLE.test(filter.assignee)) q.set('assignee', filter.assignee);
|
||||
if (/^[1-9]\d{0,14}$/.test(filter.label)) q.set('label', filter.label);
|
||||
return q.toString();
|
||||
}
|
||||
async function read() {
|
||||
const [response, list] = await Promise.all([
|
||||
fetch(`/api/workspace?${readQuery()}`, { cache: 'no-store', redirect: 'manual' }),
|
||||
data.organizations().then(b => (Array.isArray(b) ? b : null)).catch(() => null),
|
||||
mark()?.ready().catch(() => null),
|
||||
window.MosaicConsole?.ready,
|
||||
]);
|
||||
canWrite = window.MosaicConsole?.config?.orgSetup === true;
|
||||
if (response.type === 'opaqueredirect' || !response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
throw Object.assign(new Error('read failed'), { status: response.status, message: typeof body?.message === 'string' ? body.message : null });
|
||||
}
|
||||
const body = await response.json();
|
||||
if (!Array.isArray(body?.orgs) || !Array.isArray(body.tasks) || !Array.isArray(body.projects)) throw new Error('read failed');
|
||||
const named = new Map((list || []).map(o => [o.id, o]));
|
||||
const orgs = body.orgs.map(o => ({ ...o, id: o.org, label: named.get(o.org)?.label || o.org, theme: named.get(o.org)?.theme ?? o.theme ?? null, roles: Array.isArray(o.roles) ? o.roles : [] }));
|
||||
return { orgs, tasks: body.tasks, projects: body.projects, summary: body.summary, readAt: body.readAt };
|
||||
}
|
||||
// Only the latest read lands: a filter change and the poll can both be reading.
|
||||
let readSeq = 0;
|
||||
const reload = async () => { const n = ++readSeq, next = await read(); if (n !== readSeq) return false; snap = next; prune(); return true; };
|
||||
async function refilter() { paint(); try { if (await reload()) paint(); } catch { /* the next poll shows the failed read */ } }
|
||||
// A filter naming an org or project that is gone falls back to all.
|
||||
function prune() {
|
||||
if (filter.org && !snap.orgs.some(o => o.id === filter.org)) filter.org = '';
|
||||
if (filter.project && filter.project !== '-' && !snap.projects.some(p => `${p.org}:${p.id}` === filter.project)) filter.project = '';
|
||||
}
|
||||
|
||||
// ---------- filters ----------
|
||||
function filtered({ q = true } = {}) {
|
||||
const needle = filter.q.trim().toLowerCase();
|
||||
return snap.tasks.filter(t => (!filter.org || t.org === filter.org)
|
||||
&& (!filter.project || (filter.project === '-' ? !t.project : `${t.org}:${t.project?.id}` === filter.project))
|
||||
&& (!filter.assignee || (filter.assignee === '-' ? !t.assignees.length : t.assignees.includes(filter.assignee)))
|
||||
&& (!filter.state || stateOf(t) === filter.state)
|
||||
&& (!filter.label || t.labels.map(String).includes(filter.label))
|
||||
&& (!q || !needle || `${t.title ?? ''} ${shortId(t.ref)} ${projectName(t) ?? ''}`.toLowerCase().includes(needle)));
|
||||
}
|
||||
const select = (name, label, options, focus) => `<label class="tb-sel"><span class="ws-label">${esc(label)}</span><select data-tb-filter="${name}" data-focus-key="${focus}">${options}</select></label>`;
|
||||
const opt = (value, text, current) => `<option value="${esc(value)}"${value === current ? ' selected' : ''}>${esc(text)}</option>`;
|
||||
function toolbar() {
|
||||
const counts = new Map(snap.orgs.map(o => [o.id, 0]));
|
||||
for (const t of snap.tasks) counts.set(t.org, (counts.get(t.org) ?? 0) + 1);
|
||||
const chip = (id, inner, label) => `<button type="button" class="tb-f${id ? '' : ' plain'}" data-tb-org="${esc(id)}" aria-pressed="${filter.org === id}" data-focus-key="tb-org:${esc(id)}"${label ? ` aria-label="${esc(label)}"` : ''}>${inner}</button>`;
|
||||
const chips = [chip('', `All <span class="n">${snap.tasks.length}</span>`)].concat(snap.orgs.map(o => chip(o.id,
|
||||
`${av(o.id)}${esc(o.label)}<span class="n">${tasksRead(o) ? counts.get(o.id) : 'unavailable'}</span>`,
|
||||
`${o.label}, ${tasksRead(o) ? `${counts.get(o.id)} tasks` : 'tasks unavailable'}`))).join('');
|
||||
const scope = snap.orgs.filter(o => !filter.org || o.id === filter.org);
|
||||
const projectOpts = opt('', `All ${snap.projects.filter(p => !filter.org || p.org === filter.org).length}`, filter.project) + opt('-', 'No project', filter.project)
|
||||
+ scope.map(o => { const ps = snap.projects.filter(p => p.org === o.id); return ps.length ? `<optgroup label="${esc(o.label)}">${ps.map(p => opt(`${p.org}:${p.id}`, p.name || p.key || p.id, filter.project)).join('')}</optgroup>` : ''; }).join('');
|
||||
const roles = [...new Set(scope.flatMap(o => o.roles).concat(snap.tasks.filter(t => !filter.org || t.org === filter.org).flatMap(t => t.assignees)))].sort();
|
||||
if (filter.assignee && filter.assignee !== '-' && !roles.includes(filter.assignee)) roles.push(filter.assignee);
|
||||
const assigneeOpts = opt('', 'Anyone', filter.assignee) + opt('-', 'Nobody', filter.assignee) + roles.map(r => opt(r, r, filter.assignee)).join('');
|
||||
const stateOpts = opt('', 'Any', filter.state) + STATES.map(([v, l]) => opt(v, l, filter.state)).join('') + (snap.tasks.some(t => stateOf(t) === UNREAD) ? opt(UNREAD, 'State not read', filter.state) : '');
|
||||
const labels = [...new Set(snap.tasks.flatMap(t => t.labels.map(String)))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||
const labelSel = labels.length ? select('label', 'Label', opt('', 'Any', filter.label) + labels.map(l => opt(l, l, filter.label)).join(''), 'tb-label') : '';
|
||||
return `<div class="tb-toolbar">
|
||||
<div class="tb-chips" role="group" aria-label="Organization">${chips}</div>
|
||||
${select('project', 'Project', projectOpts, 'tb-project')}${select('assignee', 'Assignee', assigneeOpts, 'tb-assignee')}${select('state', 'State', stateOpts, 'tb-state')}${labelSel}
|
||||
<div class="tb-right"><label class="tb-search">${icon('search')}<input type="search" placeholder="Find a task" aria-label="Find a task" data-tb-q data-focus-key="tb-q" value="${esc(filter.q)}" autocomplete="off"></label></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---------- results ----------
|
||||
const who = t => (t.assignees.length ? `<span class="tb-who">${icon('people')}${esc(t.assignees.join(', '))}</span>` : '');
|
||||
const prio = t => (Number.isSafeInteger(t.priority) && t.priority > 0 ? `<span class="tb-chip${t.priority >= 4 ? ' warn' : ''}" title="Priority ${t.priority} of 5">P${t.priority}</span>` : '');
|
||||
const due = t => { const d = t.dueDate && dueText(t.dueDate); return d ? `<span class="tb-chip${overdue(t) ? ' warn' : ''}" title="Due ${esc(t.dueDate.slice(0, 10))} (UTC)">${overdue(t) ? 'overdue' : 'due'} ${esc(d)}</span>` : ''; };
|
||||
const title = t => esc(t.title || 'Untitled');
|
||||
function card(t) {
|
||||
const w = taskWritable(t), k = esc(key(t));
|
||||
return `<article class="tb-card ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${k}" data-org="${esc(t.org)}"${w ? ' draggable="true"' : ''}>
|
||||
<div class="top">${av(t.org, 'xs')}<span class="ws-sr">${esc(orgOf(t.org).label)}</span><span class="id">${esc(shortId(t.ref))}</span>${trk(t.tracker)}</div>
|
||||
<div class="tt"><button type="button" class="tb-open" data-tb-open="${k}" data-focus-key="open:${k}">${title(t)}</button></div>
|
||||
<div class="bot"><span>${projectName(t) ? esc(projectName(t)) : '<span class="muted">No project</span>'}</span>${who(t)}${prio(t)}${due(t)}</div>
|
||||
</article>`;
|
||||
}
|
||||
function kanban(rows) {
|
||||
const cols = STATES.concat(rows.some(t => stateOf(t) === UNREAD) ? [[UNREAD, 'State not read']] : []);
|
||||
return `<div class="tb-board" style="--tb-cols:${cols.length}" data-tb-kanban>${cols.map(([v, l]) => {
|
||||
const inCol = rows.filter(t => stateOf(t) === v), orgs = [...new Set(inCol.map(t => t.org))];
|
||||
const drop = v !== UNREAD ? ` data-tb-col="${v}"` : '';
|
||||
return `<section class="tb-col"${drop} aria-label="${esc(l)}"><div class="tb-col-h"><h3>${esc(l)}</h3><span class="n">${inCol.length}</span>${orgs.length ? `<span class="mix" title="Organizations in this column">${orgs.map(o => av(o, 'xs')).join('')}</span>` : ''}</div>
|
||||
${v === UNREAD ? '<p class="tb-colnote">Vikunja does not tell the bus which bucket an open task is in.</p>' : ''}${inCol.map(card).join('') || '<p class="tb-empty">No tasks</p>'}</section>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
const order = t => { const i = STATES.findIndex(([v]) => v === t.state); return i < 0 ? STATES.length : i; };
|
||||
function list(rows) {
|
||||
const sorted = [...rows].sort((a, b) => order(a) - order(b) || a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref, undefined, { numeric: true }));
|
||||
const stateCell = t => {
|
||||
if (!taskWritable(t)) return esc(STATE_LABEL[t.state] || 'Not read');
|
||||
return `<select class="tb-move" data-tb-move="${esc(key(t))}" data-focus-key="move:${esc(key(t))}" aria-label="${esc(`State of ${shortId(t.ref)}, ${t.title || 'Untitled'}`)}">${STATES.map(([v, l]) => opt(v, l, t.state)).join('')}</select>`;
|
||||
};
|
||||
return `<div class="ws-tbl tb-list" data-tb-list><table><thead><tr><th scope="col">Org</th><th scope="col">Id</th><th scope="col">Task</th><th scope="col" class="hide-m">Project</th><th scope="col">State</th><th scope="col" class="hide-m">Assignee</th><th scope="col" class="hide-m">Priority</th><th scope="col" class="hide-m">Due</th></tr></thead><tbody>
|
||||
${sorted.map(t => `<tr class="tb-row ws-o${t.state === 'done' ? ' dim' : ''}" style="${colours(t.org)}" data-tb-task="${esc(key(t))}" data-org="${esc(t.org)}">
|
||||
<td>${av(t.org)}<span class="ws-sr">${esc(orgOf(t.org).label)}</span></td>
|
||||
<td class="mono">${esc(shortId(t.ref))}<span class="t2">${t.tracker === 'vikunja' ? 'Vikunja' : 'built-in'}</span></td>
|
||||
<td><button type="button" class="tb-open t1" data-tb-open="${esc(key(t))}" data-focus-key="lopen:${esc(key(t))}">${title(t)}</button><span class="t2 show-m">${esc(projectName(t) || 'No project')}</span></td>
|
||||
<td class="hide-m">${projectName(t) ? esc(projectName(t)) : '<span class="muted">none</span>'}</td>
|
||||
<td>${stateCell(t)}</td>
|
||||
<td class="hide-m">${t.assignees.length ? esc(t.assignees.join(', ')) : '<span class="muted">nobody</span>'}</td>
|
||||
<td class="hide-m">${prio(t) || '<span class="muted">none</span>'}</td>
|
||||
<td class="hide-m">${due(t) || '<span class="muted">none</span>'}</td>
|
||||
</tr>`).join('')}</tbody></table></div>`;
|
||||
}
|
||||
function results() {
|
||||
const rows = filtered(), all = snap.tasks.length;
|
||||
// Project, state, assignee and label filter on the server, so the board never holds the unfiltered
|
||||
// total; with one of those on, the line says how many match rather than inventing an "of N".
|
||||
const onServer = new URLSearchParams(readQuery()).size > 1, n = rows.length;
|
||||
const line = onServer ? `<p class="tb-count">Showing ${n} ${n === 1 ? 'task that matches' : 'tasks that match'} the filters.</p>`
|
||||
: n !== all ? `<p class="tb-count">Showing ${n} of ${all} tasks.</p>` : '';
|
||||
// An empty result is only exhaustive when every organization's tasks were read.
|
||||
const unread = snap.orgs.filter(o => !tasksRead(o));
|
||||
const none = what => (unread.length ? `${what} in the organizations that answered. Tasks from ${unread.map(o => o.label).join(', ')} could not be read.` : `${what}.`);
|
||||
const body = !snap.orgs.length ? '<p class="ws-panel tight note">No organizations yet.</p>'
|
||||
: !snap.orgs.some(tasksRead) ? '<p class="ws-panel tight note">No organization answered with its tasks, so no tasks could be read.</p>'
|
||||
: !all && !onServer ? `<p class="ws-panel tight note">${esc(none('No tasks yet'))}</p>`
|
||||
: !rows.length ? `<p class="ws-panel tight note">${esc(none('No tasks match these filters'))}</p>`
|
||||
: layoutNow() === 'list' ? list(rows) : kanban(rows);
|
||||
return `<div data-tb-results>${line}${body}</div>`;
|
||||
}
|
||||
|
||||
// ---------- page ----------
|
||||
function trackers() {
|
||||
const items = snap.orgs.map(o => {
|
||||
const s = ORG_STATE[o.state] ?? (o.state === 'ok' && o.errors?.tasks ? ['s-error', '▲', 'tasks not read'] : null);
|
||||
const why = ORG_STATE[o.state] ? o.reason : o.errors?.tasks;
|
||||
const st = s ? ` <span class="ws-dot ${s[0]}"><b aria-hidden="true">${s[1]}</b>${esc(s[2])}</span>${why ? `<span class="tb-reason">${esc(why)}</span>` : ''}` : '';
|
||||
const cut = o.tasksHeld ? `<span class="tb-reason">shows the 500 most recently changed of ${esc(o.tasksHeld)} matching tasks</span>` : '';
|
||||
return `<span class="tb-tracker${s ? ' is-down' : ''}" data-tb-tracker="${esc(o.id)}" data-state="${esc(o.state)}">${av(o.id)}${esc(o.label)} ${o.tracker ? trk(o.tracker) : ''}${st}${cut}</span>`;
|
||||
}).join('');
|
||||
return `<div class="tb-trackers"><span class="ws-label">Trackers</span>${items}<span class="note">Ids keep their tracker's form: V-12 is Vikunja, #41 is built in.</span></div>`;
|
||||
}
|
||||
function head() {
|
||||
const lay = layoutNow();
|
||||
const writableOrgs = snap ? snap.orgs.filter(orgWritable) : [];
|
||||
const why = !canWrite ? 'This Console takes no task changes.' : !writableOrgs.length ? 'No organization with a built-in task store answered.' : '';
|
||||
const seg = `<span class="ws-seg" role="group" aria-label="Layout"><button type="button" data-tb-layout="kanban" aria-pressed="${lay === 'kanban'}" data-focus-key="tb-layout-kanban">Kanban</button><button type="button" data-tb-layout="list" aria-pressed="${lay === 'list'}" data-focus-key="tb-layout-list">List</button></span>`;
|
||||
const add = `<button type="button" class="ws-btn" data-tb-new data-focus-key="tb-new"${why ? ` disabled title="${esc(why)}"` : ''}>${icon('plus')}New task</button>`;
|
||||
let sub = '';
|
||||
if (snap) {
|
||||
const n = snap.tasks.length, m = snap.orgs.filter(tasksRead).length, of = snap.summary?.of ?? snap.orgs.length;
|
||||
sub = `${n} ${n === 1 ? 'task' : 'tasks'} from ${m} of ${of} ${of === 1 ? 'organization' : 'organizations'}. Each organization keeps its own tasks; a change made here goes only to that organization's bus.`;
|
||||
if (snap.orgs.some(o => o.tracker === 'vikunja')) sub += ' Vikunja tasks are read only here.';
|
||||
if (!canWrite) sub += ' This Console takes no task changes, so the board is read only.';
|
||||
}
|
||||
return `<div class="ws-vh"><h1 tabindex="-1">Tasks</h1><div class="ws-right">${seg}${add}</div>${sub ? `<p class="sub">${sub}</p>` : ''}</div>`;
|
||||
}
|
||||
const noticeLine = () => (notice ? `<p class="tb-notice${notice.error ? ' is-error' : ''}" role="${notice.error ? 'alert' : 'status'}" data-tb-notice>${esc(notice.text)}</p>` : '<p class="tb-notice" role="status" data-tb-notice></p>');
|
||||
function markup() {
|
||||
return `<section class="ws tb" data-tasks-board data-layout="${layoutNow()}">${head()}${noticeLine()}${trackers()}${toolbar()}${results()}</section>`;
|
||||
}
|
||||
const failed = err => `<section class="ws tb" data-tasks-board><div class="ws-vh"><h1 tabindex="-1">Tasks</h1><p class="sub">The task board could not be read${err?.message && err.message !== 'read failed' ? `: ${esc(err.message)}` : ''}. It tries again on the next refresh.</p></div></section>`;
|
||||
|
||||
// Kept across a repaint: focus by data-focus-key, the search caret and the sideways scroll of the
|
||||
// board and the list. bus.js restores focus by key; the caret and scroll are put back here.
|
||||
function snapshotView(root) {
|
||||
const a = document.activeElement;
|
||||
return {
|
||||
focus: root && root.contains(a) ? a.dataset?.focusKey || null : null,
|
||||
caret: root && root.contains(a) && a.matches?.('[data-tb-q]') ? [a.selectionStart, a.selectionEnd] : null,
|
||||
scroll: root ? [...root.querySelectorAll('.tb-board,.tb-list')].map(e => [e.matches('.tb-board') ? 'board' : 'list', e.scrollLeft]) : [],
|
||||
};
|
||||
}
|
||||
// With focus false (after bus.js's own repaint, which puts focus back by key) the caret is put back
|
||||
// only if focus is still on the search box it was taken from.
|
||||
function putBack(root, kept, { focus = true } = {}) {
|
||||
if (!root || !kept) return;
|
||||
for (const [which, left] of kept.scroll) { const e = root.querySelector(which === 'board' ? '.tb-board' : '.tb-list'); if (e) e.scrollLeft = left; }
|
||||
const el = kept.focus ? root.querySelector(`[data-focus-key="${CSS.escape(kept.focus)}"]`) : null;
|
||||
if (focus && el && document.activeElement !== el) el.focus({ preventScroll: true });
|
||||
if (el && document.activeElement === el && kept.caret && el.matches('[data-tb-q]')) el.setSelectionRange(kept.caret[0], kept.caret[1]);
|
||||
}
|
||||
// Repaint in place, for the board's own changes (filters, layout, a write).
|
||||
function paint(part = 'all') {
|
||||
const root = document.querySelector('[data-tasks-board]');
|
||||
if (!root || !snap) return;
|
||||
const kept = snapshotView(root);
|
||||
if (part === 'results') root.querySelector('[data-tb-results]').outerHTML = results();
|
||||
else root.outerHTML = markup();
|
||||
putBack(document.querySelector('[data-tasks-board]'), kept);
|
||||
}
|
||||
|
||||
// bus.js's view for #/tasks. A drag in progress holds the poll's repaint until it ends.
|
||||
async function view() {
|
||||
if (dragging) await Promise.race([dragEnded, new Promise(r => setTimeout(r, 30000))]);
|
||||
try { await reload(); } catch (err) { snap = null; return failed(err); }
|
||||
// Taken after the read, just before bus.js replaces the view, so focus, caret or scroll moved while
|
||||
// the read was in flight is what is kept. bus.js puts focus back by key; this puts back the rest.
|
||||
const kept = snapshotView(document.querySelector('[data-tasks-board]'));
|
||||
setTimeout(() => putBack(document.querySelector('[data-tasks-board]'), kept, { focus: false }), 0);
|
||||
return markup();
|
||||
}
|
||||
|
||||
// ---------- writes ----------
|
||||
const UNKNOWN = Object.freeze({ ok: false, error: 'outcome-unknown', unknown: true, message: 'The Console\'s answer could not be read. The change may or may not have been written; the board shows it on its next read.' });
|
||||
async function write(body) {
|
||||
let response, answer = null;
|
||||
try {
|
||||
response = await fetch('/api/workspace/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });
|
||||
answer = await response.json().catch(() => null);
|
||||
} catch { return UNKNOWN; }
|
||||
if (response.type === 'opaqueredirect') return { ok: false, error: 'signed-out', message: 'The Console asked to sign in again. Nothing was written.' };
|
||||
if (response.ok && answer?.ok) return answer;
|
||||
// A refusal names its code. An answer that cannot be read, or one that says the outcome is open,
|
||||
// is not a refusal: the change may have reached the bus.
|
||||
if (typeof answer?.error !== 'string' || answer.sent === 'unknown') return { ...UNKNOWN, message: answer?.sent === 'unknown' && answer.message ? answer.message : UNKNOWN.message };
|
||||
return { ok: false, error: answer.error, message: answer.message || `The Console refused the change (${response.status}). Nothing was written.` };
|
||||
}
|
||||
const find = k => snap?.tasks.find(t => key(t) === k) || null;
|
||||
async function move(k, state) {
|
||||
const t = find(k);
|
||||
if (!t || !taskWritable(t) || t.state === state) return;
|
||||
notice = { text: `Moving ${shortId(t.ref)} to ${STATE_LABEL[state]}…` }; paint();
|
||||
const r = await write({ op: 'edit', org: t.org, task_ref: t.ref, expect: t.digest, state });
|
||||
notice = r.ok ? { text: `${shortId(t.ref)} moved to ${STATE_LABEL[state]} in ${orgOf(t.org).label}.` } : { error: true, text: r.unknown ? `${shortId(t.ref)}: ${r.message}` : `${shortId(t.ref)} was not moved. ${r.message}` };
|
||||
try { await reload(); } catch {}
|
||||
paint();
|
||||
}
|
||||
|
||||
// ---------- dialog (create and edit) ----------
|
||||
function dialogEl() {
|
||||
let d = document.getElementById('tb-dialog');
|
||||
if (!d) {
|
||||
d = document.createElement('dialog');
|
||||
d.id = 'tb-dialog'; d.className = 'tb-dialog'; d.setAttribute('aria-labelledby', 'tb-dialog-h');
|
||||
document.body.append(d);
|
||||
d.addEventListener('close', () => { const back = d.dataset.return; d.innerHTML = ''; opened = null; if (back) document.querySelector(`[data-focus-key="${CSS.escape(back)}"]`)?.focus(); });
|
||||
}
|
||||
return d;
|
||||
}
|
||||
const dateValue = iso => (typeof iso === 'string' && /^\d{4}-\d\d-\d\d/.test(iso) ? iso.slice(0, 10) : '');
|
||||
const roleBoxes = (roles, picked) => (roles.length
|
||||
? `<fieldset class="tb-roles"><legend>Assignees</legend>${roles.map(r => `<label><input type="checkbox" name="assignees" value="${esc(r)}"${picked.includes(r) ? ' checked' : ''}>${esc(r)}</label>`).join('')}</fieldset>`
|
||||
: '<p class="tb-field-note">Assignees: this organization reports no roles.</p>');
|
||||
const prioSelect = p => `<label>Priority<select name="priority">${[0, 1, 2, 3, 4, 5].map(n => `<option value="${n}"${n === (p ?? 0) ? ' selected' : ''}>${n ? `P${n}` : 'None'}</option>`).join('')}</select></label>`;
|
||||
const stateSelect = s => `<label>State<select name="state">${STATES.map(([v, l]) => `<option value="${v}"${v === s ? ' selected' : ''}>${l}</option>`).join('')}</select></label>`;
|
||||
function projectOptions(org, current) {
|
||||
return `<option value="">No project</option>${snap.projects.filter(p => p.org === org).map(p => `<option value="${esc(p.id)}"${p.id === current ? ' selected' : ''}>${esc(p.name || p.key || p.id)}</option>`).join('')}`;
|
||||
}
|
||||
function createForm(orgId) {
|
||||
const orgs = snap.orgs.filter(orgWritable), org = orgs.find(o => o.id === orgId) || orgs[0];
|
||||
return `<form method="dialog" class="tb-form" data-tb-form="create">
|
||||
<h2 id="tb-dialog-h">New task</h2>
|
||||
<label>Organization<select name="org" data-tb-create-org>${orgs.map(o => `<option value="${esc(o.id)}"${o.id === org.id ? ' selected' : ''}>${esc(o.label)}</option>`).join('')}</select></label>
|
||||
<label>Project<select name="group">${projectOptions(org.id, filter.project.startsWith(`${org.id}:`) ? filter.project.slice(org.id.length + 1) : '')}</select></label>
|
||||
<label>Title<input name="title" required maxlength="250" autocomplete="off"></label>
|
||||
<label>Requirement<input name="requirement" required pattern="${REQUIREMENT}" placeholder="REQ-WEB-1" autocomplete="off" aria-describedby="tb-req-note"></label>
|
||||
<p class="tb-field-note" id="tb-req-note">The requirement this task serves, written as REQ-AREA-N.</p>
|
||||
<div class="tb-row2">${stateSelect('todo')}${prioSelect(0)}<label>Due date<input type="date" name="due_date"></label></div>
|
||||
<div data-tb-roles>${roleBoxes(org.roles, [])}</div>
|
||||
<p class="tb-form-status" role="alert" data-tb-form-status></p>
|
||||
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Create task</button></div>
|
||||
</form>`;
|
||||
}
|
||||
function editForm(t) {
|
||||
const o = orgOf(t.org), w = taskWritable(t);
|
||||
const why = t.tracker === 'vikunja' ? 'This task is in Vikunja. The board reads it but does not change it; change it in Vikunja.'
|
||||
: !canWrite ? 'This Console takes no task changes.' : o.state !== 'ok' ? 'This organization\'s bus did not answer.' : !w ? 'The bus did not give this task a version to check a change against.' : '';
|
||||
const facts = `<p class="tb-facts">${av(t.org, 'xs')}${esc(o.label)} · <span class="mono">${esc(shortId(t.ref))}</span> ${trk(t.tracker)}${t.requirement ? ` · <span class="mono">${esc(t.requirement)}</span>` : ''}${projectName(t) ? ` · ${esc(projectName(t))}` : ''}</p>`;
|
||||
if (!w) {
|
||||
return `<div class="tb-form" data-tb-form="view"><h2 id="tb-dialog-h">${title(t)}</h2>${facts}
|
||||
<dl class="tb-dl"><dt>State</dt><dd>${esc(STATE_LABEL[t.state] || 'Not read')}</dd><dt>Assignees</dt><dd>${t.assignees.length ? esc(t.assignees.join(', ')) : 'nobody'}</dd><dt>Priority</dt><dd>${prio(t) || 'none'}</dd><dt>Due</dt><dd>${due(t) || 'none'}</dd></dl>
|
||||
<p class="tb-field-note">${esc(why)}</p>
|
||||
<div class="tb-actions"><button type="button" class="ws-btn" data-tb-cancel>Close</button></div></div>`;
|
||||
}
|
||||
return `<form method="dialog" class="tb-form" data-tb-form="edit" data-tb-key="${esc(key(t))}">
|
||||
<h2 id="tb-dialog-h">Edit ${esc(shortId(t.ref))}</h2>${facts}
|
||||
<label>Title<input name="title" required maxlength="250" value="${esc(t.title ?? '')}" autocomplete="off"></label>
|
||||
<div class="tb-row2">${stateSelect(t.state)}${prioSelect(t.priority)}<label>Due date<input type="date" name="due_date" value="${dateValue(t.dueDate)}"></label></div>
|
||||
${roleBoxes([...new Set([...o.roles, ...t.assignees])].sort(), t.assignees)}
|
||||
<p class="tb-form-status" role="alert" data-tb-form-status></p>
|
||||
<div class="tb-actions"><button type="button" class="ws-btn danger" data-tb-archive>Archive</button><span class="tb-gap"></span><button type="button" class="ws-btn" data-tb-cancel>Cancel</button><button type="submit" class="ws-btn primary" data-tb-submit>Save</button></div>
|
||||
</form>`;
|
||||
}
|
||||
function openDialog(html, back, task = null) {
|
||||
const d = dialogEl();
|
||||
d.innerHTML = html; d.dataset.return = back || ''; opened = task ? structuredClone(task) : null;
|
||||
if (!d.open) d.showModal();
|
||||
d.querySelector('input:not([type=checkbox]),select,button')?.focus();
|
||||
}
|
||||
const formStatus = (form, text) => { const s = form.querySelector('[data-tb-form-status]'); if (s) s.textContent = text; };
|
||||
const due0 = v => (v ? `${v}T00:00:00.000Z` : null);
|
||||
const picked = form => [...form.querySelectorAll('input[name=assignees]:checked')].map(i => i.value).sort();
|
||||
function editBody(form, t) {
|
||||
const f = new FormData(form), body = { op: 'edit', org: t.org, task_ref: t.ref, expect: t.digest };
|
||||
const titleV = String(f.get('title') ?? ''), state = String(f.get('state')), priority = Number(f.get('priority')), dueV = due0(String(f.get('due_date') || '')), who = picked(form);
|
||||
if (titleV !== (t.title ?? '')) body.title = titleV;
|
||||
if (state !== t.state) body.state = state;
|
||||
if (priority !== (t.priority ?? 0)) body.priority = priority;
|
||||
if (dueV !== (t.dueDate ? `${dateValue(t.dueDate)}T00:00:00.000Z` : null)) body.due_date = dueV;
|
||||
if (who.join('\n') !== [...t.assignees].sort().join('\n')) body.assignees = who;
|
||||
return body;
|
||||
}
|
||||
// The competing write may have moved the task out of the board's filters, so the conflict read
|
||||
// asks for its one organization unfiltered. The task was just changed, so the cap keeps it.
|
||||
async function current(t) {
|
||||
try {
|
||||
const r = await fetch(`/api/workspace?${new URLSearchParams({ sections: 'tasks', org: t.org })}`, { cache: 'no-store', redirect: 'manual' });
|
||||
if (r.type === 'opaqueredirect' || !r.ok) return null;
|
||||
const b = await r.json();
|
||||
return (Array.isArray(b?.tasks) && b.tasks.find(x => x.org === t.org && x.ref === t.ref)) || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function submit(form) {
|
||||
const kind = form.dataset.tbForm, btns = form.querySelectorAll('button');
|
||||
if (!form.reportValidity()) return;
|
||||
let body, t = null;
|
||||
if (kind === 'create') {
|
||||
const f = new FormData(form);
|
||||
body = { op: 'create', org: String(f.get('org')), title: String(f.get('title')), requirement: String(f.get('requirement')), state: String(f.get('state')), priority: Number(f.get('priority')), assignees: picked(form) };
|
||||
const group = String(f.get('group') || ''); if (group) body.group = group;
|
||||
const d = due0(String(f.get('due_date') || '')); if (d) body.due_date = d;
|
||||
} else {
|
||||
t = opened && key(opened) === form.dataset.tbKey ? opened : null;
|
||||
if (!t) { formStatus(form, 'The task is no longer on the board. Nothing was written.'); return; }
|
||||
body = editBody(form, t);
|
||||
if (Object.keys(body).length === 4) { formStatus(form, 'Nothing changed.'); return; }
|
||||
}
|
||||
btns.forEach(b => { b.disabled = true; }); formStatus(form, 'Sending…');
|
||||
const r = await write(body);
|
||||
btns.forEach(b => { b.disabled = false; });
|
||||
if (!r.ok) {
|
||||
formStatus(form, r.message);
|
||||
// The task changed under the form: the form shows the current task and keeps the refusal.
|
||||
if (r.error === 'task-conflict' && t) {
|
||||
try { await reload(); } catch {}
|
||||
const now = find(key(t)) || await current(t);
|
||||
if (now) { const msg = r.message; openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, msg); }
|
||||
paint();
|
||||
} else if (r.unknown) { try { await reload(); } catch {} paint(); }
|
||||
return;
|
||||
}
|
||||
const label = orgOf(body.org).label;
|
||||
notice = { text: kind === 'create' ? `${shortId(r.task_ref || '')} created in ${label}.` : `${shortId(t.ref)} saved in ${label}.` };
|
||||
dialogEl().close();
|
||||
try { await reload(); } catch {}
|
||||
paint();
|
||||
}
|
||||
async function archive(form) {
|
||||
const t = opened && key(opened) === form.dataset.tbKey ? opened : null;
|
||||
if (!t) return;
|
||||
if (!form.querySelector('[data-tb-archive]').hasAttribute('data-armed')) {
|
||||
form.querySelector('[data-tb-archive]').setAttribute('data-armed', '');
|
||||
form.querySelector('[data-tb-archive]').textContent = 'Archive: press again to confirm';
|
||||
return;
|
||||
}
|
||||
const r = await write({ op: 'archive', org: t.org, task_ref: t.ref, expect: t.digest });
|
||||
if (!r.ok) { formStatus(form, r.message); if (r.unknown) { try { await reload(); } catch {} paint(); } return; }
|
||||
notice = { text: `${shortId(t.ref)} archived in ${orgOf(t.org).label}.` };
|
||||
dialogEl().close();
|
||||
try { await reload(); } catch {}
|
||||
paint();
|
||||
}
|
||||
|
||||
// ---------- events ----------
|
||||
const inBoard = e => e.target.closest?.('[data-tasks-board]');
|
||||
document.addEventListener('click', e => {
|
||||
const d = e.target.closest?.('#tb-dialog');
|
||||
if (d) {
|
||||
if (e.target.closest('[data-tb-cancel]')) { e.preventDefault(); d.close(); }
|
||||
else if (e.target.closest('[data-tb-archive]')) { e.preventDefault(); archive(e.target.closest('form')); }
|
||||
return;
|
||||
}
|
||||
if (!inBoard(e) || !snap) return;
|
||||
const lay = e.target.closest('[data-tb-layout]');
|
||||
if (lay) { layouts[phone() ? 'phone' : 'wide'] = lay.dataset.tbLayout; save(LAYOUT, JSON.stringify(layouts)); paint(); return; }
|
||||
const chip = e.target.closest('[data-tb-org]');
|
||||
if (chip) {
|
||||
filter.org = chip.dataset.tbOrg;
|
||||
if (filter.project && filter.project !== '-' && filter.org && !filter.project.startsWith(`${filter.org}:`)) { filter.project = ''; refilter(); } else paint();
|
||||
return;
|
||||
}
|
||||
const open = e.target.closest('[data-tb-open]');
|
||||
if (open) { const t = find(open.dataset.tbOpen); if (t) openDialog(editForm(t), open.dataset.focusKey, t); return; }
|
||||
const add = e.target.closest('[data-tb-new]');
|
||||
if (add && !add.disabled) openDialog(createForm(filter.org), 'tb-new');
|
||||
});
|
||||
document.addEventListener('change', e => {
|
||||
if (e.target.matches?.('[data-tb-create-org]')) {
|
||||
const form = e.target.closest('form'), org = orgOf(e.target.value);
|
||||
form.querySelector('select[name=group]').innerHTML = projectOptions(org.id, '');
|
||||
form.querySelector('[data-tb-roles]').innerHTML = roleBoxes(org.roles, []);
|
||||
return;
|
||||
}
|
||||
if (!inBoard(e) || !snap) return;
|
||||
const f = e.target.dataset?.tbFilter;
|
||||
if (f) { filter[f] = e.target.value; refilter(); return; }
|
||||
if (e.target.matches('[data-tb-move]')) move(e.target.dataset.tbMove, e.target.value);
|
||||
});
|
||||
document.addEventListener('input', e => {
|
||||
if (!e.target.matches?.('[data-tb-q]') || !inBoard(e) || !snap) return;
|
||||
filter.q = e.target.value; paint('results');
|
||||
});
|
||||
document.addEventListener('submit', e => {
|
||||
const form = e.target.closest?.('#tb-dialog form');
|
||||
if (!form) return;
|
||||
e.preventDefault(); submit(form);
|
||||
});
|
||||
// Drag and drop between columns: an enhancement over the list's State control and the edit form.
|
||||
document.addEventListener('dragstart', e => {
|
||||
const c = e.target.closest?.('[data-tasks-board] .tb-card[draggable=true]');
|
||||
if (!c) return;
|
||||
dragging = c.dataset.tbTask; dragEnded = new Promise(r => { c.addEventListener('dragend', r, { once: true }); });
|
||||
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragging);
|
||||
c.classList.add('is-dragging');
|
||||
});
|
||||
document.addEventListener('dragend', () => { dragging = null; document.querySelectorAll('.tb-col.is-over').forEach(c => c.classList.remove('is-over')); });
|
||||
document.addEventListener('dragover', e => {
|
||||
const col = dragging && e.target.closest?.('[data-tasks-board] [data-tb-col]');
|
||||
if (!col) return;
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
|
||||
document.querySelectorAll('.tb-col.is-over').forEach(c => { if (c !== col) c.classList.remove('is-over'); });
|
||||
col.classList.add('is-over');
|
||||
});
|
||||
document.addEventListener('drop', e => {
|
||||
const col = e.target.closest?.('[data-tasks-board] [data-tb-col]');
|
||||
if (!col || !dragging) return;
|
||||
e.preventDefault();
|
||||
const k = dragging; dragging = null;
|
||||
move(k, col.dataset.tbCol);
|
||||
});
|
||||
// The layout default follows the width when the viewer has not picked one for it.
|
||||
window.matchMedia('(max-width:759px)').addEventListener('change', () => paint());
|
||||
|
||||
window.MosaicTasksBoard = Object.freeze({ view });
|
||||
})();
|
||||
@@ -191,8 +191,10 @@
|
||||
}).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;
|
||||
@@ -210,11 +212,12 @@
|
||||
|
||||
// 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>';
|
||||
@@ -269,7 +272,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 +327,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 +376,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 +387,11 @@
|
||||
}
|
||||
document.addEventListener('change', e => {
|
||||
const select = e.target.closest?.('[data-agents-project]');
|
||||
const box = select && document.querySelector('[data-agents-available]');
|
||||
if (!box || !agentsAvailable || !window.MosaicOrg) return;
|
||||
agentsPick = { ...agentsPick, [window.MosaicOrg.id]: select.value };
|
||||
box.innerHTML = agentsAvailable(select.value);
|
||||
const org = select?.dataset.agentsProject, draw = org && Object.hasOwn(agentsAvailable, org) ? agentsAvailable[org] : null;
|
||||
const box = draw && select.closest('[data-org-agents]')?.querySelector('[data-agents-available]');
|
||||
if (!box) return;
|
||||
agentsPick = { ...agentsPick, [org]: select.value };
|
||||
box.innerHTML = draw(select.value);
|
||||
});
|
||||
|
||||
// ---------- across organizations (U3: samples 02, 04 and 05) ----------
|
||||
@@ -735,6 +740,8 @@
|
||||
clearTimeout(typing); typing = setTimeout(() => document.dispatchEvent(new CustomEvent('mosaic-workspace-render')), 250);
|
||||
});
|
||||
|
||||
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, refresh: () => refresh(), home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
|
||||
// U4b: the org mark (colour and initials) for the task board (tasks-board.js).
|
||||
const orgMark = Object.freeze({ ready: ensureThemes, av, colours, initials });
|
||||
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, orgMark, refresh: () => refresh(), av, colours, label, state, dot, themes: ensureThemes, home, projectsPage, inboxPage, setGrouping, get pollMs() { return pollMs; } });
|
||||
refresh();
|
||||
})();
|
||||
@@ -22,6 +22,7 @@ import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
|
||||
import { consoleRestartHTTP } from './console-restart-http.mjs';
|
||||
import { workspaceHTTP } from './workspace-read.mjs';
|
||||
import { agentLevelsHTTP } from './agent-levels-read.mjs';
|
||||
import { TASK_WRITE_ROUTE, workspaceTasksHTTP } from './workspace-tasks.mjs';
|
||||
import { actor } from './action-trail.mjs';
|
||||
import { devHostName } from '../../cli/src/dev-local-owner.mjs';
|
||||
|
||||
@@ -59,12 +60,12 @@ const files = new Map([
|
||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
|
||||
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js', 'tasks-board.js', 'panes.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
|
||||
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
|
||||
['/files.css', ['files.css', 'text/css; charset=utf-8']],
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css', 'tasks-board.css', 'panes.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
['/display.js', ['display.js', 'text/javascript; charset=utf-8']],
|
||||
['/inbox.js', ['inbox.js', 'text/javascript; charset=utf-8']],
|
||||
['/inbox.css', ['inbox.css', 'text/css; charset=utf-8']],
|
||||
@@ -811,6 +812,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
registered.add('GET', '/api/workspace', (req, res, context) => workspaceHTTP(req, res, { ...context, organizations, log, ...(workspaceTimeoutMs === undefined ? {} : { timeoutMs: workspaceTimeoutMs }) }));
|
||||
// #1873 A1: the agent levels behind the Agents grid. Read-only; owner only, like the workspace.
|
||||
registered.add('GET', '/api/agents/levels', (req, res, context) => agentLevelsHTTP(req, res, { ...context, organizations, levels: agentLevels, log, ...(workspaceTimeoutMs === undefined ? {} : { timeoutMs: workspaceTimeoutMs }) }));
|
||||
// U4b (#1873): the task board's writes, each to the one organization it names (workspace-tasks.mjs).
|
||||
registered.add('POST', TASK_WRITE_ROUTE, (req, res, context) => workspaceTasksHTTP(req, res, { ...context, organizations, trail, log }));
|
||||
const upstream = boardURL(board);
|
||||
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
|
||||
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
|
||||
|
||||
@@ -23,6 +23,16 @@ const PROJECT_SELECTOR = /^([a-z0-9][a-z0-9._-]{0,63}):([A-Za-z0-9._-]{1,128})$/
|
||||
const SHELL_SAFE = /^[A-Za-z0-9._:-]{1,128}$/;
|
||||
const WEEK_MS = 7 * 24 * 3600 * 1000;
|
||||
const PER_ORG_CONCURRENCY = 3;
|
||||
// U4b (#1873): the task board's states, the built-in store's (broker STORE_STATES), in board order.
|
||||
export const TASK_STATES = Object.freeze(['todo', 'in-progress', 'in-review', 'blocked', 'done']);
|
||||
// U4b: at most this many tasks per organization reach one answer. The bus's tasks read has no
|
||||
// cursor for the merged form, so the cap bounds the answer and the board's DOM. It applies last:
|
||||
// projects, metrics, sessions and the inbox count every task, and the filters run before it, so a
|
||||
// filter reaches every task the org holds. An org past it keeps its most recently changed tasks
|
||||
// and says how many matched (`tasksHeld`); the board says the list is cut.
|
||||
export const TASKS_PER_ORG = 500;
|
||||
const ROLE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
|
||||
// The broker reads each section needs. Each name is one human-side read the org pages already make.
|
||||
const NEEDS = {
|
||||
@@ -57,7 +67,7 @@ function oneOf(name, value, allowed) {
|
||||
// Strict: an unknown or repeated key is refused, so a misspelled filter never reads as "all".
|
||||
export function parseWorkspaceQuery(search = '') {
|
||||
const params = new URLSearchParams(search), seen = new Set();
|
||||
const q = { sections: new Set(WORKSPACE_SECTIONS), orgs: null, status: new Set(['active']), state: null, name: null, sort: 'activity', order: null, project: null };
|
||||
const q = { sections: new Set(WORKSPACE_SECTIONS), orgs: null, status: new Set(['active']), state: null, name: null, sort: 'activity', order: null, project: null, taskState: null, assignee: null, label: null };
|
||||
for (const [key, value] of params) {
|
||||
if (seen.has(key)) bad(`${key} is given twice`);
|
||||
seen.add(key);
|
||||
@@ -77,6 +87,17 @@ export function parseWorkspaceQuery(search = '') {
|
||||
const m = PROJECT_SELECTOR.exec(value);
|
||||
if (!m) bad('project takes <org>:<project id or key>');
|
||||
q.project = { org: m[1], key: m[2] };
|
||||
// U4b: the task filters. A Vikunja task whose bucket the bus does not name has state null and
|
||||
// matches only `open`.
|
||||
} else if (key === 'task-state') q.taskState = oneOf('task-state', value, [...TASK_STATES, 'open']);
|
||||
else if (key === 'assignee') {
|
||||
const names = list(value);
|
||||
if (!names.length || names.some(n => !ROLE_NAME.test(n))) bad('assignee takes role names, comma separated');
|
||||
q.assignee = new Set(names);
|
||||
} else if (key === 'label') {
|
||||
const ids = list(value).map(Number);
|
||||
if (!ids.length || ids.some(n => !Number.isSafeInteger(n) || n < 1)) bad('label takes label ids, comma separated');
|
||||
q.label = new Set(ids);
|
||||
} else bad(`unknown query key ${key}`);
|
||||
}
|
||||
q.order ??= q.sort === 'activity' ? 'desc' : 'asc';
|
||||
@@ -172,7 +193,8 @@ function validRead(name, value) {
|
||||
if (name === 'claims') return strings(v, ['thread', 'instance', 'holder_run', 'harness', 'at']);
|
||||
if (name === 'inbox') return string(v.id) && strings(v, ['question', 'class', 'raised_by_role', 'task_ref', 'at']) && optional(v.options, a => records(a) && a.every(o => strings(o, ['key', 'text'])));
|
||||
if (name === 'agents') return strings(v, ['role', 'holder_run', 'harness', 'state', 'at']) && optional(v.working, w => record(w) && strings(w, ['since'])) && ['inputNeeded', 'stranded'].every(k => optional(v[k], records)) && (v.inputNeeded ?? []).every(d => strings(d, ['task_ref', 'at']));
|
||||
if (name === 'tasks') return strings(v, ['task_ref', 'project_key', 'at']) && optional(v.fields, f => record(f) && strings(f, ['title', 'bucket']) && ['assignees', 'labels'].every(k => optional(f[k], Array.isArray)));
|
||||
// A Vikunja row's bucket is the bucket's numeric id (tasks digest.mjs taskFields); a store row's is its state.
|
||||
if (name === 'tasks') return strings(v, ['task_ref', 'project_key', 'at']) && optional(v.fields, f => record(f) && strings(f, ['title']) && optional(f.bucket, b => string(b) || Number.isSafeInteger(b)) && ['assignees', 'labels'].every(k => optional(f[k], Array.isArray)));
|
||||
if (name === 'decisions') return strings(v, ['at']) && optional(v.closed, c => record(c) && strings(c, ['op', 'at']));
|
||||
return true;
|
||||
});
|
||||
@@ -188,14 +210,25 @@ function orgRows(org, data, doc, nowMs) {
|
||||
const byId = new Map(projects.map(p => [p.id, p]));
|
||||
const ref = p => (p ? { id: p.id, key: p.repo ?? null, name: p.name ?? null } : null);
|
||||
const threadProject = new Map(arr(data.threads).map(t => [t.id, byId.get(t.project) ?? null]));
|
||||
const taskRows = arr(data.tasks).filter(t => t && typeof t.task_ref === 'string' && !t.fields?.gone);
|
||||
const taskRows = arr(data.tasks).filter(t => t && typeof t.task_ref === 'string' && !t.fields?.gone && t.archived !== true);
|
||||
const bots = new Map(Object.entries(doc?.roles ?? {}).filter(([, r]) => r?.tracker?.botId !== undefined).map(([role, r]) => [r.tracker.botId, role]));
|
||||
const tasks = taskRows.map(t => {
|
||||
const f = t.fields ?? {};
|
||||
// U4b: a built-in store row (source 'store') carries role names, its state and its group; the
|
||||
// board files a store task under the org project whose id or key is its group. A Vikunja row
|
||||
// carries bot ids, which map to roles through the org file, and a numeric bucket the bus does
|
||||
// not name, so its state is only known when it is done.
|
||||
const store = t.source === 'store';
|
||||
const state = store ? (TASK_STATES.includes(f.state) ? f.state : null) : (f.done ? 'done' : null);
|
||||
const group = store && typeof f.group === 'string' ? f.group : null;
|
||||
const project = store ? (group === null ? null : byId.get(group) ?? byKey.get(group) ?? null) : (typeof t.project_key === 'string' ? byKey.get(t.project_key) : null);
|
||||
return {
|
||||
org: id, ref: t.task_ref, tracker: org.tracker, title: f.title ?? null, done: Boolean(f.done), bucket: f.bucket ?? null,
|
||||
assignees: arr(f.assignees).filter(u => bots.has(u)).map(u => bots.get(u)).sort(), labels: arr(f.labels), priority: t.priority ?? null,
|
||||
requirement: t.requirement ?? null, project: ref(typeof t.project_key === 'string' ? byKey.get(t.project_key) : null), changedAt: t.at ?? null,
|
||||
org: id, ref: t.task_ref, tracker: store ? 'built-in' : t.source === undefined ? org.tracker : 'vikunja', title: f.title ?? null, done: Boolean(f.done), bucket: f.bucket ?? null,
|
||||
state, assignees: store ? arr(f.assignees).filter(r => typeof r === 'string').sort() : arr(f.assignees).filter(u => bots.has(u)).map(u => bots.get(u)).sort(),
|
||||
labels: arr(f.labels), priority: t.priority ?? null, dueDate: typeof f.due_date === 'string' ? f.due_date : null,
|
||||
requirement: t.requirement ?? null, project: ref(project), group, changedAt: t.at ?? null,
|
||||
// The digest a write sends back as `expect`; only a store task is written from the board.
|
||||
digest: store && typeof t.digest === 'string' && DIGEST.test(t.digest) ? t.digest : null, writable: store,
|
||||
};
|
||||
});
|
||||
const taskByRef = new Map(tasks.map(t => [t.ref, t]));
|
||||
@@ -303,8 +336,20 @@ export async function readWorkspace({ organizations, session, query = parseWorks
|
||||
if (query.sections.has('inbox')) body.inbox = merged.inbox.sort((a, b) => (a.class === 'gated' ? 0 : 1) - (b.class === 'gated' ? 0 : 1) || (a.at ?? '').localeCompare(b.at ?? ''));
|
||||
if (query.sections.has('tasks')) {
|
||||
const p = query.project;
|
||||
body.tasks = merged.tasks.filter(t => !p || (t.org === p.org && (t.project?.id === p.key || t.project?.key === p.key)))
|
||||
.sort((a, b) => a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref));
|
||||
const matched = merged.tasks.filter(t => (!p || (t.org === p.org && (t.project?.id === p.key || t.project?.key === p.key)))
|
||||
&& (!query.taskState || query.taskState.has(t.state ?? 'open'))
|
||||
&& (!query.assignee || t.assignees.some(a => query.assignee.has(a)))
|
||||
&& (!query.label || t.labels.some(l => query.label.has(l))));
|
||||
// The cap, per org, after the filters: the most recently changed tasks stay.
|
||||
const byOrg = Map.groupBy(matched, t => t.org), kept = [];
|
||||
for (const r of read) {
|
||||
const rows = byOrg.get(r.org.org) ?? [];
|
||||
if (rows.length > TASKS_PER_ORG) {
|
||||
r.org.tasksHeld = rows.length;
|
||||
kept.push(...rows.sort((a, b) => (b.changedAt ?? '').localeCompare(a.changedAt ?? '') || b.ref.localeCompare(a.ref, undefined, { numeric: true })).slice(0, TASKS_PER_ORG));
|
||||
} else kept.push(...rows);
|
||||
}
|
||||
body.tasks = kept.sort((a, b) => a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref, undefined, { numeric: true }));
|
||||
}
|
||||
if (query.sections.has('metrics')) {
|
||||
const m = merged.metrics;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// U4b (#1873, PRD 1.20): the task board's writes, POST /api/workspace/tasks.
|
||||
//
|
||||
// One write goes to one organization's bus. The body names the org; the org is resolved for this
|
||||
// session through the same admission the org pages use (organizations.mjs `resolve`), and the write
|
||||
// goes out on that org's own transport (`taskWrite`, console-bus.mjs) and nowhere else. The verbs are
|
||||
// the person's built-in store verbs (tasks.create, tasks.edit, tasks.archive): the bus runs its own
|
||||
// human check on every one, so a Console started by an agent gets human-required and writes nothing.
|
||||
//
|
||||
// The route table's hook fence has already required a signed-in session on the page's own name, the
|
||||
// exact Origin and Sec-Fetch-Site: same-origin. This handler adds the owner, --send and a strict JSON
|
||||
// body. A refusal is answered with the bus's code and a fixed message; nothing is retried.
|
||||
import { actor } from './action-trail.mjs';
|
||||
import { TASK_STATES } from './workspace-read.mjs';
|
||||
|
||||
export const TASK_WRITE_ROUTE = '/api/workspace/tasks';
|
||||
const MAX_BODY = 16384;
|
||||
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const STORE_REF = /^task:[1-9][0-9]{0,14}$/;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const REQUIREMENT = /^REQ-[A-Z]+-[1-9][0-9]*$/;
|
||||
const ROLE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const DUE = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/;
|
||||
// Each op: its bus verb, the keys it takes and the ones it needs. The board edits title, state,
|
||||
// assignees, priority and due date; a create also names the requirement and the project (group).
|
||||
const OPS = {
|
||||
create: { verb: 'tasks.create', keys: ['title', 'requirement', 'group', 'state', 'assignees', 'priority', 'due_date'], need: ['title', 'requirement'] },
|
||||
// An edit or archive names the digest the board read, so a change made since is a task-conflict
|
||||
// and never a silent overwrite. The bus checks it; the route refuses a write without one.
|
||||
edit: { verb: 'tasks.edit', keys: ['task_ref', 'expect', 'title', 'state', 'assignees', 'priority', 'due_date'], need: ['task_ref', 'expect'] },
|
||||
archive: { verb: 'tasks.archive', keys: ['task_ref', 'expect'], need: ['task_ref', 'expect'] },
|
||||
};
|
||||
const FIELD_CHECKS = {
|
||||
task_ref: v => typeof v === 'string' && STORE_REF.test(v),
|
||||
expect: v => typeof v === 'string' && DIGEST.test(v),
|
||||
title: v => typeof v === 'string' && v.trim().length >= 1 && v.length <= 250 && !v.includes('\0'),
|
||||
requirement: v => typeof v === 'string' && REQUIREMENT.test(v),
|
||||
group: v => v === null || (typeof v === 'string' && v.length >= 1 && v.length <= 64 && !/[\u0000-\u001f\u007f]/.test(v)),
|
||||
state: v => TASK_STATES.includes(v),
|
||||
assignees: v => Array.isArray(v) && v.length <= 50 && v.every(r => typeof r === 'string' && ROLE_NAME.test(r)),
|
||||
priority: v => Number.isSafeInteger(v) && v >= 0 && v <= 5,
|
||||
due_date: v => v === null || (typeof v === 'string' && DUE.test(v) && Number.isFinite(Date.parse(v)) && new Date(v).toISOString() === v),
|
||||
};
|
||||
|
||||
// The answers a person reads on the board, by bus code. Each says whether anything was written.
|
||||
const REFUSALS = {
|
||||
'human-required': [403, 'The bus takes task changes only from a person at the Console. Nothing was written.'],
|
||||
'tracker-not-store': [409, 'This organization keeps its tasks in Vikunja. The board does not write to Vikunja; nothing was written.'],
|
||||
'task-conflict': [409, 'The task changed after the board read it. Nothing was written; the board now shows the current task.'],
|
||||
'task-archived': [409, 'The task is archived. Nothing was written.'],
|
||||
'task-already-archived': [409, 'The task was already archived. Nothing was written.'],
|
||||
'task-not-found': [404, 'The task is not on this organization\'s bus. Nothing was written.'],
|
||||
'task-position-full': [409, 'This project has no room for another task position. Nothing was written.'],
|
||||
'invalid-state': [400, 'The bus does not have that state. Nothing was written.'],
|
||||
'unknown-role': [400, 'An assignee is not a role in this organization. Nothing was written.'],
|
||||
'invalid-request': [400, 'The bus refused the change as invalid. Nothing was written.'],
|
||||
'credential-leak': [400, 'The change holds part of a stored credential, so the bus refused it. Nothing was written.'],
|
||||
'no-bus-host': [503, 'This organization\'s bus is not running. Nothing was written.'],
|
||||
'outcome-unknown': [503, 'The bus did not answer. The change may or may not have been written; the board shows it on its next read.'],
|
||||
};
|
||||
const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large', 'outcome-unknown-revoked'];
|
||||
|
||||
const json = (res, status, body) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
const one = (req, name) => (req.headersDistinct?.[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
||||
class BodyError extends Error { constructor(status, message) { super(message); this.status = status; } }
|
||||
|
||||
async function readBody(req) {
|
||||
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(one(req, 'content-type') ?? '')) throw new BodyError(415, 'JSON body required');
|
||||
let size = 0;
|
||||
const chunks = [];
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY) throw new BodyError(413, 'body too large');
|
||||
chunks.push(chunk);
|
||||
}
|
||||
let value;
|
||||
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new BodyError(400, 'invalid JSON'); }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BodyError(400, 'the body is an object');
|
||||
return value;
|
||||
}
|
||||
|
||||
// Strict: an unknown key, a missing required key or a malformed value is refused before any bus.
|
||||
export function parseTaskWrite(body) {
|
||||
const { op, org, ...rest } = body;
|
||||
const spec = OPS[op];
|
||||
if (!spec) throw new BodyError(400, `op takes ${Object.keys(OPS).join(', ')}`);
|
||||
if (typeof org !== 'string' || !ORG_ID.test(org)) throw new BodyError(400, 'org takes an organization id');
|
||||
for (const key of Object.keys(rest)) if (!spec.keys.includes(key)) throw new BodyError(400, `${op} does not take ${key}`);
|
||||
for (const key of spec.need) if (!Object.hasOwn(rest, key)) throw new BodyError(400, `${op} needs ${key}`);
|
||||
for (const [key, value] of Object.entries(rest)) if (!FIELD_CHECKS[key](value)) throw new BodyError(400, `${key} is not valid`);
|
||||
if (op === 'edit' && !Object.keys(rest).some(k => !['task_ref', 'expect'].includes(k))) throw new BodyError(400, 'edit needs a field to change');
|
||||
return { op, org, verb: spec.verb, args: rest };
|
||||
}
|
||||
|
||||
// `organizations.resolve(id, session)` admits only an org this session belongs to and answers its
|
||||
// options, `taskWrite` among them. Nothing here holds another org's transport.
|
||||
export async function workspaceTasksHTTP(req, res, { session = null, owner = false, send = false, organizations = null, trail = null, log = () => {} }) {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
if (!organizations || typeof organizations.resolve !== 'function') return json(res, 404, { error: 'org-unavailable' });
|
||||
if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'the task board\'s changes are the owner\'s' });
|
||||
if (!send) return json(res, 403, { error: 'read-only', message: 'start the Console with --send to make changes' });
|
||||
let write;
|
||||
try { write = parseTaskWrite(await readBody(req)); }
|
||||
catch (e) { if (e instanceof BodyError) return json(res, e.status, { error: 'invalid-request', message: e.message, sent: false }); throw e; }
|
||||
let selected;
|
||||
try { selected = await organizations.resolve(write.org, session); }
|
||||
catch (e) {
|
||||
if (e?.code === 'org-archived') return json(res, 409, { error: 'org-archived', message: 'organization is archived', sent: false });
|
||||
return json(res, 404, { error: 'org-unavailable', sent: false });
|
||||
}
|
||||
if (!selected) return json(res, 404, { error: 'org-unavailable', sent: false });
|
||||
if (selected.busHealthy === false) return json(res, 503, { error: 'no-bus-host', message: REFUSALS['no-bus-host'][1], sent: false });
|
||||
if (typeof selected.taskWrite !== 'function') return json(res, 403, { error: 'read-only', message: 'this organization takes no task changes from this Console', sent: false });
|
||||
const note = outcome => {
|
||||
try { trail?.({ by: actor(session), org: write.org, action: write.verb, ...(write.args.task_ref ? { task: write.args.task_ref } : {}), outcome }); }
|
||||
catch { log(`${new Date().toISOString()} trail write failed for ${write.verb}`); }
|
||||
};
|
||||
let result;
|
||||
try { result = await selected.taskWrite(write.verb, write.args); }
|
||||
catch (e) {
|
||||
const code = typeof e?.code === 'string' && /^[a-z-]{1,64}$/.test(e.code) ? e.code : 'outcome-unknown';
|
||||
const open = OPEN.includes(code);
|
||||
const [status, message] = REFUSALS[open ? 'outcome-unknown' : code] ?? [502, `The bus refused the change (${code}). Nothing was written.`];
|
||||
log(`${new Date().toISOString()} task board: ${write.verb} on ${write.org} refused ${code}`);
|
||||
note(open ? 'unknown' : `refused ${code}`);
|
||||
return json(res, status, { error: open ? 'outcome-unknown' : code, message, sent: open ? 'unknown' : false });
|
||||
}
|
||||
note('written');
|
||||
const task = result && typeof result === 'object' && typeof result.task_ref === 'string' ? result.task_ref : write.args.task_ref ?? null;
|
||||
return json(res, 200, { ok: true, org: write.org, op: write.op, task_ref: task, digest: typeof result?.digest === 'string' ? result.digest : null });
|
||||
}
|
||||
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it.
|
||||
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', '/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,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,313 @@
|
||||
// U4b (#1873, PRD 1.20): the task board across organizations (sample 06) in a real browser.
|
||||
// Three orgs: forge keeps its tasks in the built-in store, ddigital in Vikunja, home's bus is down.
|
||||
// Every bus is a fake in this file; each org's taskWrite records what reached that org only.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
|
||||
const D = n => String(n).repeat(64);
|
||||
|
||||
function boardOrgs({ refuse = null, tasksFail = [] } = {}) {
|
||||
const ORGS = [
|
||||
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
|
||||
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
|
||||
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
|
||||
];
|
||||
const DOCS = {
|
||||
ddigital: { appearance: { theme: 'rosewood' }, tracker: { kind: 'vikunja' }, roles: { copywriter: { tracker: { botId: 7 } }, editor: { tracker: { botId: 8 } } } },
|
||||
forge: { appearance: { theme: 'cobalt' }, roles: { coder: {}, reviewer: {} } },
|
||||
home: { appearance: { theme: 'grove' } },
|
||||
};
|
||||
const PROJECTS = {
|
||||
ddigital: [{ id: 'spring-launch', repo: 'spring', name: 'Spring launch', at: '2026-11-09T10:00:00.000Z' }],
|
||||
forge: [{ id: 'api-gateway', repo: 'gateway', name: 'API gateway', at: '2026-11-09T10:00:00.000Z' }, { id: 'billing', repo: 'billing', name: 'Billing', at: '2026-11-08T10:00:00.000Z' }],
|
||||
};
|
||||
const store = [
|
||||
{ n: 41, title: 'Rate limit the public API', state: 'todo', group: 'api-gateway', assignees: ['coder'], priority: 4, due: '2026-11-20T00:00:00.000Z' },
|
||||
{ n: 42, title: 'Invoice export', state: 'in-progress', group: 'billing', assignees: [], priority: 0, due: null },
|
||||
{ n: 43, title: 'Retire the v1 gateway', state: 'done', group: 'api-gateway', assignees: ['reviewer'], priority: 2, due: null },
|
||||
].map(t => ({ ...t, rev: 0 }));
|
||||
// A task's digest changes with every write to it, as the store's does.
|
||||
const digest = t => D((t.n + t.rev) % 10);
|
||||
const TASKS = {
|
||||
forge: () => store.map(t => ({ source: 'store', task_ref: `task:${t.n}`, at: '2026-11-09T12:00:00.000Z', priority: t.priority, digest: digest(t), requirement: 'REQ-API-1',
|
||||
fields: { title: t.title, state: t.state, group: t.group, assignees: t.assignees, due_date: t.due } })),
|
||||
ddigital: () => [
|
||||
{ task_ref: 'vikunja:3/112', project_key: 'spring', priority: 3, at: '2026-11-09T11:00:00.000Z', fields: { title: 'Launch email, draft 2', done: false, assignees: [7], bucket: 12, labels: ['launch'] } },
|
||||
{ task_ref: 'vikunja:3/118', project_key: 'spring', priority: 0, at: '2026-11-08T11:00:00.000Z', fields: { title: 'Teaser reel', done: true, assignees: [8], bucket: 14, labels: [] } },
|
||||
],
|
||||
};
|
||||
const writes = [];
|
||||
const taskWrite = org => async (verb, args) => {
|
||||
writes.push([org, verb, args]);
|
||||
if (refuse) throw Object.assign(new Error('refused'), { code: refuse });
|
||||
const t = store.find(s => `task:${s.n}` === args.task_ref);
|
||||
if (t && args.expect && args.expect !== digest(t)) throw Object.assign(new Error('conflict'), { code: 'task-conflict' });
|
||||
if (verb === 'tasks.edit' && t) { for (const f of ['title', 'state', 'assignees']) if (args[f] !== undefined) t[f] = args[f]; t.rev++; }
|
||||
return { task_ref: args.task_ref, digest: t ? digest(t) : D(9) };
|
||||
};
|
||||
const options = id => ({
|
||||
bus: { inbox: async () => [], agents: async () => [], 'thread.claims': async () => [], tasks: async () => { if (tasksFail.includes(id)) throw Object.assign(new Error('tasks'), { code: 'tracker-unavailable' }); return TASKS[id](); }, messages: async () => ({ messages: [], unread: 0 }) },
|
||||
chat: { projects: async () => PROJECTS[id], threads: async () => [] },
|
||||
});
|
||||
const healthy = async () => ({ healthy: true, pid: 4242, boot: null });
|
||||
const down = async () => ({ healthy: false, pid: null, boot: null });
|
||||
return {
|
||||
writes, store,
|
||||
list: async () => ORGS,
|
||||
members: async () => ORGS.map(o => ({ id: o.id, doc: DOCS[o.id], status: o.busHealthy ? healthy : down, options: () => options(o.id) })),
|
||||
resolve: async id => {
|
||||
const org = ORGS.find(o => o.id === id); if (!org) return null;
|
||||
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] }, taskWrite: taskWrite(id) };
|
||||
return { busHealthy: true, reads: { business: async () => ({ business: { id, projects: PROJECTS[id].map(p => p.id) } }) }, ...options(id), taskWrite: taskWrite(id) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function open(t, { refuse = null, tasksFail = [], send = true, width = 1440, height = 900, orgs = boardOrgs({ refuse, tasksFail }) } = {}) {
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: orgs, ...(send ? { send: async () => ({}) } : {}), log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
await b.viewport(width, height);
|
||||
const url = `http://127.0.0.1:${server.address().port}/#/orgs/forge/tasks`;
|
||||
await b.navigate(url);
|
||||
const wait = waitFor(b);
|
||||
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
|
||||
return { b, wait, orgs, url };
|
||||
}
|
||||
const cards = b => b.evaluate('[...document.querySelectorAll("[data-tasks-board] [data-tb-task]")].map(e => e.dataset.tbTask)');
|
||||
|
||||
test('the board merges every organization\'s tasks, tags each with its org, and a down org keeps its state', async t => {
|
||||
const { b } = await open(t);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'kanban');
|
||||
assert.deepEqual((await cards(b)).sort(), ['ddigital|vikunja:3/112', 'ddigital|vikunja:3/118', 'forge|task:41', 'forge|task:42', 'forge|task:43']);
|
||||
// Each card carries its org's mark and the id in its tracker's form.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-card")].map(c => [c.dataset.org, c.querySelector(".id").textContent, !!c.querySelector(".ws-av"), c.querySelector(".tb-trk").textContent]).sort()'),
|
||||
[['ddigital', 'V-112', true, 'Vikunja'], ['ddigital', 'V-118', true, 'Vikunja'], ['forge', '#41', true, 'built-in'], ['forge', '#42', true, 'built-in'], ['forge', '#43', true, 'built-in']]);
|
||||
// Columns: the store's states, plus one for the Vikunja task whose bucket the bus does not name.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".tb-col h3")].map(h => h.textContent)'), ['To do', 'In progress', 'Review', 'Blocked', 'Done', 'State not read']);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelector("[data-tb-col=todo] [data-tb-task=\'forge|task:41\'] .bot").children].map(e => e.textContent.trim())'), ['API gateway', 'coder', 'P4', 'due 20 Nov']);
|
||||
// Only built-in store cards can be dragged; a Vikunja card is read only.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-task=\'forge|task:41\']").draggable'), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-task=\'ddigital|vikunja:3/112\']").draggable'), false);
|
||||
// home's bus is down: it shows its state and reason, and the other orgs still render.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-tracker]")].map(e => [e.dataset.tbTracker, e.dataset.state])'), [['ddigital', 'ok'], ['forge', 'ok'], ['home', 'down']]);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tb-tracker=home]").textContent'), /Home.*bus down.*the bus is not running/s);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tb-org=home]").textContent'), /Home\s*unavailable/);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /5 tasks from 2 of 3 organizations.*Vikunja tasks are read only here/);
|
||||
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /not built yet/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), false);
|
||||
});
|
||||
|
||||
test('org and project filters narrow the board', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-org=forge]").click()');
|
||||
await wait('document.querySelector("[data-tb-org=forge]").getAttribute("aria-pressed") === "true"');
|
||||
assert.deepEqual((await cards(b)).sort(), ['forge|task:41', 'forge|task:42', 'forge|task:43']);
|
||||
assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 3 of 5 tasks.');
|
||||
// The project list follows the org filter.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-tb-filter=project] option")].map(o => o.value).sort()'), ['', '-', 'forge:api-gateway', 'forge:billing']);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
|
||||
assert.deepEqual(await cards(b), ['forge|task:42']);
|
||||
assert.equal(await b.evaluate('document.querySelector(".tb-count").textContent'), 'Showing 1 task that matches the filters.');
|
||||
// Back to every org; the project filter stays on forge's project.
|
||||
await b.evaluate('document.querySelector("[data-tb-org=\'\']").click()');
|
||||
await wait('document.querySelector("[data-tb-org=\'\']").getAttribute("aria-pressed") === "true"');
|
||||
assert.deepEqual(await cards(b), ['forge|task:42']);
|
||||
// A project of another org, then the assignee filter on a Vikunja role.
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "ddigital:spring-launch"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 2');
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=assignee]"); s.value = "copywriter"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
|
||||
assert.deepEqual(await cards(b), ['ddigital|vikunja:3/112']);
|
||||
});
|
||||
|
||||
test('the layout toggle is remembered across a reload', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "list" && document.querySelector("[data-tb-list]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-layout=list]").getAttribute("aria-pressed")'), 'true');
|
||||
await b.evaluate('location.reload()');
|
||||
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'list');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-tb-list] tr.tb-row").length'), 5);
|
||||
});
|
||||
|
||||
test('a phone opens on List with no sideways scroll, and Kanban is one tap away', async t => {
|
||||
const { b, wait } = await open(t, { width: 390, height: 844 });
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'list');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=kanban]").click()');
|
||||
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "kanban"');
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true);
|
||||
// One column on a phone.
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector(".tb-board")).gridTemplateColumns.split(" ").length'), 1);
|
||||
});
|
||||
|
||||
test('a move writes only to its own org, and a polled repaint keeps focus', async t => {
|
||||
const { b, wait, orgs } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
|
||||
// Vikunja rows have no State control; a store row does.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-move^=ddigital]")'), null);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:41\']"); s.focus(); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('/moved to Review in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
|
||||
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:41', expect: D(1), state: 'in-review' }]]);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-move=\'forge|task:41\']").value'), 'in-review');
|
||||
// The search keeps focus and its caret through the poll's repaint.
|
||||
await b.evaluate('(() => { const q = document.querySelector("[data-tb-q]"); q.focus(); q.value = "inv"; q.dispatchEvent(new Event("input", { bubbles: true })); q.setSelectionRange(1, 1); })()');
|
||||
await wait('document.querySelectorAll("[data-tb-list] tr.tb-row").length === 1');
|
||||
// bus.js repaints the view on its own 10 s poll; wait for that repaint to replace the board.
|
||||
assert.equal(await b.evaluate('(async () => { const before = document.querySelector("[data-tasks-board]"); for (let i = 0; i < 140 && document.querySelector("[data-tasks-board]") === before; i++) await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 50)); return document.querySelector("[data-tasks-board]") !== before; })()'), true);
|
||||
assert.deepEqual(await b.evaluate('[document.activeElement?.dataset.focusKey, document.activeElement?.selectionStart, document.querySelector("[data-tb-q]").value]'), ['tb-q', 1, 'inv']);
|
||||
});
|
||||
|
||||
test('a write the bus refuses for want of a person is shown, and nothing else is written', async t => {
|
||||
const { b, wait, orgs } = await open(t, { refuse: 'human-required' });
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
await wait('document.querySelector("[data-tb-move=\'forge|task:42\']")');
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:42\']"); s.value = "done"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('document.querySelector("[data-tb-notice][role=alert]")');
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tb-notice]").textContent'), /#42 was not moved\. The bus takes task changes only from a person at the Console\. Nothing was written\./);
|
||||
assert.deepEqual(orgs.writes.map(w => w[0]), ['forge']);
|
||||
// The board still shows the task where the bus has it.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-move=\'forge|task:42\']").value'), 'in-progress');
|
||||
// The edit dialog shows the same refusal.
|
||||
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
||||
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
||||
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
||||
await wait('/only from a person/.test(document.querySelector("#tb-dialog [data-tb-form-status]").textContent)');
|
||||
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]);
|
||||
});
|
||||
|
||||
test('a Console without --send shows a read-only board and the write route refuses', async t => {
|
||||
const { b, orgs } = await open(t, { send: false });
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), true);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll(".tb-card[draggable=true]").length'), 0);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /board is read only/);
|
||||
const status = await b.evaluate('fetch("/api/workspace/tasks", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ op: "edit", org: "forge", task_ref: "task:41", state: "done" }) }).then(r => r.status)');
|
||||
assert.equal(status, 403);
|
||||
assert.deepEqual(orgs.writes, []);
|
||||
});
|
||||
|
||||
// bus.js repaints the view on its own 10 s poll; this resolves once that repaint has replaced the board.
|
||||
const POLLED = '(async () => { const before = document.querySelector("[data-tasks-board]"); for (let i = 0; i < 140 && document.querySelector("[data-tasks-board]") === before; i++) await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 50)); return document.querySelector("[data-tasks-board]") !== before; })()';
|
||||
|
||||
test('an edit dialog left open while another writer changes the task saves against what it showed and gets a conflict', async t => {
|
||||
const { b, wait, orgs } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
||||
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
||||
// Another writer renames the task and assigns it; a poll then reads the new version.
|
||||
const t42 = orgs.store.find(s => s.n === 42);
|
||||
Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 });
|
||||
assert.equal(await b.evaluate(POLLED), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export');
|
||||
// Save sends the digest and the changes against the task as the dialog showed it.
|
||||
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
||||
await wait('/changed after the board read it/.test(document.querySelector("#tb-dialog [data-tb-form-status]")?.textContent)');
|
||||
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]]);
|
||||
assert.equal(t42.title, 'Invoice export (renamed elsewhere)');
|
||||
// The dialog reopens on the current task, so the next save is checked against what it now shows.
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']);
|
||||
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
||||
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
|
||||
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), false);
|
||||
});
|
||||
|
||||
test('a poll that answers after focus has moved leaves focus where the person put it', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
|
||||
// Hold the next workspace read until focus has moved from the search to a row's State control.
|
||||
await b.evaluate(`(() => {
|
||||
const real = window.fetch;
|
||||
window.__held = null;
|
||||
window.fetch = (u, o) => String(u).startsWith('/api/workspace?') && !window.__held
|
||||
? new Promise(go => { window.__held = () => real(u, o).then(go); })
|
||||
: real(u, o);
|
||||
document.querySelector("[data-tb-q]").focus();
|
||||
})()`);
|
||||
// bus.js polls every 10 s, longer than waitFor allows.
|
||||
assert.equal(await b.evaluate('(async () => { for (let i = 0; i < 140 && !window.__held; i++) await new Promise(r => setTimeout(r, 100)); return !!window.__held; })()'), true);
|
||||
await b.evaluate('(window.__board = document.querySelector("[data-tasks-board]"), document.querySelector("[data-tb-move=\'forge|task:43\']").focus())');
|
||||
await b.evaluate('window.__held()');
|
||||
await wait('document.querySelector("[data-tasks-board]") !== window.__board');
|
||||
await b.evaluate('new Promise(r => setTimeout(r, 100))');
|
||||
assert.equal(await b.evaluate('document.activeElement?.dataset.focusKey'), 'move:forge|task:43');
|
||||
});
|
||||
|
||||
test('an org whose tasks read fails shows its tasks as not read, not as none', async t => {
|
||||
const { b } = await open(t, { tasksFail: ['forge'] });
|
||||
assert.deepEqual((await cards(b)).sort(), ['ddigital|vikunja:3/112', 'ddigital|vikunja:3/118']);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tb-tracker=forge]").textContent'), /Forge.*tasks not read.*tracker-unavailable/s);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tb-org=forge]").textContent'), /Forge\s*unavailable/);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tasks-board] .sub").textContent'), /2 tasks from 1 of 3 organizations/);
|
||||
// No org that can take a write is left, so New task is off.
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-new]").disabled'), true);
|
||||
});
|
||||
|
||||
test('a board with no tasks read at all says so instead of showing an empty board', async t => {
|
||||
const { b } = await open(t, { tasksFail: ['forge', 'ddigital'] });
|
||||
assert.deepEqual(await cards(b), []);
|
||||
assert.match(await b.evaluate('document.querySelector("[data-tasks-board]").textContent'), /No organization answered with its tasks, so no tasks could be read\./);
|
||||
});
|
||||
|
||||
test('a committed write whose answer cannot be read is shown as unknown, not as refused', async t => {
|
||||
const { b, wait, orgs } = await open(t);
|
||||
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
|
||||
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']")');
|
||||
// The write reaches the bus; a proxy then replaces the answer with a page that is not JSON.
|
||||
await b.evaluate(`(() => { const real = window.fetch; window.fetch = async (u, o) => { const r = await real(u, o); return String(u) === '/api/workspace/tasks' ? new Response('<html>Bad gateway</html>', { status: 502, headers: { 'content-type': 'text/html' } }) : r; }; })()`);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-move=\'forge|task:41\']"); s.value = "in-review"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('/may or may not have been written/.test(document.querySelector("[data-tb-notice]").textContent)');
|
||||
const text = await b.evaluate('document.querySelector("[data-tb-notice]").textContent');
|
||||
assert.doesNotMatch(text, /was not moved|Nothing was written/);
|
||||
assert.match(text, /^#41: /);
|
||||
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:41', expect: D(1), state: 'in-review' }]]);
|
||||
// The board reads again and shows where the bus has the task.
|
||||
await wait('document.querySelector("[data-tb-move=\'forge|task:41\']").value === "in-review"');
|
||||
});
|
||||
|
||||
test('a filter change asks the server for the matching tasks', async t => {
|
||||
const { b, wait } = await open(t);
|
||||
await b.evaluate(`(() => { const real = window.fetch; window.__reads = []; window.fetch = (u, o) => { if (String(u).startsWith('/api/workspace?')) window.__reads.push(String(u)); return real(u, o); }; })()`);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=project]"); s.value = "forge:billing"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('window.__reads.length');
|
||||
const q = new URLSearchParams(await b.evaluate('window.__reads[0].split("?")[1]'));
|
||||
assert.equal(q.get('project'), 'forge:billing');
|
||||
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
|
||||
assert.deepEqual(await cards(b), ['forge|task:42']);
|
||||
});
|
||||
|
||||
test('a conflict on a task another writer moved out of the board\'s filter reopens on the current task', async t => {
|
||||
const { b, wait, orgs } = await open(t);
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "in-progress"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('document.querySelectorAll("[data-tasks-board] [data-tb-task]").length === 1');
|
||||
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
|
||||
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
|
||||
// Another writer finishes the task, so the board's In progress read no longer holds it.
|
||||
const t42 = orgs.store.find(s => s.n === 42);
|
||||
Object.assign(t42, { state: 'done', rev: t42.rev + 1 });
|
||||
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
||||
await wait('document.querySelector("#tb-dialog form")?.state.value === "done"');
|
||||
assert.match(await b.evaluate('document.querySelector("#tb-dialog [data-tb-form-status]").textContent'), /changed after the board read it/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), true);
|
||||
await b.evaluate('(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = "Invoice export, CSV"; f.requestSubmit(); })()');
|
||||
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
|
||||
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
|
||||
});
|
||||
|
||||
test('an empty result names the organizations whose tasks were not read', async t => {
|
||||
const { b, wait } = await open(t, { tasksFail: ['ddigital'] });
|
||||
await b.evaluate('(() => { const s = document.querySelector("[data-tb-filter=state]"); s.value = "blocked"; s.dispatchEvent(new Event("change", { bubbles: true })); })()');
|
||||
await wait('/No tasks match/.test(document.querySelector("[data-tb-results]").textContent)');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-tb-results] .ws-panel").textContent'),
|
||||
'No tasks match these filters in the organizations that answered. Tasks from Distinctly Digital, Home could not be read.');
|
||||
});
|
||||
@@ -110,13 +110,8 @@ test('sidebar follows sample 01 and Queue opens from the organization dashboard,
|
||||
assert.equal(queue, '#/queue');
|
||||
await b.evaluate('document.querySelector("[data-org-queue]").click()');
|
||||
await wait('location.hash === "#/orgs/ddigital/queue" && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Queue")');
|
||||
// Views not built yet open and say so.
|
||||
// Inbox is built (U3, workspace-pages-browser.test.mjs).
|
||||
for (const [route, title] of [['tasks', 'Tasks'], ['side-by-side', 'Side by side']]) {
|
||||
await b.evaluate(`location.hash = "#/orgs/ddigital/${route}"`);
|
||||
await wait(`document.getElementById("bus-view").innerText.includes("not built yet") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === ${JSON.stringify(title)})`);
|
||||
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /error|failed/i, route);
|
||||
}
|
||||
// Every sidebar view is built: Inbox (U3, workspace-pages-browser.test.mjs), Tasks (U4b,
|
||||
// tasks-board-browser.test.mjs) and Side by side (U5, side-by-side-browser.test.mjs).
|
||||
});
|
||||
|
||||
test('organization dashboard renders every section from the per-organization reads', async t => {
|
||||
|
||||
@@ -172,7 +172,8 @@ test('Projects sorts and filters by each key, remembers the grouping, and rows o
|
||||
await b.evaluate('location.reload()');
|
||||
await wait('document.querySelector("[data-ws-table]")?.dataset.grouping === "flat" && document.querySelectorAll("[data-ws-project]").length === 1 && document.querySelector("#workspace-nav [data-group=flat]")?.getAttribute("aria-pressed") === "true"');
|
||||
// The sidebar's toggle switches this page back.
|
||||
await b.evaluate('document.querySelector("#workspace-nav [data-group=org]").click()');
|
||||
// The nav repaints after async reads, so the click retries until the page switches.
|
||||
await wait('(document.querySelector("#workspace-nav [data-group=org]")?.click(), document.querySelector("[data-ws-table]")?.dataset.grouping === "org")');
|
||||
await wait('document.querySelector("[data-ws-table]")?.dataset.grouping === "org" && document.querySelector("[data-ws-group=org]").getAttribute("aria-pressed") === "true"');
|
||||
});
|
||||
const location = hash => decodeURIComponent(hash);
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
// U4b (#1873, PRD 1.20): the task board's server side. The write route POST /api/workspace/tasks
|
||||
// (strict body, the hook fence, owner and --send, one org per write, the bus's refusals), the task
|
||||
// rows the cross-organization read adds, and a Vikunja organization's tasks read through the real
|
||||
// tasks adapter from a fake Vikunja listening on port 0. Every bus here is a real Broker over a Store
|
||||
// in this test's own scratch, or a fake options object; no test reaches a live bus or a real Vikunja.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { storeAdapter } from '../../tasks/src/store.mjs';
|
||||
import { FakeVikunja } from '../../tasks/src/fake.mjs';
|
||||
import { world, sleep } from '../../tasks/tests/world.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { actionTrail } from '../src/action-trail.mjs';
|
||||
import { parseTaskWrite, TASK_WRITE_ROUTE } from '../src/workspace-tasks.mjs';
|
||||
import { parseWorkspaceQuery, readWorkspace, WorkspaceQueryError, TASKS_PER_ORG } from '../src/workspace-read.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const D = n => String(n).repeat(64);
|
||||
const scratch = (t, prefix) => { const root = mkdtempSync(join(tmpdir(), prefix)); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
|
||||
|
||||
function call(target, { method = 'GET', path, headers = {}, body }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const all = { ...headers };
|
||||
for (const k of Object.keys(all)) if (all[k] === null) delete all[k];
|
||||
if (body !== undefined) all['content-length'] = Buffer.byteLength(body);
|
||||
const req = request({ ...target, method, path, headers: all }, res => {
|
||||
let raw = '';
|
||||
res.on('data', c => { raw += c; });
|
||||
res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch { /* not JSON */ } resolve({ status: res.statusCode, body: json }); });
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
// One organization's bus: a real Broker over its own Store. `taskTracker` names the module the bus
|
||||
// runtime chose (a Vikunja org's is not the store). `human` is the person's cap, `agent` a role's.
|
||||
const AUTHORITY = { withinRole: ['task.update.assigned', 'message.send'], crossRole: [] };
|
||||
async function bus(t, id, { taskTracker = null } = {}) {
|
||||
const root = scratch(t, 'tb-');
|
||||
const businesses = { [id]: { id, human: 'jason', arbiters: { technical: 'reviewer', delivery: 'coder' }, roles: { coder: { definition: 'coder', authority: AUTHORITY }, reviewer: { definition: 'reviewer', authority: AUTHORITY } } } };
|
||||
const store = new Store(root);
|
||||
const broker = new Broker({ store, businesses, ...(taskTracker ? { taskTracker } : {}) });
|
||||
const adapter = await storeAdapter()({ broker, businesses });
|
||||
t.after(() => { adapter.close(); store.close(); });
|
||||
const human = broker.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const agent = broker.bindLaunch({ business: id, role: 'coder', run: 'coder-run', harness: 'pi' });
|
||||
broker.request(agent, { verb: 'role.claim' });
|
||||
const read = () => broker.request(broker.bindReader({ business: id }), { verb: 'tasks', args: {} });
|
||||
return { broker, human, agent, read };
|
||||
}
|
||||
|
||||
// The registry the route resolves through: `resolve(id, session)` admits an org the session belongs
|
||||
// to and answers its own transport. Every resolve and every bus call is recorded by org.
|
||||
async function registry(t) {
|
||||
const forge = await bus(t, 'forge');
|
||||
const ddigital = await bus(t, 'ddigital', { taskTracker: () => 'vikunja' });
|
||||
const robot = await bus(t, 'robot');
|
||||
const resolved = [], sent = [];
|
||||
const writer = (org, b, cap) => async (verb, args) => { sent.push([org, verb, args]); return b.broker.request(cap, { verb, args }); };
|
||||
const orgs = {
|
||||
forge: { busHealthy: true, taskWrite: writer('forge', forge, forge.human) },
|
||||
ddigital: { busHealthy: true, taskWrite: writer('ddigital', ddigital, ddigital.human) },
|
||||
// A Console started by an agent: the bus sees no person and answers human-required.
|
||||
robot: { busHealthy: true, taskWrite: writer('robot', robot, robot.agent) },
|
||||
home: { busHealthy: false, taskWrite: async (...a) => { sent.push(['home', ...a]); return {}; } },
|
||||
lost: { busHealthy: true, taskWrite: async (...a) => { sent.push(['lost', ...a]); throw new Error('socket closed'); } },
|
||||
odd: { busHealthy: true, taskWrite: async (...a) => { sent.push(['odd', ...a]); throw Object.assign(new Error('x'), { code: 'brand-new-code' }); } },
|
||||
quiet: { busHealthy: true, taskWrite: null },
|
||||
};
|
||||
const organizations = {
|
||||
list: async () => [], members: async () => [],
|
||||
async resolve(id, session) {
|
||||
resolved.push([id, session?.sub ?? null]);
|
||||
if (id === 'old') throw Object.assign(new Error('archived'), { code: 'org-archived' });
|
||||
if (id === 'broken') throw new Error('/srv/x/org.json: Unexpected token');
|
||||
return orgs[id] ?? null;
|
||||
},
|
||||
};
|
||||
return { organizations, resolved, sent, forge, ddigital, robot };
|
||||
}
|
||||
|
||||
async function serve(t, { organizations, send = true, sso = createDevOwner() }) {
|
||||
const dir = join(scratch(t, 'tw-'), 'console');
|
||||
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, organizations, log: () => {}, trail: actionTrail(dir), ...(send ? { send: async () => ({}) } : {}) });
|
||||
t.after(() => close(server));
|
||||
const port = server.address().port, host = `127.0.0.1:${port}`, origin = `http://${host}`;
|
||||
const post = (body, headers = {}) => call({ host: '127.0.0.1', port }, { method: 'POST', path: TASK_WRITE_ROUTE, body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
headers: { host, origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...headers } });
|
||||
const trail = () => (existsSync(join(dir, 'actions.jsonl')) ? readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []);
|
||||
return { post, trail, origin };
|
||||
}
|
||||
|
||||
test('parseTaskWrite takes the three ops strictly and refuses before any bus', () => {
|
||||
assert.deepEqual(parseTaskWrite({ op: 'create', org: 'forge', title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', state: 'todo', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' }),
|
||||
{ op: 'create', org: 'forge', verb: 'tasks.create', args: { title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', state: 'todo', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' } });
|
||||
assert.deepEqual(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), state: 'in-review' }),
|
||||
{ op: 'edit', org: 'forge', verb: 'tasks.edit', args: { task_ref: 'task:4', expect: D(1), state: 'in-review' } });
|
||||
assert.deepEqual(parseTaskWrite({ op: 'archive', org: 'forge', task_ref: 'task:4', expect: D(1) }), { op: 'archive', org: 'forge', verb: 'tasks.archive', args: { task_ref: 'task:4', expect: D(1) } });
|
||||
assert.equal(parseTaskWrite({ op: 'edit', org: 'forge', task_ref: 'task:4', expect: D(1), due_date: null }).args.due_date, null);
|
||||
const refused = [
|
||||
[{ op: 'delete', org: 'forge', task_ref: 'task:1' }, /^op takes create, edit, archive$/],
|
||||
[{ op: 'unarchive', org: 'forge', task_ref: 'task:1' }, /^op takes/],
|
||||
[{ op: 'edit', org: 'Forge!', task_ref: 'task:1', title: 'x' }, /^org takes/],
|
||||
[{ op: 'edit', task_ref: 'task:1', title: 'x' }, /^org takes/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', description: 'x' }, /^edit does not take description$/],
|
||||
[{ op: 'archive', org: 'forge', task_ref: 'task:1', title: 'x' }, /^archive does not take title$/],
|
||||
[{ op: 'create', org: 'forge', title: 'x' }, /^create needs requirement$/],
|
||||
[{ op: 'edit', org: 'forge', title: 'x' }, /^edit needs task_ref$/],
|
||||
// An edit or archive without the digest the board read is refused before any bus (no silent overwrite).
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', title: 'x' }, /^edit needs expect$/],
|
||||
[{ op: 'archive', org: 'forge', task_ref: 'task:1' }, /^archive needs expect$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(2) }, /^edit needs a field to change$/],
|
||||
// A Vikunja task is never written from the board: its ref is refused here.
|
||||
[{ op: 'edit', org: 'ddigital', expect: D(1), task_ref: 'vikunja:3/112', title: 'x' }, /^task_ref is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:0', title: 'x' }, /^task_ref is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', task_ref: 'task:1', expect: 'abc', title: 'x' }, /^expect is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: ' ' }, /^title is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', title: 'x'.repeat(251) }, /^title is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', state: 'doing' }, /^state is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 6 }, /^priority is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', priority: 1.5 }, /^priority is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: ['coder', 'bad name'] }, /^assignees is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', assignees: 'coder' }, /^assignees is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-11-20' }, /^due_date is not valid$/],
|
||||
[{ op: 'edit', org: 'forge', expect: D(1), task_ref: 'task:1', due_date: '2026-02-30T00:00:00.000Z' }, /^due_date is not valid$/],
|
||||
[{ op: 'create', org: 'forge', title: 'x', requirement: 'req-1' }, /^requirement is not valid$/],
|
||||
[{ op: 'create', org: 'forge', title: 'x', requirement: 'REQ-TASK-1', group: 'a\nb' }, /^group is not valid$/],
|
||||
];
|
||||
for (const [body, message] of refused) assert.throws(() => parseTaskWrite(body), e => e.status === 400 && message.test(e.message), JSON.stringify(body));
|
||||
});
|
||||
|
||||
test('route: the fence, the owner and --send come before any org is resolved', async t => {
|
||||
const r = await registry(t);
|
||||
const { post } = await serve(t, { organizations: r.organizations });
|
||||
const body = { op: 'edit', org: 'forge', task_ref: 'task:1', expect: D(1), title: 'x' };
|
||||
const fenced = [
|
||||
[{ origin: null }, 'cross-origin request refused'],
|
||||
[{ origin: 'http://evil.test' }, 'cross-origin request refused'],
|
||||
[{ 'sec-fetch-site': null }, 'cross-site request refused'],
|
||||
[{ 'sec-fetch-site': 'cross-site' }, 'cross-site request refused'],
|
||||
[{ 'sec-fetch-site': 'same-site' }, 'cross-site request refused'],
|
||||
];
|
||||
for (const [headers, error] of fenced) {
|
||||
const res = await post(body, headers);
|
||||
assert.equal(res.status, 403, JSON.stringify(headers)); assert.equal(res.body.error, error);
|
||||
}
|
||||
// Body checks: content type, size and shape, each with sent:false.
|
||||
const bad = [
|
||||
[await post(JSON.stringify(body), { 'content-type': 'text/plain' }), 415],
|
||||
[await post(JSON.stringify({ ...body, title: 'x'.repeat(17000) })), 413],
|
||||
[await post('{not json'), 400],
|
||||
[await post('[1]'), 400],
|
||||
[await post({ ...body, extra: 1 }), 400],
|
||||
];
|
||||
for (const [res, status] of bad) { assert.equal(res.status, status); assert.equal(res.body.error, 'invalid-request'); assert.equal(res.body.sent, false); }
|
||||
assert.deepEqual(r.resolved, []); assert.deepEqual(r.sent, []);
|
||||
|
||||
// Without --send the Console is read only.
|
||||
const ro = await serve(t, { organizations: r.organizations, send: false });
|
||||
const refused = await ro.post(body);
|
||||
assert.equal(refused.status, 403); assert.equal(refused.body.error, 'read-only');
|
||||
// A granted user's session is fenced as owner-only before the handler.
|
||||
const granted = { ...createDevOwner(), session: () => ({ provider: 'local-dev', iss: 'local-dev', sub: 'alice-sub', user: 'alice', name: 'Alice' }) };
|
||||
const user = await serve(t, { organizations: r.organizations, sso: granted });
|
||||
const other = await user.post(body);
|
||||
assert.equal(other.status, 403); assert.equal(other.body.error, 'owner-only');
|
||||
assert.deepEqual(r.resolved, []); assert.deepEqual(r.sent, []);
|
||||
});
|
||||
|
||||
test('route: each write reaches only the org it names, through that org\'s real bus, with the person\'s verbs', async t => {
|
||||
const r = await registry(t);
|
||||
const { post, trail } = await serve(t, { organizations: r.organizations });
|
||||
|
||||
const created = await post({ op: 'create', org: 'forge', title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', assignees: ['coder'], priority: 2 });
|
||||
assert.equal(created.status, 200, JSON.stringify(created.body));
|
||||
assert.equal(created.body.ok, true); assert.equal(created.body.org, 'forge'); assert.equal(created.body.op, 'create');
|
||||
assert.equal(created.body.task_ref, 'task:1'); assert.match(created.body.digest, DIGEST);
|
||||
assert.deepEqual(r.resolved, [['forge', 'local-dev-owner']]);
|
||||
assert.deepEqual(r.sent.map(([org, verb]) => [org, verb]), [['forge', 'tasks.create']]);
|
||||
const [task] = r.forge.read();
|
||||
assert.equal(task.task_ref, 'task:1'); assert.equal(task.source, 'store'); assert.equal(task.fields.title, 'Rate limits');
|
||||
assert.equal(task.digest, created.body.digest);
|
||||
// No other org's bus holds anything.
|
||||
assert.deepEqual(r.robot.read(), []);
|
||||
|
||||
// An edit with the digest the board read is written; a stale one is refused and changes nothing.
|
||||
const moved = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: created.body.digest, state: 'in-review' });
|
||||
assert.equal(moved.status, 200); assert.notEqual(moved.body.digest, created.body.digest);
|
||||
assert.equal(r.forge.read()[0].fields.state, 'in-review');
|
||||
const stale = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: created.body.digest, title: 'Lost update' });
|
||||
assert.equal(stale.status, 409); assert.equal(stale.body.error, 'task-conflict'); assert.equal(stale.body.sent, false);
|
||||
assert.match(stale.body.message, /Nothing was written/);
|
||||
assert.equal(r.forge.read()[0].fields.title, 'Rate limits');
|
||||
const missing = await post({ op: 'edit', org: 'forge', task_ref: 'task:9', expect: D(1), title: 'x' });
|
||||
assert.equal(missing.status, 404); assert.equal(missing.body.error, 'task-not-found');
|
||||
const role = await post({ op: 'edit', org: 'forge', task_ref: 'task:1', expect: moved.body.digest, assignees: ['nobody'] });
|
||||
assert.equal(role.status, 400); assert.equal(role.body.error, 'unknown-role');
|
||||
const archived = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: moved.body.digest });
|
||||
assert.equal(archived.status, 200);
|
||||
assert.deepEqual(r.forge.read(), [], 'an archived task leaves the read');
|
||||
const again = await post({ op: 'archive', org: 'forge', task_ref: 'task:1', expect: archived.body.digest });
|
||||
assert.equal(again.status, 409); assert.equal(again.body.error, 'task-already-archived');
|
||||
|
||||
// A Vikunja organization's bus refuses the store verbs; the board never writes to Vikunja.
|
||||
const vik = await post({ op: 'create', org: 'ddigital', title: 'Launch post', requirement: 'REQ-TASK-1' });
|
||||
assert.equal(vik.status, 409); assert.equal(vik.body.error, 'tracker-not-store'); assert.equal(vik.body.sent, false);
|
||||
assert.match(vik.body.message, /keeps its tasks in Vikunja/);
|
||||
assert.deepEqual(r.ddigital.read(), []);
|
||||
// A Console started by an agent: the bus finds no person, so nothing is written.
|
||||
const robot = await post({ op: 'create', org: 'robot', title: 'Sneaky', requirement: 'REQ-TASK-1' });
|
||||
assert.equal(robot.status, 403); assert.equal(robot.body.error, 'human-required');
|
||||
assert.match(robot.body.message, /only from a person/);
|
||||
assert.deepEqual(r.robot.read(), []);
|
||||
|
||||
// An org outside the session, an archived org, an unreadable registry, a down bus, a Console
|
||||
// without the write: each is refused before or without a bus call.
|
||||
const before = r.sent.length;
|
||||
const outside = await post({ op: 'edit', org: 'acme', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(outside.status, 404); assert.deepEqual(outside.body, { error: 'org-unavailable', sent: false });
|
||||
const old = await post({ op: 'edit', org: 'old', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(old.status, 409); assert.equal(old.body.error, 'org-archived');
|
||||
const broken = await post({ op: 'edit', org: 'broken', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(broken.status, 404); assert.deepEqual(broken.body, { error: 'org-unavailable', sent: false });
|
||||
const down = await post({ op: 'edit', org: 'home', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(down.status, 503); assert.equal(down.body.error, 'no-bus-host'); assert.equal(down.body.sent, false);
|
||||
const quiet = await post({ op: 'edit', org: 'quiet', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(quiet.status, 403); assert.equal(quiet.body.error, 'read-only');
|
||||
assert.equal(r.sent.length, before);
|
||||
|
||||
// A lost answer is not a refusal: the change may have been written, and the person is told so.
|
||||
const lost = await post({ op: 'edit', org: 'lost', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(lost.status, 503); assert.equal(lost.body.error, 'outcome-unknown'); assert.equal(lost.body.sent, 'unknown');
|
||||
const odd = await post({ op: 'edit', org: 'odd', task_ref: 'task:1', expect: D(1), title: 'x' });
|
||||
assert.equal(odd.status, 502); assert.equal(odd.body.error, 'brand-new-code'); assert.equal(odd.body.sent, false);
|
||||
|
||||
// Every resolve named exactly the org in the body, and each bus call went to that org.
|
||||
for (const [org, verb] of r.sent) assert.ok(['tasks.create', 'tasks.edit', 'tasks.archive'].includes(verb), `${org} ${verb}`);
|
||||
assert.deepEqual([...new Set(r.resolved.map(([id]) => id))], ['forge', 'ddigital', 'robot', 'acme', 'old', 'broken', 'home', 'quiet', 'lost', 'odd']);
|
||||
// The action trail notes each outcome with who, which org and which verb.
|
||||
const notes = trail().map(e => [e.org, e.action, e.task ?? null, e.outcome]);
|
||||
assert.deepEqual(notes, [
|
||||
['forge', 'tasks.create', null, 'written'],
|
||||
['forge', 'tasks.edit', 'task:1', 'written'],
|
||||
['forge', 'tasks.edit', 'task:1', 'refused task-conflict'],
|
||||
['forge', 'tasks.edit', 'task:9', 'refused task-not-found'],
|
||||
['forge', 'tasks.edit', 'task:1', 'refused unknown-role'],
|
||||
['forge', 'tasks.archive', 'task:1', 'written'],
|
||||
['forge', 'tasks.archive', 'task:1', 'refused task-already-archived'],
|
||||
['ddigital', 'tasks.create', null, 'refused tracker-not-store'],
|
||||
['robot', 'tasks.create', null, 'refused human-required'],
|
||||
['lost', 'tasks.edit', 'task:1', 'unknown'],
|
||||
['odd', 'tasks.edit', 'task:1', 'refused brand-new-code'],
|
||||
]);
|
||||
assert.ok(trail().every(e => e.by?.sub === 'local-dev-owner' && e.by?.provider === 'local-dev'));
|
||||
});
|
||||
|
||||
// One org's reads as the workspace read sees them (options().bus and options().chat).
|
||||
function member(id, data, doc = {}) {
|
||||
const value = name => Promise.resolve(data[name] ?? (['usage', 'limits'].includes(name) ? {} : []));
|
||||
return {
|
||||
id, doc, status: async () => ({ healthy: true, pid: 1, boot: null }),
|
||||
options: () => ({
|
||||
bus: { inbox: () => value('inbox'), agents: () => value('agents'), 'thread.claims': () => value('claims'), tasks: () => value('tasks'), usage: () => value('usage'), limits: () => value('limits'), decisions: () => value('decisions') },
|
||||
chat: { threads: () => value('threads'), projects: () => value('projects') },
|
||||
}),
|
||||
};
|
||||
}
|
||||
const SESSION = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
|
||||
const readTasks = (members, search = 'sections=tasks') => readWorkspace({ organizations: { members: async () => members }, session: SESSION, query: parseWorkspaceQuery(search), now: () => Date.parse('2026-11-10T12:00:00.000Z') });
|
||||
|
||||
test('read: a built-in store org\'s rows, from its real bus, carry state, project, roles and the digest a write sends back', async t => {
|
||||
const forge = await bus(t, 'forge');
|
||||
const p = (verb, args) => forge.broker.request(forge.human, { verb, args });
|
||||
p('tasks.create', { title: 'Rate limits', requirement: 'REQ-TASK-1', group: 'p-api', assignees: ['coder'], priority: 3, due_date: '2026-11-20T00:00:00.000Z' });
|
||||
p('tasks.create', { title: 'Invoice PDF', requirement: 'REQ-TASK-1', group: 'billing', state: 'blocked' });
|
||||
p('tasks.create', { title: 'Loose end', requirement: 'REQ-TASK-1' });
|
||||
const gone = p('tasks.create', { title: 'Old idea', requirement: 'REQ-TASK-1' });
|
||||
p('tasks.archive', { task_ref: gone.task_ref });
|
||||
const projects = [{ id: 'p-api', repo: 'api-gateway', name: 'API gateway', archived: false, at: '2026-11-01T00:00:00.000Z' }, { id: 'p-bill', repo: 'billing', name: 'Billing', archived: false, at: '2026-11-01T00:00:00.000Z' }];
|
||||
const body = await readTasks([member('forge', { tasks: forge.read(), projects }, { roles: { coder: {}, reviewer: {} } })]);
|
||||
assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker]), [['forge', 'ok', 'built-in']]);
|
||||
const rows = body.tasks.map(x => [x.ref, x.tracker, x.title, x.state, x.project?.key ?? null, x.group, x.assignees, x.priority, x.dueDate, x.writable, DIGEST.test(x.digest)]);
|
||||
assert.deepEqual(rows, [
|
||||
// A group matches a project by id or by key; a group naming neither has no project.
|
||||
['task:1', 'built-in', 'Rate limits', 'todo', 'api-gateway', 'p-api', ['coder'], 3, '2026-11-20T00:00:00.000Z', true, true],
|
||||
['task:2', 'built-in', 'Invoice PDF', 'blocked', 'billing', 'billing', [], 0, null, true, true],
|
||||
['task:3', 'built-in', 'Loose end', 'todo', null, null, [], 0, null, true, true],
|
||||
]);
|
||||
// The digest is the bus's own, so an edit's `expect` matches the task the board shows.
|
||||
assert.equal(body.tasks[0].digest, forge.read().find(x => x.task_ref === 'task:1').digest);
|
||||
p('tasks.edit', { task_ref: 'task:1', expect: body.tasks[0].digest, state: 'done' });
|
||||
const after = await readTasks([member('forge', { tasks: forge.read(), projects }, {})], 'sections=tasks&task-state=done');
|
||||
assert.deepEqual(after.tasks.map(x => [x.ref, x.state, x.done]), [['task:1', 'done', true]]);
|
||||
});
|
||||
|
||||
test('read: filters, the per-org cap, and rows the read refuses', async () => {
|
||||
const store = (n, f = {}, extra = {}) => ({ task_ref: `task:${n}`, source: 'store', digest: D(n % 10), at: '2026-11-09T00:00:00.000Z', fields: { title: `T${n}`, state: 'todo', group: null, assignees: [], labels: [], ...f }, ...extra });
|
||||
const tasks = [store(1, { state: 'in-review', assignees: ['coder'], labels: [4] }), store(2, { assignees: ['reviewer'] }), store(3, { state: 'nonsense' }), store(4, {}, { digest: 'not-a-digest' }), store(5, {}, { archived: true })];
|
||||
const one = async search => (await readTasks([member('forge', { tasks })], `sections=tasks${search}`)).tasks.map(x => x.ref);
|
||||
assert.deepEqual(await one(''), ['task:1', 'task:2', 'task:3', 'task:4']);
|
||||
assert.deepEqual(await one('&task-state=in-review'), ['task:1']);
|
||||
// An unknown state reads as null, which only `open` matches.
|
||||
assert.deepEqual(await one('&task-state=open'), ['task:3']);
|
||||
assert.deepEqual(await one('&assignee=reviewer,coder'), ['task:1', 'task:2']);
|
||||
assert.deepEqual(await one('&label=4'), ['task:1']);
|
||||
const all = (await readTasks([member('forge', { tasks })])).tasks;
|
||||
assert.equal(all.find(x => x.ref === 'task:4').digest, null, 'a malformed digest is never sent back');
|
||||
for (const search of ['task-state=doing', 'assignee=', 'assignee=a b', 'label=0', 'label=x', 'task-state=todo&task-state=done'])
|
||||
assert.throws(() => parseWorkspaceQuery(search), WorkspaceQueryError, search);
|
||||
|
||||
// Past the cap the org keeps its TASKS_PER_ORG most recently changed rows and says how many
|
||||
// matched; another org is untouched. Task n changed n minutes after midnight.
|
||||
const minute = n => new Date(Date.parse('2026-11-09T00:00:00.000Z') + n * 60000).toISOString();
|
||||
const many = Array.from({ length: TASKS_PER_ORG + 7 }, (_, i) => store(i + 1, i + 1 > TASKS_PER_ORG ? { group: 'late', state: 'blocked' } : { group: 'p1' }, { at: minute(i + 1), project_key: 'k' }));
|
||||
const projects = [{ id: 'p1', repo: 'one', name: 'One', archived: false, at: minute(0) }, { id: 'late', repo: 'late', name: 'Late', archived: false, at: minute(0) }];
|
||||
const big = [member('big', { tasks: many, projects }), member('small', { tasks: [store(1)] })];
|
||||
const capped = await readTasks(big, 'sections=tasks,projects,metrics');
|
||||
const bigRows = capped.tasks.filter(x => x.org === 'big');
|
||||
assert.equal(bigRows.length, TASKS_PER_ORG);
|
||||
assert.equal(capped.orgs.find(o => o.org === 'big').tasksHeld, TASKS_PER_ORG + 7);
|
||||
assert.equal(capped.orgs.find(o => o.org === 'small').tasksHeld, undefined);
|
||||
assert.equal(bigRows.some(x => x.ref === 'task:7'), false, 'the oldest changes are the ones cut');
|
||||
assert.equal(bigRows.some(x => x.ref === 'task:8'), true);
|
||||
// Store refs sort by number, so task:10 follows task:9.
|
||||
assert.deepEqual(bigRows.slice(1, 4).map(x => x.ref), ['task:9', 'task:10', 'task:11']);
|
||||
// Projects and metrics count every task the org holds, not only the ones the answer carries.
|
||||
const proj = Object.fromEntries(capped.projects.filter(x => x.org === 'big').map(x => [x.id, x]));
|
||||
assert.equal(proj.p1.openTasks, TASKS_PER_ORG); assert.equal(proj.late.openTasks, 7);
|
||||
assert.equal(capped.metrics.orgs.find(m => m.org === 'big').openTasks, TASKS_PER_ORG + 7);
|
||||
// A filter runs before the cap, so it reaches tasks past row TASKS_PER_ORG.
|
||||
for (const search of ['sections=tasks&project=big:late', 'sections=tasks&task-state=blocked']) {
|
||||
const filtered = await readTasks(big, search);
|
||||
assert.deepEqual(filtered.tasks.map(x => x.ref), Array.from({ length: 7 }, (_, i) => `task:${TASKS_PER_ORG + i + 1}`), search);
|
||||
assert.equal(filtered.orgs.find(o => o.org === 'big').tasksHeld, undefined, search);
|
||||
}
|
||||
});
|
||||
|
||||
test('read: a Vikunja org\'s tasks come through the real tasks adapter from a fake Vikunja on port 0', async t => {
|
||||
const fake = new FakeVikunja();
|
||||
const server = await fake.listen();
|
||||
t.after(server.close);
|
||||
assert.match(server.url, /^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
const w = await world(t, { fake, fetch: globalThis.fetch, tracker: { baseUrl: server.url } });
|
||||
await w.adapter.start('demo');
|
||||
assert.equal(w.adapter.status()[0].state, 'ready', w.adapter.status()[0].refused);
|
||||
const pm = w.agent('pm');
|
||||
const a = await w.call(pm, 'task.create', { title: 'Launch post', request: w.instruction(), requirement: 'REQ-TASK-1' });
|
||||
const b = await w.call(pm, 'task.create', { title: 'Press kit', request: w.instruction(), requirement: 'REQ-TASK-1' });
|
||||
const id = ref => Number(ref.slice(ref.indexOf('/') + 1));
|
||||
// The owner works in the Vikunja UI; the adapter's poll picks it up.
|
||||
assert.equal((await w.ui('POST', `/tasks/${id(a.task_ref)}/assignees`, { user_id: w.bots.coder })).status, 201);
|
||||
assert.equal((await w.ui('POST', `/tasks/${id(a.task_ref)}/labels`, { label_id: w.label })).status, 201);
|
||||
assert.equal((await w.ui('PATCH', `/tasks/${id(a.task_ref)}`, { due_date: '2026-11-20T00:00:00Z', percent_done: 0.5, description: 'not on the board' })).status, 200);
|
||||
assert.equal((await w.ui('PATCH', `/tasks/${id(b.task_ref)}`, { done: true })).status, 200);
|
||||
await sleep(20);
|
||||
await w.adapter.tick('demo');
|
||||
assert.ok(fake.requests.length > 0, 'the adapter read the fake over HTTP');
|
||||
|
||||
const rows = w.broker.request(w.human, { verb: 'tasks', args: {} });
|
||||
const doc = w.businesses.demo;
|
||||
const body = await readTasks([member('demo', { tasks: rows, projects: [] }, doc)]);
|
||||
assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker]), [['demo', 'ok', 'vikunja']]);
|
||||
const [x, y] = body.tasks;
|
||||
// Mapped: ref, title, done (the only state the bus names), bot ids as roles, labels, priority,
|
||||
// due date and the numeric bucket. Left out: description, percent_done, relations, comments.
|
||||
assert.deepEqual({ ref: x.ref, tracker: x.tracker, title: x.title, state: x.state, done: x.done, assignees: x.assignees, labels: x.labels, dueDate: x.dueDate, writable: x.writable, digest: x.digest },
|
||||
{ ref: a.task_ref, tracker: 'vikunja', title: 'Launch post', state: null, done: false, assignees: ['coder'], labels: [w.label], dueDate: '2026-11-20T00:00:00.000Z', writable: false, digest: null });
|
||||
assert.ok(Number.isSafeInteger(x.bucket) && x.bucket > 0, 'the bucket is the board\'s numeric id');
|
||||
assert.equal(x.requirement, 'REQ-TASK-1');
|
||||
assert.deepEqual([y.ref, y.state, y.done], [b.task_ref, 'done', true]);
|
||||
for (const key of ['description', 'percent_done', 'percentDone', 'relations', 'comments']) assert.equal(Object.hasOwn(x, key), false, key);
|
||||
// The board's filters work on a Vikunja org too.
|
||||
const open = await readTasks([member('demo', { tasks: rows, projects: [] }, doc)], 'sections=tasks&task-state=open&assignee=coder');
|
||||
assert.deepEqual(open.tasks.map(t => t.ref), [a.task_ref]);
|
||||
});
|
||||
Reference in new issue
Block a user