Compare commits

..
12 Commits
Author SHA1 Message Date
jason.woltje 69af2fe94b docs(plans): accounts design draft 2, review fix round (#1873)
Applies the luna review (3 changes) and the sol review (10 changes):
per-role transport table with member reads limited to threads, ownsOrg
over every subject of the owning account, memberGrant adapter, verified
snapshot read after the check, authGen session invalidation, one lock
order, random org incarnation, explicit invite callback exception, a
build stamp that refuses the dev owner in stamped copies, and a rollback
that keeps OIDC. Member board reads and task writes move to slice M8
behind an owner ruling.
2026-11-10 12:32:06 -06:00
jason.woltje 4106bf7344 docs(plans): accounts, membership and invites design, Phase M (#1873) 2026-11-10 12:13:32 -06:00
jason.woltje 8630b8dfc0 test(webui): wait for both sidebar lists after the reload (#1873) 2026-11-10 12:01:55 -06:00
jason.woltje 5eb6dda049 fix(webui): U2b review fixes for the workspace dashboard and agents (#1873)
- Agent activity comes from the bus agents read (the state for the run
  holding the role); with no reported state a live agent shows "running",
  never working or idle.
- A session's project and task is the thread its live run claims
  (thread.claims), not the agent's first listed conversation.
- The dashboard reads an organization only after /api/orgs lists it as
  valid; otherwise it shows "Organization unavailable" and reads nothing.
  Agents reads the selected business only for a listed organization.
- A failed conversation read says "Conversation unavailable", not that
  none exists.
- A reported model gets a Change link to Settings, Routing, in the
  dashboard and agents tables.
2026-11-10 11:32:19 -06:00
jason.woltje 8710cc0e1e feat(webui): U2 unified workspace sidebar, org dashboard and agents grid (#1873)
Finishes the U2 WIP (37b0891a3) to samples 01, 03 and 08 of the
2026-11-09 unified workspace set:
- workspace.js: sidebar Organizations and Projects groups (collapsible,
  state remembered), the org dashboard, the Agents levels.
- workspace-data.js: every read these screens make, GET no-store,
  redirect manual; a refused read shows unavailable for that part only.
- shell.css: the html[data-organizations] .ws-* block from the sample.
- bus.js: org-mode sidebar order, pinned Settings, no project tree under
  Projects, focus kept across repaint.
- Honest empty states where the Console has no data (KPIs, metrics, kind).
- Tests: unified-workspace-browser (10), picker/delete/switch browser tests
  follow the sidebar, browser.test script pin.
2026-11-10 11:32:19 -06:00
jason.woltje 364f1ae7a6 fix(tasks): U4a review fixes for the built-in task store (#1873)
Six review findings, one round:
- A selected task-tracker module that fails to load or has no factory refuses boot with tracker-config.
- The expect digest covers the task revision, so archive and unarchive invalidate an old token.
- Position exhaustion and an occupied explicit position refuse task-position-full; no rebalancing yet.
- The store adapter reads roles from the broker's current business document (Broker.taskRoles), so a role added by reload works.
- Only newly requested assignees are checked against live roles; stored ones survive edits, archive and delete.
- Human tasks.create requires a requirement citation (REQ-AREA-N); tasks.edit accepts one.
2026-11-09 19:55:25 -06:00
jason.woltje 9b7bf11afb feat(tasks): U4a, a task store built into each organization (#1873)
PRD 1.20 REQ-TASK-1, REQ-PLUG-1, REQ-WS-8. Each organization keeps its
tasks in its own bus through the new task-tracker plug-in kind. The
default module is the built-in store; Vikunja is the optional module
and a Vikunja organization keeps today's path.

- bus schema 3u to 3v (migration 3u-3v.sql): store_tasks, append-only,
  one row per write, each citing its events row; seven guard triggers.
- broker: the eight role verbs on the store with the slice 1 field
  table checked twice (module, then a before/after field diff); the
  person's tasks.create/edit/archive/unarchive/delete as human.input
  events; the tasks read returns store rows in board order (group,
  position), filtered and paged like tracker rows.
- runtime: each business gets its module at start (organization file
  plugin, else vikunja with a tracker entry, else store); a store
  business with a Vikunja tracker refuses the boot (tracker-config).
- plugins: task-tracker kind with store and vikunja modules.
- cli: the launcher's tracker flag covers store organizations; trail
  prints store writes.
- pins: schema.test counts, the bus migration tests, stack-release #811
  and T46 (store_tasks on the always list; COUNTED unchanged),
  budget-supervisor accepts 3v.

Rebased onto SYS1 by the lead: SYS1 took 3t-3u, so this step is 3u-3v.
2026-11-09 19:25:44 -06:00
jason.woltje feb98d5529 test(cli): compare file bytes in the org tester-init refusal check
Sol review of 221c1e9d3 (P2): the snapshot held only path names, so a
rewrite of .mosaic-org.json before the refusal passed. It now holds names,
types and file bytes; the reviewer's rewrite mutant fails it.
2026-11-09 19:17:28 -06:00
jason.woltje 221c1e9d35 Fix tester project tests for organization refusal 2026-11-09 19:05:09 -06:00
jason.woltje 544918a715 docs: record U1 and SYS1 landings (#1873) 2026-11-09 19:00:25 -06:00
jason.woltje 2c6556ab1e fix(sys1): count project system types in the restore guard and admit the CLI verbs
Sol review sys1-review-r1 (changes 3):
- P1: project_system_types joins stack-release.sh COUNTED and the T45 LISTED pin; the
  post-backup refusal and in-run recount tests now cover it.
- P2: the CLI channel admits project.system-types and project.system-types.set; a new test
  drives both through run() and the real channel module (1/1 red without the fix).
- P2: the #811 migration-reader pin moves to 19 steps ending 3t-3u.
2026-11-09 18:46:36 -06:00
jason.woltje f4bfd3a744 feat(plugins,organization): system types as a plug-in kind (REQ-SYS-1, #1873)
Adds the system-type kind with five modules (coding, creative-writing,
marketing, montage, business-management), an org-level enabled list
validated fail-closed, project selections limited to the org's list,
read verbs, and bus migration 3t-3u. REQ-SYS-2 toolsets are designed and
deferred: they need a role authority contract.
2026-11-09 18:46:35 -06:00
76 changed files with 4141 additions and 144 deletions

No files matched your search

+2
View File
@@ -6345,3 +6345,5 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
2026-11-09 UTC, organizations F3 (#1873, PR #1884, a8f9f0957). The WebUI restarts the Console (supervised Consoles exit and `mosaic console start` relaunches them), restarts an organization's bus from the bus-down banner with a done receipt, and sends the owner's routing, agent, instance, name, assistant, limits and model proxy writes to the selected organization's bus. Routing apply lists how a change reaches each saved agent and offers Restart NAME now. Console restarts, bus restarts and routing, agent and instance changes append to `<dataRoot>/console/actions.jsonl`. Builder plus two refactor merges: combined list 354/0 on 999d173fd. Sol review CHANGES 1/0/1. P1: on a `--dev-local-owner` Console a local agent can send the right Origin and Fetch Metadata headers and reach these controls. The pinned startup warning and the accepted no-auth dev flags already cover this, but the README claimed no agent reaches them. Lead decision: correct the README and file the cooperative local-agent peer refusal as #1883, without building it in this round. P3: the instances route wrote no trail line. Fixed in 0a73279d6; the regression test fails on 999d173fd and passes on the fix. After the refactor merge, 25 touched or README-reading files 195/0 (Node 26.12.1, one file at a time, env -i). uiorg redeployed at a8f9f0957, 22:45Z; live config offers consoleRestart and org settingsSend. Correction to this session's process: after the test run completed I replied "No response requested" and stopped until Jason asked. Open follow-ups: defect l (a bus restart after adding a credentialed agent can refuse to start), the bus restart refusal code, a stale-lock Recover control, one control for banner Restart and Settings Start, #1883. Report: /mnt/storage/scratch/mosaic-lead/reports/org-f3-review.md.
2026-11-09 UTC, PRD 1.20 and defect l (#1873, #1886; PRs #1887 03fe321b1 and #1888 7a97cfc4f). PRD 1.20: Jason approved draft 2 at 22:57Z ("The file looks fine other than that"; the Montage module name stays deferred). The PRD now has 118 requirements, with REQ-WS-1..8, REQ-AGENT-1..3, REQ-SYS-1..2, REQ-ACCT-1..2 and REQ-SETUP-1..2 added and REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3 amended. Builder Codex Terra xhigh declined to commit because stack-copy failed 20/1 (node-gyp missing from the test Node's npm); that failure is pre-existing on 483eccb3d, so the lead committed. Sol review APPROVE 0 (word-for-word diff against draft 2, catalog order, both requirements tests). Defect l: before, an agent that declared a store credential made the next bus start refuse, because Credentials refuses a missing store root and only issue() created it. After: start creates the root 0700 when any reference names the store; a root that exists with loose modes or as a symlink still refuses. Two new credential-verbs tests fail on the parent. Bus suite 171 files one at a time, 1713/0 (Node 26.12.1, env -i). Sol review APPROVE 0. uiorg redeployed at 7a97cfc4f, 23:15Z. Follow-up: the stack-copy node-gyp test environment. Reports: /mnt/storage/scratch/mosaic-lead/reports/{prd120-land,prd120-review,defectl-review}.md.
2026-11-10 UTC, unified workspace U1 and SYS1 (#1873; PRs #1889 d35447005 and #1890 2c6556ab1). U1, before: each org page read only its own bus and nothing read every org at once. After: /api/workspace returns projects, sessions, inbox, tasks and metrics from every org the owner holds, each row tagged with its org and each org with a state (ok, starting, down, timeout, invalid); `mosaic console restart` and `mosaic org reload <org>` send the F3 routes. Sol review CHANGES 0/2 on 2109458b3: malformed data from one org turned the whole read into a 503, and the per-org deadline left sockets and transport children running. Terra fix 0672d5fe8 validates each org's result inside its boundary and carries an absolute deadline and AbortSignal through the status check and transport; the new workspace-failures test is 0/10 on the parent and 10/0 on the fix. The lead re-pinned the webui bus.mjs hash in bus channel-proof (the deliberate signal/deadline change; nonces, argv and limits unchanged) and amended as d35447005. SYS1, before: no system-type kind. After: a system-type plug-in kind with five modules (business-management, coding, creative-writing, marketing, montage), an org enable list under plugins.system-type, owner-only project selection with an append-only project_system_types table (migration 3t-3u) and CLI verbs; the modules grant no tools yet (REQ-SYS-2 deferred). Sol review CHANGES 1/2 on a9629f332: the restore guard did not count project_system_types, the CLI channel did not admit the two project verbs, and the #811 migration-reader pin still expected 18 steps. Lead fix 2c6556ab1 (rebased on d35447005) adds the table to COUNTED and LISTED with refusal and in-run recount tests, admits both verbs with a run()-level channel test (red on the unfixed channel), and moves the pin to 19 steps ending 3t-3u; fix files 323/0 and rebase overlap files 63/0 (Node 26.12.1, one file at a time, env -i). Full gate on a9629f332 4040/5: the two stack-release pins above, and tester-reads 3/3, which also fails on 1616d1dd6 (project.mjs:95 refuses an org --tester init; follow-up). Correction to the uiorg deploy at 00:50Z: the deploy script switched the tree without the offline schema migration, so all five buses refused to start (normal startup refuses an older schema by design). The lead ran migrate-schema.mjs for the five org stores (3t to 3u, all ready), restarted the buses (201 each) and added the migration to the deploy script with stop-on-refusal. test3 refused writer-locked and stays down, as before. Reports: /mnt/storage/scratch/mosaic-lead/reports/{u1-review,u1-fix,org-sys1-build,sys1-review}.md.
+1
View File
@@ -2123,3 +2123,4 @@ are never rewritten or removed; corrections are new entries.
2026-11-07 UTC | Codex implementation sub-agent | Organizations release fix round 1 on ui-org-r1 | Local candidate for independent review; verification and findings recorded in /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-fix-r1.md. Queue refused linked worktree; no push, merge or deployment.
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | Organizations F3 fix round and landing (#1873) | PR #1884 merged a8f9f0957; review P3 fixed, P1 README corrected with follow-up #1883; 195/0 focused suite; uiorg redeployed 22:45Z.
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | PRD 1.20 landing and defect l (#1873, #1886) | PR #1887 merged 03fe321b1, PR #1888 merged 7a97cfc4f; both Sol reviews APPROVE 0; bus suite 1713/0; uiorg redeployed 23:15Z; U1, U2 and U4a builds dispatched.
2026-11-10 UTC | Mos (lead, Claude Opus 5.5) | U1 and SYS1 landings (#1873) | PR #1889 merged d35447005, PR #1890 merged 2c6556ab1; both Sol reviews CHANGES, fixed (U1 by Terra, SYS1 by the lead); uiorg at 2c6556ab1 after the missed 3t-3u store migration was run; deploy script now migrates.
File diff suppressed because it is too large. Load diff
+11
View File
@@ -1117,3 +1117,14 @@ For lifecycle tasks, the first review request includes the exact raw JSON
verification-document and verdict-body contract; unrelated reviews retain their
existing free-text contract. The typed record tools describe the existing closed
requirement schema without relaxing host validation.
## Built-in task store writes
The outside-agent human session can call `tasks.create` with `title` and a `requirement` ID
(such as `REQ-TASK-1`), plus optional task fields. `tasks.edit` accepts `task_ref` and the
fields to change, including `requirement`; archive, unarchive, and delete take `task_ref`.
Each write records `human.input`. A task read returns a `digest` that can be supplied as
`expect` to a later write; any intervening revision, including archive or unarchive, refuses
`task-conflict`. Existing assignees may remain after a role is removed, but new assignees
must be current roles. Automatic tail placement and an explicitly occupied position refuse
`task-position-full` when there is no room; no task row is written.
+57
View File
@@ -639,3 +639,60 @@ CREATE TRIGGER request_snapshot_events_no_replace BEFORE INSERT ON request_snaps
CREATE TRIGGER request_project_events_no_update BEFORE UPDATE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
CREATE TRIGGER request_project_events_no_delete BEFORE DELETE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
CREATE TRIGGER request_project_events_no_replace BEFORE INSERT ON request_project_events WHEN EXISTS (SELECT 1 FROM request_project_events WHERE seq=NEW.seq OR (business=NEW.business AND project=NEW.project AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
-- Per-project selections are a human-authored append-only settings trail.
CREATE TABLE project_system_types (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
types TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX project_system_types_project ON project_system_types (business, project, seq);
CREATE TRIGGER project_system_types_no_update BEFORE UPDATE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_delete BEFORE DELETE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_types WHEN EXISTS (SELECT 1 FROM project_system_types WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END;
-- U4a (#1873): the task store built into each organization, the default task-tracker module
-- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row
-- and the rows are its history: who wrote it (a role and run, or the person), when, under which
-- verb, and the event that records the write. Task numbers count up per organization and are
-- never reused. A delete is a last row: the task leaves every read and its history stays until the
-- organization is removed, which removes this whole database with it. Only a new append-only
-- table, its index and its guards; no existing row changes and nothing is deleted.
CREATE TABLE store_tasks (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task INTEGER NOT NULL CHECK (task >= 1),
revision INTEGER NOT NULL CHECK (revision >= 1),
op TEXT NOT NULL CHECK (op IN ('create','change','archive','unarchive','delete')),
verb TEXT NOT NULL,
by_role TEXT,
by_run TEXT,
by_human TEXT,
event TEXT NOT NULL REFERENCES events(id),
fields TEXT NOT NULL CHECK (json_valid(fields) AND json_type(fields) = 'object'),
CHECK ((by_human IS NULL) = (by_role IS NOT NULL AND by_run IS NOT NULL)),
UNIQUE (business, task, revision)
) STRICT;
CREATE INDEX store_tasks_task ON store_tasks (business, task, seq);
CREATE TRIGGER store_tasks_no_update BEFORE UPDATE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_delete BEFORE DELETE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_replace BEFORE INSERT ON store_tasks WHEN EXISTS (SELECT 1 FROM store_tasks WHERE seq = NEW.seq OR (business = NEW.business AND task = NEW.task AND revision = NEW.revision)) BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_revision BEFORE INSERT ON store_tasks
WHEN NEW.revision IS NOT COALESCE((SELECT MAX(revision) FROM store_tasks WHERE business = NEW.business AND task = NEW.task), 0) + 1
BEGIN SELECT RAISE(ABORT, 'a store task revision follows the last one'); END;
CREATE TRIGGER store_tasks_create BEFORE INSERT ON store_tasks
WHEN (NEW.op = 'create') IS NOT (NEW.revision = 1)
OR (NEW.op = 'create' AND NEW.task IS NOT COALESCE((SELECT MAX(task) FROM store_tasks WHERE business = NEW.business), 0) + 1)
BEGIN SELECT RAISE(ABORT, 'a store task is created once, with the next number'); END;
CREATE TRIGGER store_tasks_deleted BEFORE INSERT ON store_tasks
WHEN EXISTS (SELECT 1 FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op = 'delete')
BEGIN SELECT RAISE(ABORT, 'a deleted store task takes no more rows'); END;
CREATE TRIGGER store_tasks_archive BEFORE INSERT ON store_tasks
WHEN NEW.op IN ('archive','unarchive')
AND (COALESCE((SELECT op FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op IN ('archive','unarchive') ORDER BY seq DESC LIMIT 1), 'unarchive') = 'archive') = (NEW.op = 'archive')
BEGIN SELECT RAISE(ABORT, 'a store task archive row changes state'); END;
+422 -13
View File
@@ -95,7 +95,8 @@ export const REPLIES = 50;
// Row 80 (#1574): how many stranded messages `agents` lists for each role, oldest first.
export const STRANDED = 50;
// Row 89 (#1583): the arguments `tasks` takes, and its page size by default and at most.
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot'];
// U4a (#1873): `archived` reads the built-in store's archived tasks; a Vikunja task is never archived.
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref', 'project_key', 'requirement_snapshot', 'archived'];
export const TASK_PAGE = 50;
export const TASK_PAGE_MAX = 200;
// D3 (#797): raw UTF-8 pre-bound for the approved requirement texts quoted into one coder/reviewer
@@ -165,7 +166,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
'thread.share', 'thread.unshare', 'thread.shares']);
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
// refuses it human-required before its own checks, and these refuse every other cap.
export const CONSOLE_VERBS = Object.freeze(['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
@@ -295,6 +296,105 @@ function identifier(x) {
return x;
}
const taskRef = (x) => typeof x === 'string' && /^vikunja:[1-9][0-9]*\/[1-9][0-9]*$/.test(x);
// U4a (#1873, REQ-TASK-1): the task store built into each organization, the default task-tracker
// module. Its rows live in the organization's own bus.sqlite (store_tasks, migration 3u-3v). These
// shapes are exported for packages/tasks/src/store.mjs, the module, so the broker and the module
// share one definition; they stay in this file because its import list is the sandbox's closure.
// A task's fields, whole after every write:
// title, description text, as the Vikunja verbs take them
// due_date canonical ISO 8601 UTC with milliseconds, or null
// priority 0 (unset) to 5
// percent_done 0 to 1
// state todo, in-progress, in-review, blocked or done
// bucket, done what state implies, under the names the Console and `mosaic tasks` read
// labels positive label ids, sorted, at most 50
// assignees role instance names, sorted, at most 50
// group null or a short name: the column or list the task sits in
// position a whole number ordering tasks inside their group
// comments, comment how many comments, and the latest ({ text, by })
// verdict the review verdict a close cited
// request, op what a role's create cited; null on a person's
// requirement the requirement cited on create (or edited by a person)
export const STORE_STATES = Object.freeze(['todo', 'in-progress', 'in-review', 'blocked', 'done']);
// The states task.update.assigned moves between; done is task.close's (slice 1 field table).
export const ASSIGNED_STATES = Object.freeze(STORE_STATES.filter((x) => x !== 'done'));
// A built-in task's ref is its number in the organization, never reused.
const STORE_REF = /^task:([1-9][0-9]{0,14})$/;
export const storeRef = (x) => typeof x === 'string' && STORE_REF.test(x);
export const storeTaskNumber = (x) => (storeRef(x) ? Number(STORE_REF.exec(x)[1]) : null);
export const STORE_POSITION_MAX = 1_000_000_000;
// What a person's tasks.create and tasks.edit may set. Roles write through the eight task verbs,
// each held to the fields the field table gives it.
export const STORE_EDIT_FIELDS = Object.freeze(['title', 'description', 'due_date', 'priority', 'percent_done', 'state', 'labels', 'assignees', 'group', 'position', 'requirement']);
const isoMillis = (x) =>
typeof x === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) && Number.isFinite(Date.parse(x)) && new Date(x).toISOString() === x;
// One check per field: the raw value in, the stored value out, or a refusal.
export function storeFieldChecks(roles) {
const str = (x, max, min = 0) => {
if (typeof x !== 'string' || x.length < min || x.length > max || x.includes('\0')) fail('invalid-request');
return x;
};
const set = (x, item, order) => {
if (!Array.isArray(x) || x.length > 50) fail('invalid-request');
return [...new Set(x.map(item))].sort(order);
};
return {
title: (x) => (str(x, 250, 1).trim() ? x : fail('invalid-request')),
description: (x) => str(x, 65536),
due_date: (x) => (x === null || isoMillis(x) ? x : fail('invalid-request')),
priority: (x) => (Number.isSafeInteger(x) && x >= 0 && x <= 5 ? x : fail('invalid-request')),
percent_done: (x) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1 ? x : fail('invalid-request')),
state: (x) => (STORE_STATES.includes(x) ? x : fail('invalid-state')),
labels: (x) => set(x, (l) => (Number.isSafeInteger(l) && l >= 1 ? l : fail('invalid-request')), (a, b) => a - b),
assignees: (x) => set(x, (r) => (typeof r === 'string' && Object.hasOwn(roles, r) ? r : fail('unknown-role'))),
group: (x) => (x === null ? null : str(x, 64, 1).trim() && !/[\u0000-\u001f\u007f]/.test(x) ? x : fail('invalid-request')),
position: (x) => (Number.isSafeInteger(x) && x >= 0 && x <= STORE_POSITION_MAX ? x : fail('invalid-request')),
requirement: (x) => (typeof x === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(x) ? x : fail('invalid-request')),
};
}
// Checked edits on a copy of the fields. A state sets the bucket and done it implies. A move to
// another group with no position goes to the end of that group (a null position, which the write settles).
export function storeApply(fields, edits) {
const out = { ...fields, ...edits };
if (Object.hasOwn(edits, 'state')) Object.assign(out, { bucket: edits.state, done: edits.state === 'done' });
if (Object.hasOwn(edits, 'group') && !Object.hasOwn(edits, 'position') && edits.group !== fields.group) out.position = null;
return out;
}
// A new task's fields. A null position means after the last task in its group; the write settles it.
export function storeNewTask({ title, request = null, requirement = null, op = null }) {
return {
title, description: '', due_date: null, priority: 0, percent_done: 0, state: 'todo', bucket: 'todo', done: false,
labels: [], assignees: [], group: null, position: null, comments: 0, comment: null, verdict: null, request, requirement, op,
};
}
// Every field a stored task has, no more and no fewer.
export const STORE_FIELDS = Object.freeze(Object.keys(storeNewTask({ title: 'x' })));
// The fields each role verb may change: the slice 1 field table (packages/tasks/fields.json) on the
// store's fields. The table's labels and due date go with task.schedule, so the store's group and
// position do too; a comment's count goes with the latest comment; a close records its verdict; and
// a create sets what it cited. The broker refuses a role's write that changes any other field
// (field-writer), whatever the module asked for.
export const STORE_VERB_FIELDS = Object.freeze({
'task.create': Object.freeze(['title', 'description', 'due_date', 'priority', 'labels', 'group', 'position', 'request', 'requirement', 'op']),
'task.assign': Object.freeze(['assignees']),
'task.reassign': Object.freeze(['assignees']),
'task.schedule': Object.freeze(['due_date', 'labels', 'group', 'position']),
'task.priority.change': Object.freeze(['priority']),
'task.scope.change': Object.freeze(['title', 'description']),
'task.update.assigned': Object.freeze(['percent_done', 'state', 'bucket', 'comments', 'comment']),
'task.close': Object.freeze(['state', 'bucket', 'done', 'verdict']),
});
// Gaps between settled positions, so a person can place a task between two others.
export const STORE_POSITION_STEP = 1024;
// The most one task's fields take, as JSON.
const STORE_TASK_BYTES = 262144;
// Board order: ungrouped first, then group name, position and task number. Every read sorts this way.
export function storeOrder(a, b) {
const ga = a.group ?? '', gb = b.group ?? '';
if (ga !== gb) return ga < gb ? -1 : 1;
if ((a.position ?? 0) !== (b.position ?? 0)) return (a.position ?? 0) - (b.position ?? 0);
return a.task - b.task;
}
// A git commit id, SHA-1 or SHA-256.
const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x);
// Row 123 (#1617): the one list of organization-file keys limits.set and organization.set accept.
@@ -544,8 +644,9 @@ export class Broker {
#accessRevoked = new Set();
#clock = 0;
#processing;
#taskTracker;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
@@ -555,6 +656,7 @@ export class Broker {
if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function');
if (accessStamp !== null && typeof accessStamp !== 'function') throw new TypeError('accessStamp must be a function');
if (compactions !== null && typeof compactions !== 'function') throw new TypeError('compactions must be a function');
if (taskTracker !== null && typeof taskTracker !== 'function') throw new TypeError('taskTracker must be a function');
this.#businesses = structuredClone(businesses);
this.#store = store;
// A Broker is a trusted host startup, including in-process hosts. Never repair an existing state as off.
@@ -596,6 +698,11 @@ export class Broker {
// Row 194 (#1692, D2.3): roleFile(name) loads role definition `name` from the roles directory as it
// stands, for the hire checks. Without it a hire refuses not-configured.
this.#roleFile = roleFile;
// U4a (#1873): taskTracker(business) names the task-tracker module the bus runtime chose for the
// business at start. The store's writes refuse tracker-not-store under any other module, so a
// Vikunja organization never grows a second task list. Without it (the unit tests' brokers)
// every business uses the store.
this.#taskTracker = taskTracker ?? (() => 'store');
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
this.#requirements = loadRequirements();
// These are host capabilities, like project/library; no filesystem module enters the client closure.
@@ -1104,6 +1211,12 @@ export class Broker {
this.#businesses[name] = next;
this.#scheduleRequests();
}
// Host-only current role resolver shared by the broker and built-in task tracker.
taskRoles(business) {
const roles = this.#business(business).roles;
if (!object(roles)) fail('invalid-business');
return structuredClone(roles);
}
// Lifecycle Task 2 (#797): the chat project a bootstrapped product lists under. Host-only, so no verb
// reaches it. State is 'free', 'associated' (this exact id, name and product) or 'conflict' (anything else
// holding the id or the name), which lets a retry insert the row once.
@@ -4632,11 +4745,16 @@ export class Broker {
// the short form in task_ref order: no description, and the assignees that are a role's tracker
// bot, by role. A tombstone isn't on the board, so a filtered read leaves it out. `total` counts
// every task the filters match; `next` is the `after` for the next page, null on the last.
// U4a (#1873, REQ-WS-8): the store's tasks come after the Vikunja rows, in board order, in the same
// shape (task_ref, fields, digest, priority, requirement, at), with source 'store'. An archived store
// task leaves the no-argument read and the filtered read unless `archived: true` asks for archived
// tasks; `task_ref` alone still gives it, with archived true.
#tasks(business, a) {
const rows = this.#taskRows(business);
if (!Object.keys(a).length) return rows;
const rows = this.#taskRows(business), store = this.#storeRows(business);
if (!Object.keys(a).length) return [...rows, ...store.filter((r) => !r.archived)];
if ('task_ref' in a) {
if (Object.keys(a).length !== 1 || !taskRef(a.task_ref)) fail('invalid-request');
if (Object.keys(a).length !== 1 || !(taskRef(a.task_ref) || storeRef(a.task_ref))) fail('invalid-request');
if (storeRef(a.task_ref)) return this.#storeCurrent(business, a.task_ref);
return rows.find((r) => r.task_ref === a.task_ref) ?? fail('task-not-found');
}
const roles = this.#business(business).roles ?? {};
@@ -4649,7 +4767,8 @@ export class Broker {
('project_key' in a && !projectKey(a.project_key)) ||
('requirement_snapshot' in a && !id(a.requirement_snapshot)) ||
('limit' in a && !positive(a.limit, TASK_PAGE_MAX)) ||
('after' in a && !taskRef(a.after))
('archived' in a && typeof a.archived !== 'boolean') ||
('after' in a && !(taskRef(a.after) || storeRef(a.after)))
)
fail('invalid-request');
const bots = new Map(
@@ -4680,10 +4799,209 @@ export class Broker {
(!('project_key' in a) || t.project_key === a.project_key) &&
(!('requirement_snapshot' in a) || t.requirement_snapshot === a.requirement_snapshot),
);
const rest = 'after' in a ? matched.filter((t) => t.task_ref > a.after) : matched;
// The store's short form adds its group, position and archived flag; its assignees are role names.
const archived = a.archived ?? false;
const filed = store
.filter((r) => r.archived === archived)
.map(({ task_ref, fields: f, priority, requirement, at, archived: gone }) => ({
task_ref,
title: f.title,
done: f.done,
bucket: f.bucket,
assignees: [...f.assignees],
labels: [...f.labels],
priority,
requirement,
changed_at: at,
group: f.group,
position: f.position,
archived: gone,
}))
.filter(
(t) =>
(!('done' in a) || t.done === a.done) &&
(!('assignee' in a) || t.assignees.includes(a.assignee)) &&
(!('label' in a) || t.labels.includes(a.label)) &&
(!('requirement' in a) || t.requirement === a.requirement) &&
!('project_key' in a) &&
!('requirement_snapshot' in a),
);
// Archived tasks are store tasks only. Pages run through the Vikunja tasks, then the store's: a
// store `after` skips every Vikunja task and the store tasks up to it in board order. A cursor whose
// task moved since continues from where it now stands; one whose task was deleted reads its last row.
const all = [...(archived ? [] : matched), ...filed];
let rest = all;
if (taskRef(a.after ?? null)) rest = all.filter((t) => !taskRef(t.task_ref) || t.task_ref > a.after);
else if ('after' in a) {
const n = storeTaskNumber(a.after);
const last = this.#store.get('SELECT fields FROM store_tasks WHERE business=? AND task=? ORDER BY seq DESC LIMIT 1', business, n);
if (!last) fail('task-not-found');
const f = JSON.parse(last.fields), at = { group: f.group, position: f.position, task: n };
rest = filed.filter((t) => storeOrder({ group: t.group, position: t.position, task: storeTaskNumber(t.task_ref) }, at) > 0);
}
const limit = a.limit ?? TASK_PAGE;
const tasks = rest.slice(0, limit);
return { tasks, total: matched.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
return { tasks, total: all.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
}
// U4a (#1873, REQ-TASK-1): the built-in task store. A task is its latest store_tasks row, and it is
// archived when its latest archive or unarchive row is an archive. A deleted task's latest row is
// its delete, so it leaves every read here. Every read builds from the rows; nothing is cached.
#storeLatest(business, task = null) {
return this.#store
.all(
`SELECT t.*, (SELECT a.op FROM store_tasks a WHERE a.business=t.business AND a.task=t.task AND a.op IN ('archive','unarchive') ORDER BY a.seq DESC LIMIT 1) AS archive
FROM store_tasks t WHERE t.business=?${task === null ? '' : ' AND t.task=?'}
AND t.seq=(SELECT max(seq) FROM store_tasks WHERE business=t.business AND task=t.task) AND t.op<>'delete' ORDER BY t.task`,
...(task === null ? [business] : [business, task]),
)
.map((r) => {
const fields = JSON.parse(r.fields);
// The keys a Vikunja `tasks` row has that the Console and `mosaic tasks` read (task_ref,
// fields, digest, priority, requirement, at, seq, source), with source 'store', and the
// store's own: revision, the verb that wrote it, who (role and run, or the person), the event.
return {
seq: r.seq, at: r.at, business: r.business, task_ref: `task:${r.task}`, tracker: 'store', source: 'store',
updated: r.at, revision: r.revision, verb: r.verb, role: r.by_role, run: r.by_run, human: r.by_human, event: r.event,
digest: createHash('sha256').update(JSON.stringify([r.revision, r.fields])).digest('hex'), fields,
priority: fields.priority ?? null, requirement: fields.requirement ?? null, archived: r.archive === 'archive',
};
});
}
// The live tasks, archived ones too, in board order.
#storeRows(business) {
const at = (r) => ({ group: r.fields.group, position: r.fields.position, task: storeTaskNumber(r.task_ref) });
return this.#storeLatest(business).sort((a, b) => storeOrder(at(a), at(b)));
}
#storeOn(business) {
this.#business(business);
if (this.#taskTracker(business) !== 'store') fail('tracker-not-store');
}
#storeCurrent(business, ref) {
const n = storeTaskNumber(ref);
if (n === null) fail('invalid-request');
return this.#storeLatest(business, n)[0] ?? fail('task-not-found');
}
// A role's create cites the person's instruction that asked for it, as a Vikunja create does.
#storeRequest(business, request) {
return (
typeof request === 'string' &&
!!this.#store.get(
"SELECT 1 FROM events WHERE business=? AND id=? AND kind='human.input' AND actor_role IS NULL AND actor_run IS NULL AND json_extract(body,'$.kind')='instruction'",
business,
request,
)
);
}
// The whole task a write leaves: every field the store keeps and no other, each already in the form
// its check gives (labels and assignees sorted, no repeats), and the bucket and done its state implies.
#storeFields(business, f, previous) {
if (!object(f) || Object.keys(f).length !== STORE_FIELDS.length || !STORE_FIELDS.every((k) => Object.hasOwn(f, k))) fail('invalid-request');
const roles = { ...this.taskRoles(business), ...Object.fromEntries((previous?.assignees ?? []).map(role => [role, true])) };
const check = storeFieldChecks(roles);
for (const k of STORE_EDIT_FIELDS) {
if (k === 'requirement' && f[k] === null) continue; // historical tasks
if (JSON.stringify(check[k](f[k])) !== JSON.stringify(f[k])) fail('invalid-request');
}
const text = (x, max) => x === null || (typeof x === 'string' && x.length >= 1 && x.length <= max && !x.includes('\0'));
if (
f.bucket !== f.state ||
f.done !== (f.state === 'done') ||
!(Number.isSafeInteger(f.comments) && f.comments >= 0) ||
!(f.comment === null || (object(f.comment) && Object.keys(f.comment).length === 2 && text(f.comment.text, 10000) && f.comment.text !== null && Object.hasOwn(f.comment, 'by') && text(f.comment.by, 128) && f.comment.by !== null)) ||
!text(f.verdict, 256) ||
!text(f.request, 64) ||
!(f.requirement === null || (typeof f.requirement === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(f.requirement))) ||
!text(f.op, 256)
)
fail('invalid-request');
}
// Appends one row and the event that records it, and answers the task as it now reads (or that it
// was deleted). The caller owns the transaction and has checked who may write. A null position
// settles after the last task in the task's group.
#storeWrite(s, { current, op, verb, fields, by, kind, body }) {
const task = current
? storeTaskNumber(current.task_ref)
: (this.#store.get('SELECT max(task) AS n FROM store_tasks WHERE business=?', s.business).n ?? 0) + 1;
const ref = `task:${task}`;
const peers = this.#storeRows(s.business).filter((r) => r.task_ref !== ref && (r.fields.group ?? null) === (fields.group ?? null));
if (fields.position === null) {
const last = peers.reduce((m, r) => Math.max(m, r.fields.position ?? 0), -STORE_POSITION_STEP);
if (last + STORE_POSITION_STEP > STORE_POSITION_MAX) fail('task-position-full');
fields = { ...fields, position: last + STORE_POSITION_STEP };
} else if ((!current || fields.group !== current.fields.group || fields.position !== current.fields.position) && peers.some(r => r.fields.position === fields.position)) {
fail('task-position-full');
}
this.#storeFields(s.business, fields, current?.fields);
const json = JSON.stringify(fields);
if (Buffer.byteLength(json) > STORE_TASK_BYTES) fail('invalid-request');
this.#secretCheck(fields);
const event = this.#event(s, kind, { ...body, task_ref: ref, tracker: 'store', op, verb }, ref);
this.#store.run(
'INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
this.#now(),
s.business,
task,
current ? current.revision + 1 : 1,
op,
verb,
by.role,
by.run,
by.human,
event,
json,
);
return op === 'delete' ? { task_ref: ref, deleted: true, event } : this.#storeCurrent(s.business, ref);
}
// U4a: the store module's one write path for a role, as authorize() and recordTask() are the
// Vikunja adapter's. One transaction holds the holder check, the authority (a decision is spent here
// as #consumeAuthority spends it), a close's verdict check, the event and the row. `change` is the
// module's: it gets a copy of the task's fields (a new task's on a create) and of its row (null on a
// create) and returns the whole new fields. The write may change only what STORE_VERB_FIELDS gives
// its verb. An archived task takes no role write; a person unarchives it first. A create that names
// an `op` the same role already created with answers that task again, with nothing written; the same
// op with other fields, or from another role, refuses (task-create-conflict).
storeTask(cap, { verb, task_ref = null, decision = null, expect = null, verdict = null, change } = {}) {
const s = this.#session(cap);
if (!TASK_VERBS.includes(verb) || typeof change !== 'function' || (verb === 'task.create') !== (task_ref === null)) fail('invalid-request');
return this.#store.transaction(() => {
this.#agent(s);
this.#storeOn(s.business);
const current = task_ref === null ? null : this.#storeCurrent(s.business, task_ref);
if (current?.archived) fail('task-archived');
if (expect !== null && current && expect !== current.digest) fail('task-conflict');
const before = current?.fields ?? storeNewTask({ title: null }), allowed = STORE_VERB_FIELDS[verb];
const fields = change(structuredClone(before), current && structuredClone(current));
if (!object(fields)) fail('invalid-request');
for (const k of new Set([...Object.keys(before), ...Object.keys(fields)]))
if (JSON.stringify(before[k]) !== JSON.stringify(fields[k]) && !allowed.includes(k)) fail('field-writer');
if (!current && !this.#storeRequest(s.business, fields.request)) fail('request-not-found');
if (!current && typeof fields.op === 'string') {
const old = this.#store.get(
"SELECT task, by_role, fields FROM store_tasks WHERE business=? AND op='create' AND json_extract(fields,'$.op')=? ORDER BY seq LIMIT 1",
s.business,
fields.op,
);
if (old) {
const was = JSON.parse(old.fields), same = (k) => JSON.stringify(was[k]) === JSON.stringify(fields[k]);
if (old.by_role !== s.role || !STORE_FIELDS.every((k) => same(k) || (k === 'position' && fields.position === null))) fail('task-create-conflict');
this.#checkAuthority(s, verb, { decision, target: null });
const ref = `task:${old.task}`;
return this.#storeLatest(s.business, old.task)[0] ?? { task_ref: ref, deleted: true };
}
}
const authority = this.#checkAuthority(s, verb, { decision, target: task_ref });
if (authority.decision && this.#spent(s.business, authority.decision)) fail('decision-consumed');
if (verb === 'task.close') this.#closeVerdict(s, task_ref, verdict);
return this.#storeWrite(s, {
current,
op: current ? 'change' : 'create',
verb,
fields,
by: { role: s.role, run: s.run, human: null },
kind: 'action.allowed',
body: { action: verb, ...authority, target: task_ref, ...(verb === 'task.close' ? { verdict } : {}) },
});
});
}
#checkAuthority(s, action, context = {}) {
this.#processingWork();
@@ -5460,16 +5778,18 @@ export class Broker {
}
#refused(s, e, request, other = 'storage-refused') {
const error = e instanceof BusError ? e : new BusError(other);
// No request content or raw exception text in refusal evidence.
// No request content or raw exception text in refusal evidence. U4a: a built-in store task's
// ref files the refusal on that task's trail too.
const subject = (x) => taskRef(x) || storeRef(x);
try {
this.#store.transaction(() =>
this.#event(
s,
'action.refused',
{ code: error.code },
taskRef(request?.args?.task_ref)
subject(request?.args?.task_ref)
? request.args.task_ref
: taskRef(request?.args?.target)
: subject(request?.args?.target)
? request.args.target
: null,
),
@@ -5560,6 +5880,28 @@ export class Broker {
return result;
}).catch(e => { throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request); });
}
if (request.verb === 'system-types.console') {
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
keys(args, ['project']);
if (!this.#organization) fail('not-configured');
const enabled = this.#organization(s.business, 'system-types.current', {}, s.human);
if (!Array.isArray(enabled)) fail('not-configured');
let project = null;
if (args.project !== undefined) {
const id = identifier(args.project);
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', id, s.business)) fail('project-not-found');
const row = this.#store.get('SELECT types FROM project_system_types WHERE business=? AND project=? ORDER BY seq DESC LIMIT 1', s.business, id);
const selected = row ? JSON.parse(row.types) : [];
project = { project: id, selected, active: selected.filter(x => enabled.includes(x)), inactive: selected.filter(x => !enabled.includes(x)) };
}
return Promise.resolve(this.#organization(s.business, 'system-types.installed', {}, s.human))
.then(installed => {
if (!Array.isArray(installed)) fail('not-configured');
const result = { installed, enabled, project };
this.#secretCheck(result);
return result;
});
}
if (request.verb === 'project.remote.get.console' || request.verb === 'project.remote.set.console') {
if (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
if (!this.#organization) fail('not-configured');
@@ -5666,7 +6008,7 @@ export class Broker {
if (["limits", "plugins", "instances", "routes", "organization.revision"].includes(request.verb)) keys(args, []);
else if (request.verb === "plugins.set") {
keys(args, ["revision", "plugins", "usage"], ["revision", "plugins"]);
if (s.tester && object(args.plugins) && Object.hasOwn(args.plugins, 'model-provider')) fail('human-required');
if (s.tester && object(args.plugins) && (Object.hasOwn(args.plugins, 'model-provider') || Object.hasOwn(args.plugins, 'system-type'))) fail('human-required');
}
// Row 165 (#1662): instances.set is human-only and outside ORGANIZATION_KEYS, so
// organization.set and the chief of staff's tool never reach it (sage-0918).
@@ -7566,10 +7908,64 @@ export class Broker {
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null))
.map((p) => (p.state === 'archive' ? { id: p.id, at: p.at, name: p.name, repo: p.repo, archived: true } : { id: p.id, at: p.at, name: p.name, repo: p.repo }));
}
case 'project.system-types':
case 'project.system-types.set': {
this.#human(s);
const write = verb === 'project.system-types.set';
keys(a, write ? ['project', 'systemTypes'] : ['project'], write ? ['project', 'systemTypes'] : ['project']);
const project = identifier(a.project);
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', project, s.business)) fail('project-not-found');
// When configured, read the trusted file each time; a stale bus snapshot must not reactivate a disabled type.
const enabled = this.#organization ? this.#organization(s.business, 'system-types.current', {}, s.human) : (b.plugins?.['system-type'] ?? []);
if (!Array.isArray(enabled)) fail('not-configured');
if (write) {
if (!Array.isArray(a.systemTypes) || a.systemTypes.some(x => typeof x !== 'string') || new Set(a.systemTypes).size !== a.systemTypes.length) fail('invalid-request');
if (a.systemTypes.some(x => !enabled.includes(x))) fail('system-type-not-enabled');
this.#store.run('INSERT INTO project_system_types(at,business,project,types,by) VALUES(?,?,?,?,?)', this.#now(), s.business, project, JSON.stringify(a.systemTypes), this.#owner(s));
}
const row = this.#store.get('SELECT types FROM project_system_types WHERE business=? AND project=? ORDER BY seq DESC LIMIT 1', s.business, project);
const selected = row ? JSON.parse(row.types) : [];
return { project, selected, active: selected.filter(x => enabled.includes(x)), inactive: selected.filter(x => !enabled.includes(x)) };
}
// #797 MVP3 (owner report 2026-11-06): the owner's reversible retirement of a project. The
// human gate is project.create's minus the tester carve-out: tester, user and agent sessions
// are refused here. Each verb appends one project_archives row (the audit trail) and deletes
// nothing; a repeat of the state the project already holds refuses.
// U4a (#1873, REQ-TASK-1): the person's own writes to the built-in task store, from the terminal
// (human-cli). Each writes a human.input event (body kind 'task') that its store_tasks row cites, so a
// person's edit comes in as an event like any other input. The person writes any field, may reopen a
// done task, and is the only one who archives, unarchives or deletes. `expect` is the digest the
// person last read; a later write refuses task-conflict.
case 'tasks.create':
case 'tasks.edit':
case 'tasks.archive':
case 'tasks.unarchive':
case 'tasks.delete': {
this.#human(s);
this.#storeOn(s.business);
const creating = verb === 'tasks.create', editing = verb === 'tasks.edit';
keys(a, creating ? STORE_EDIT_FIELDS : editing ? ['task_ref', 'expect', ...STORE_EDIT_FIELDS] : ['task_ref', 'expect'], creating ? ['title', 'requirement'] : ['task_ref']);
if (Object.hasOwn(a, 'expect') && !(typeof a.expect === 'string' && /^[0-9a-f]{64}$/.test(a.expect))) fail('invalid-request');
const current = creating ? null : this.#storeCurrent(s.business, a.task_ref);
const roles = { ...this.taskRoles(s.business), ...Object.fromEntries((current?.fields.assignees ?? []).map(role => [role, true])) };
const check = storeFieldChecks(roles), edits = {};
for (const k of STORE_EDIT_FIELDS) if (Object.hasOwn(a, k)) edits[k] = check[k](a[k]);
if (editing && !Object.keys(edits).length) fail('invalid-request');
if (current && Object.hasOwn(a, 'expect') && a.expect !== current.digest) fail('task-conflict');
if (editing && current.archived) fail('task-archived');
if (verb === 'tasks.archive' && current.archived) fail('task-already-archived');
if (verb === 'tasks.unarchive' && !current.archived) fail('task-not-archived');
const fields = creating ? storeApply(storeNewTask({ title: edits.title }), edits) : editing ? storeApply(current.fields, edits) : current.fields;
return this.#storeWrite(s, {
current,
op: creating ? 'create' : editing ? 'change' : verb.slice('tasks.'.length),
verb,
fields,
by: { role: null, run: null, human: this.#owner(s) },
kind: 'human.input',
body: { kind: 'task' },
});
}
case 'project.archive':
case 'project.unarchive': {
this.#human(s);
@@ -8350,6 +8746,19 @@ export class Broker {
);
}
}
// U4a (#1873): a store task's rows (its whole history, a delete included) and the person's
// instruction a role's create cited, with that instruction's message.
if (storeRef(subject)) {
const own = this.#store.all('SELECT * FROM store_tasks WHERE business=? AND task=? ORDER BY seq', s.business, storeTaskNumber(subject));
add('store_tasks', own);
const request = own.length ? JSON.parse(own[0].fields).request : null;
const input = request && this.#store.get("SELECT * FROM events WHERE business=? AND kind='human.input' AND id=?", s.business, request);
if (input) {
add('events', [input]);
const body = JSON.parse(input.body);
if (body.message) add('messages', this.#store.all('SELECT * FROM messages WHERE business=? AND id=?', s.business, body.message));
}
}
for (const m of [...rows.values()])
if (m.table === 'messages')
add('deliveries', this.#store.all('SELECT * FROM deliveries WHERE message=?', m.id));
+16
View File
@@ -0,0 +1,16 @@
-- Per-project selections are a human-authored append-only settings trail.
CREATE TABLE project_system_types (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
types TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX project_system_types_project ON project_system_types (business, project, seq);
CREATE TRIGGER project_system_types_no_update BEFORE UPDATE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_delete BEFORE DELETE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_types WHEN EXISTS (SELECT 1 FROM project_system_types WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END;
+41
View File
@@ -0,0 +1,41 @@
-- U4a (#1873): the task store built into each organization, the default task-tracker module
-- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row
-- and the rows are its history: who wrote it (a role and run, or the person), when, under which
-- verb, and the event that records the write. Task numbers count up per organization and are
-- never reused. A delete is a last row: the task leaves every read and its history stays until the
-- organization is removed, which removes this whole database with it. Only a new append-only
-- table, its index and its guards; no existing row changes and nothing is deleted.
CREATE TABLE store_tasks (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task INTEGER NOT NULL CHECK (task >= 1),
revision INTEGER NOT NULL CHECK (revision >= 1),
op TEXT NOT NULL CHECK (op IN ('create','change','archive','unarchive','delete')),
verb TEXT NOT NULL,
by_role TEXT,
by_run TEXT,
by_human TEXT,
event TEXT NOT NULL REFERENCES events(id),
fields TEXT NOT NULL CHECK (json_valid(fields) AND json_type(fields) = 'object'),
CHECK ((by_human IS NULL) = (by_role IS NOT NULL AND by_run IS NOT NULL)),
UNIQUE (business, task, revision)
) STRICT;
CREATE INDEX store_tasks_task ON store_tasks (business, task, seq);
CREATE TRIGGER store_tasks_no_update BEFORE UPDATE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_delete BEFORE DELETE ON store_tasks BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_no_replace BEFORE INSERT ON store_tasks WHEN EXISTS (SELECT 1 FROM store_tasks WHERE seq = NEW.seq OR (business = NEW.business AND task = NEW.task AND revision = NEW.revision)) BEGIN SELECT RAISE(ABORT, 'store_tasks is append-only'); END;
CREATE TRIGGER store_tasks_revision BEFORE INSERT ON store_tasks
WHEN NEW.revision IS NOT COALESCE((SELECT MAX(revision) FROM store_tasks WHERE business = NEW.business AND task = NEW.task), 0) + 1
BEGIN SELECT RAISE(ABORT, 'a store task revision follows the last one'); END;
CREATE TRIGGER store_tasks_create BEFORE INSERT ON store_tasks
WHEN (NEW.op = 'create') IS NOT (NEW.revision = 1)
OR (NEW.op = 'create' AND NEW.task IS NOT COALESCE((SELECT MAX(task) FROM store_tasks WHERE business = NEW.business), 0) + 1)
BEGIN SELECT RAISE(ABORT, 'a store task is created once, with the next number'); END;
CREATE TRIGGER store_tasks_deleted BEFORE INSERT ON store_tasks
WHEN EXISTS (SELECT 1 FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op = 'delete')
BEGIN SELECT RAISE(ABORT, 'a deleted store task takes no more rows'); END;
CREATE TRIGGER store_tasks_archive BEFORE INSERT ON store_tasks
WHEN NEW.op IN ('archive','unarchive')
AND (COALESCE((SELECT op FROM store_tasks WHERE business = NEW.business AND task = NEW.task AND op IN ('archive','unarchive') ORDER BY seq DESC LIMIT 1), 'unarchive') = 'archive') = (NEW.op = 'archive')
BEGIN SELECT RAISE(ABORT, 'a store task archive row changes state'); END;
+54 -4
View File
@@ -22,6 +22,7 @@ import { readSessions, sessionsFile, stopSession } from '../../seat/src/session.
import { compactionCount, storeDir } from '../../seat/src/compactions.mjs';
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
import { pluginSettings } from '../../organization/src/plugin-settings.mjs';
import { loadManagementClient } from '../../model-proxy/src/client.mjs';
import { ProxyPage } from './proxy-page.mjs';
@@ -62,6 +63,8 @@ export const ACCESS_SWEEP_MS = 30_000;
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
// Human limits writes use the shared organization writer behind the verified transport.
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
// U4a (#1873): it serves the businesses whose task-tracker module is Vikunja; the others get theirs
// through the plug-in loader, the built-in store by default (see trackerOf in startBroker).
// REQ-HARN-2, REQ-PLUG-2 (decision 0b81b0c5): one web service per business. Its search source is
// the "search-source" plug-in the business's organization file switches on: searxng-url (the
// business var web.searxng) or searxng-bundled. With none, web.search refuses "not configured" and
@@ -652,7 +655,21 @@ export async function startBroker({
return no(declared ? 'project-pending' : 'project-unknown');
}
const requirementsHost = createRequirementsHost({ store, businesses, builtinBinding, journal: requestJournal });
// U4a (#1873, REQ-TASK-1, REQ-PLUG-1): the task-tracker module each business uses, fixed for this
// start. The organization file's "task-tracker" plug-in names it. With none, a business the boot
// config gives a Vikunja tracker keeps Vikunja (so does every business when an in-process caller
// hands `tasks` and no `trackers`), and any other business gets the built-in store. A business that
// names another module while the boot config also gives it a Vikunja tracker refuses the boot:
// two trackers can't both hold its tasks.
const trackerOf = new Map();
for (const [id, b] of Object.entries(businesses)) {
const named = b?.plugins?.['task-tracker'];
const tracked = trackers !== null && typeof trackers === 'object' && Object.hasOwn(trackers, id);
if (named !== undefined && named !== 'vikunja' && tracked) throw new BusError('tracker-config');
trackerOf.set(id, named ?? ((trackers ? tracked : tasks !== null) ? 'vikunja' : 'store'));
}
broker = new Broker({
taskTracker: (id) => trackerOf.get(id),
workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }),
requirementsHost,
requestLifecycle: services => {
@@ -751,6 +768,8 @@ export async function startBroker({
known.set(id, canonicalJson(next.projects));
return getProjectRemote(writer, id, args.project);
}
if (verb === 'system-types.installed') return installedSystemTypes();
if (verb === 'system-types.current') return settings.current(id).plugins?.['system-type'] ?? [];
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='plugins')return {...plugins.read(id), available:{harness:levels.names ?? []}};
// Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
@@ -885,11 +904,42 @@ export async function startBroker({
}
const bound = launches.map(bindLaunch);
const readCaps = readers.map((business) => ({ business, cap: broker.bindReader({ business }) }));
if (tasks) {
adapter = await tasks({ broker, credentials, businesses, processing });
if (typeof adapter?.handle !== 'function' || !Number.isSafeInteger(adapter.timeout) || adapter.timeout < 1)
// U4a: one adapter per task-tracker module in use, behind one handle that routes each call by the
// caller's business. The host's `tasks` factory is the Vikunja module. Any other module, and Vikunja
// when the host gave no factory but an organization file names it, loads through the plug-in loader
// with the boot config's `trackers`. A module that refuses to load or has no factory refuses
// startup with tracker-config. A module whose factory makes no
// adapter refuses the boot (invalid-adapter), and every adapter made so far is closed. The host's
// factory is always made when given, used or not, so its own checks of `trackers` still run.
const made = new Map();
adapter = { async close() { for (const a of made.values()) await a?.close?.(); } };
const pluginLog = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`);
for (const name of new Set([...(tasks ? ['vikunja'] : []), ...trackerOf.values()])) {
let factory = null;
if (name === 'vikunja' && tasks) factory = tasks;
else if (name !== 'vikunja' || [...Object.values(businesses)].some((b) => b?.plugins?.['task-tracker'] === 'vikunja')) {
const loader = createLoader({ log: pluginLog });
const { refused } = await loader.load({ 'task-tracker': name });
const [module] = loader.modules('task-tracker');
if (!refused.length && module) factory = module.adapter({ trackers: trackers ?? {} });
}
// A host may omit its implicit Vikunja adapter, retaining the legacy unconfigured path.
// An explicitly selected module must always supply a factory.
if (!factory && name === 'vikunja' && !tasks && !Object.values(businesses).some(b => b?.plugins?.['task-tracker'] === 'vikunja')) continue;
if (typeof factory !== 'function') throw new BusError('tracker-config');
const one = await factory({ broker, credentials, businesses, processing });
made.set(name, one);
if (typeof one?.handle !== 'function' || !Number.isSafeInteger(one.timeout) || one.timeout < 1)
throw new BusError('invalid-adapter');
}
if (made.size) {
adapter.timeout = Math.max(...[...made.values()].map((a) => a.timeout));
adapter.handle = async (cap, verb, args) => {
const one = made.get(trackerOf.get(broker.identity(cap).business));
if (!one) throw new BusError('tracker-unconfigured');
return one.handle(cap, verb, args);
};
}
const web = await webServices(businesses);
// Row 210 (#1707, design D7 step 6): clear what a killed bus child left, before anyone can call.
if (runRoot) sweepRunDir(runRoot);
@@ -918,7 +968,7 @@ export async function startBroker({
web: (business) => web.get(business) ?? null,
host: host ? { busy: (...args) => host.busy(...args),
run: (...args) => processing.work(() => host.run(...args)) } : null,
tasks: adapter ? { handle: adapter.handle, timeout: adapter.timeout } : null,
tasks: adapter?.handle ? { handle: adapter.handle, timeout: adapter.timeout } : null,
broker,
path,
peerReader,
+12 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = '1cec88b0b697c648bcab18b45f86d85526aa1701a7619046367e16c38476aee8';
export const SCHEMA_VERSION = '3t';
export const SCHEMA_SHA256 = 'e5868996851ee60a3c6d2798a17b9da446ba666c859d84d1b575ca5104ae61f8';
export const SCHEMA_VERSION = '3v';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -112,6 +112,14 @@ const migrations = [{
// and its guards; no existing row changes and nothing is deleted.
from: '3s', to: '3t',
sql: readFileSync(new URL('./migrations/3s-3t.sql', import.meta.url), 'utf8'),
}, {
from: '3t', to: '3u',
sql: readFileSync(new URL('./migrations/3t-3u.sql', import.meta.url), 'utf8'),
}, {
// U4a (#1873): the task store built into each organization, the default task-tracker module.
// Only a new append-only table, its index and its guards; no existing row changes.
from: '3u', to: '3v',
sql: readFileSync(new URL('./migrations/3u-3v.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -138,6 +146,8 @@ const timedTables = [
'request_snapshot_events',
'request_project_events',
'project_archives',
'project_system_types',
'store_tasks',
];
const hex = (value) => createHash('sha256').update(value).digest('hex');
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
@@ -19,7 +19,7 @@ const here = (p) => new URL(p, import.meta.url);
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -94,6 +94,8 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -101,7 +103,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -151,7 +153,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
});
@@ -163,14 +165,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+2 -2
View File
@@ -616,7 +616,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -640,7 +640,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3u';")
.replace('}];\nconst timedTables', `}, { from: '3t', to: '3u', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3w';")
.replace('}];\nconst timedTables', `}, { from: '3v', to: '3w', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql', '3u-3v.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3t ready/);
assert.match(migrated.stdout, /bus schema 3v ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+1 -1
View File
@@ -89,7 +89,7 @@ function setup(t) {
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
// no unsuffixed verb is ever a console verb.
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
+8 -6
View File
@@ -36,11 +36,11 @@ function source3j(t) {
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -84,6 +84,8 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -105,7 +107,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -131,7 +133,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
});
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -142,14 +144,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3j,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3j';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
assert.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+8 -6
View File
@@ -22,7 +22,7 @@ const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1',
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -42,7 +42,7 @@ function source3i(t) {
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -87,6 +87,8 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql'));
copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql'));
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -110,7 +112,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path).filter(r => !LATER.includes(r.name));
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -135,7 +137,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
});
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -146,14 +148,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3i,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3i';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
assert.throws(() => new Store3i(root), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+1 -1
View File
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
+4 -1
View File
@@ -198,6 +198,9 @@ test('3p through 3s migration preserves existing request rows and the append-onl
const path = join(root, 'bus/bus.sqlite'), ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
// 3s-3t (#797) adds project_archives; the 3p fixture predates it and the replayed chain recreates it.
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace(",'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced'", '');
const db = new DatabaseSync(path); db.exec(ddl);
const at = '2026-11-03T00:00:00.000Z';
@@ -213,7 +216,7 @@ test('3p through 3s migration preserves existing request rows and the append-onl
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); t.after(() => store.close());
assert.deepEqual(store.all('SELECT * FROM request_events'), before);
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')", at);
assert.throws(() => store.run('DELETE FROM request_events'), /append-only/);
assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/);
+5 -4
View File
@@ -143,7 +143,8 @@ const here = (p) => new URL(p, import.meta.url);
const migrationSQL = readFileSync(here('../src/migrations/3s-3t.sql'), 'utf8');
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
assert.equal(currentDDL.split(migrationSQL).length, 2, 'schema.sql holds the 3s→3t step exactly once');
const ddl3s = currentDDL.replace(migrationSQL, '');
// 3u-3v (#1873, U4a) adds store_tasks after them; a 3s store has neither.
const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), '').replace(readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8'), '');
function source3s(t) {
const root = mkdtempSync(join(tmpdir(), 'project-archive-3s-'));
@@ -178,7 +179,7 @@ test('a 3s store migrates to 3t: project_archives is new and empty, every row st
assert.deepEqual(master(path), was, 'the refused open changed nothing');
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.equal(store.get("SELECT id FROM events").id, 'preserved');
assert.equal(store.get("SELECT name FROM projects").name, 'kept');
assert.deepEqual(store.all('SELECT seq,at,business,project,op,by FROM project_archives'), []);
@@ -211,7 +212,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3s).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3s';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3s';")
.replace(/\}, \{\n {2}\/\/ #797 MVP3[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3s';") && edited.includes('3r-3s') && !edited.includes('3s-3t'), 'the 3s engine has no 3s→3t step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -221,7 +222,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
const Store3s = (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
assert.throws(() => new Store3s(root), /schema-mismatch/);
assert.throws(() => new Store3s(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
// The same engine opens a fresh 3s store, so the refusal is the version's and not a broken copy.
const { root: old } = source3s(t);
+7 -4
View File
@@ -90,7 +90,7 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
test('the console verb list includes the artifact read and existing owner controls', () => {
assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
});
@@ -302,7 +302,7 @@ test('a cancel is refused when the owner-to-PM edge closed, with no withdrawn de
});
test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => {
assert.equal(SCHEMA_VERSION, '3t');
assert.equal(SCHEMA_VERSION, '3v');
const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-'));
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), AT = '2026-11-03T00:00:00.000Z';
@@ -310,8 +310,11 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
// 3s-3t (#797) adds project_archives, outside request_events; the 3q fixture predates it and
// the replayed chain recreates it, so the step's text is removed here like the kinds are.
const step3t = readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8');
// 3t-3u (#1873, SYS1) adds project_system_types the same way, and 3u-3v (U4a) adds store_tasks.
const step3u = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
const step3v = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8');
// 3p-3q shipped the PRD kinds and mode.changed; 3q-3r adds the three Task 3 kinds after them.
const ddl3q = current.replace(step3t, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
const ddl3q = current.replace(step3t, '').replace(step3u, '').replace(step3v, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
assert.notEqual(ddl3q, current, 'the fixture is the narrower schema');
const migration = readFileSync(new URL('../src/migrations/3q-3r.sql', import.meta.url), 'utf8');
assert.ok(migration.includes("'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'"), 'the step carries every 3q kind plus its own');
@@ -328,7 +331,7 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n');
const store = new Store(root);
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes, including the 3q kinds');
const fresh = new DatabaseSync(':memory:'); fresh.exec(current);
@@ -230,6 +230,9 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
// 3s-3t (#797) adds project_archives; the 3r fixture predates it and the replayed chain recreates it.
db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace(",'advanced'", ''));
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3r');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
@@ -250,7 +253,7 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before);
assert.deepEqual(store.all("SELECT name,sql FROM sqlite_master WHERE type='trigger' AND tbl_name IN ('request_events','request_effects') ORDER BY name"), guards);
assert.equal(store.get("SELECT name FROM sqlite_master WHERE type='index' AND name='request_events_request'").name, 'request_events_request');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',16,'advanced',?,'{}')", at);
assert.equal(store.get("SELECT max(seq) n FROM request_events").n, 42);
assert.throws(() => store.run("UPDATE request_events SET kind='opened'"), /append-only/);
+8 -2
View File
@@ -278,6 +278,9 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
// 3s-3t (#797) adds project_archives after the request tables; the pre-3t fixtures below
// predate it and the replayed chain recreates it, so the step's text comes out here too.
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8');
assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length);
@@ -290,7 +293,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
db.close(); chmodSync(path, 0o600); const before = readFileSync(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n'); assert.equal(SCHEMA_VERSION, '3t');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n'); assert.equal(SCHEMA_VERSION, '3v');
const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved');
for (const table of ['request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0);
store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0);
@@ -313,6 +316,9 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
// The 3o schema is the current one with the narrower kind list.
const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8')
.replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '')
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'");
const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z';
const db = new DatabaseSync(path); db.exec(ddl3o);
@@ -329,7 +335,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n');
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n');
const store = new Store(root);
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes');
assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects);
+3 -2
View File
@@ -527,7 +527,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards.
// Task 4 (3p to 3q), Task 3 (3q to 3r) and Task 6b (3r to 3s) widen request_events.kind in place.
// #797 MVP3 (3s to 3t) adds project_archives and its five guards; project-archive.test.mjs tests them.
assert.equal(count('table') - 1, 31);
assert.equal(count('trigger'), 119);
// 3t to 3u adds project_system_types and four guards; U4a #1873 (3u to 3v) adds store_tasks and its seven guards.
assert.equal(count('table') - 1, 33);
assert.equal(count('trigger'), 130);
assert.equal(count('view'), 5);
});
+4 -4
View File
@@ -530,9 +530,9 @@ test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
const after = tables(path);
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives']) {
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) {
assert.deepEqual(after[name], [], name);
delete after[name];
}
@@ -571,7 +571,7 @@ async function engine3m(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3m';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3m';")
.replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -588,7 +588,7 @@ test('T4 schema: the 3m engine refuses a 3n store, migrating or not, and leaves
const Store3m = await engine3m(root);
assert.throws(() => new Store3m(root), /schema-mismatch/);
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
+3 -3
View File
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
@@ -430,7 +430,7 @@ async function engine3k(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3k';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3k';")
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
+109
View File
@@ -0,0 +1,109 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
test('project selections require enabled types, retain inactive selections, and only the owner writes', t => {
const root = mkdtempSync(join(tmpdir(), 'sys-'));
const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: [], crossRole: [] } } }, projects: {}, plugins: { 'system-type': ['coding'] }, vars: { 'bus.tester': true } };
const broker = new Broker({ store, businesses: { demo: business } });
const owner = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = broker.bindTester({ business: 'demo' });
const agent = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
const call = (verb, args = {}, cap = owner) => broker.request(cap, { verb, args });
const project = call('project.create', { name: 'example' }).id;
assert.deepEqual(call('project.system-types', { project }), { project, selected: [], active: [], inactive: [] });
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['montage'] }), /system-type-not-enabled/);
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['coding'] }, tester), /tester-refused/);
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['coding'] }, agent), /human-required/);
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['coding', 'coding'] }), /invalid-request/);
call('project.system-types.set', { project, systemTypes: ['coding'] });
assert.deepEqual(call('project.system-types', { project }).active, ['coding']);
broker.reloadBusiness('demo', { ...business, plugins: { 'system-type': [] } });
assert.deepEqual(call('project.system-types', { project }), { project, selected: ['coding'], active: [], inactive: ['coding'] });
assert.deepEqual(store.all('SELECT by FROM project_system_types').map(r => r.by), ['jason']);
assert.throws(() => store.run("UPDATE project_system_types SET types='[]'"), /append-only/);
assert.throws(() => store.run('DELETE FROM project_system_types'), /append-only/);
assert.throws(() => store.run("INSERT INTO project_system_types(at,business,project,types,by) VALUES(?,?,?,?,?)", new Date().toISOString(), 'other', project, '[]', 'jason'), /belongs to its project/);
});
test('console owner can read system types but cannot write project selections', async t => {
const root = mkdtempSync(join(tmpdir(), 'sys-'));
const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: [], crossRole: [] } } }, projects: {}, plugins: { 'system-type': ['coding'] }, vars: { 'bus.tester': true } };
let discoveries = 0, enabled = ['coding'];
const broker = new Broker({ store, businesses: { demo: business }, organization: (_id, verb) => { if (verb === 'system-types.current') return enabled; if (verb === 'system-types.installed') { discoveries++; return installedSystemTypes(); } } });
const owner = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const consoleOwner = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
const tester = broker.bindTester({ business: 'demo' });
assert.throws(() => broker.request(tester, { verb: 'plugins.set', args: { revision: 'a'.repeat(64), plugins: { 'system-type': ['coding'] } } }), /human-required/);
const project = broker.request(owner, { verb: 'project.create', args: { name: 'example' } }).id;
const result = await broker.request(consoleOwner, { verb: 'system-types.console', args: { project } });
assert.deepEqual(result.enabled, ['coding']);
assert.equal(result.installed.length, 5);
assert.equal(discoveries, 1);
broker.request(owner, { verb: 'project.system-types.set', args: { project, systemTypes: ['coding'] } });
enabled = [];
assert.deepEqual(broker.request(owner, { verb: 'project.system-types', args: { project } }).inactive, ['coding']);
assert.throws(() => broker.request(owner, { verb: 'project.system-types.set', args: { project, systemTypes: ['coding'] } }), /system-type-not-enabled/);
assert.deepEqual(result.project, { project, selected: [], active: [], inactive: [] });
assert.throws(() => broker.request(consoleOwner, { verb: 'project.system-types.set', args: { project, systemTypes: ['coding'] } }), /human-required/);
});
test('3t store migrates offline without altering existing project rows', async t => {
const { DatabaseSync } = await import('node:sqlite');
const { readFileSync, mkdirSync, chmodSync } = await import('node:fs');
const { schemaDigest } = await import('../src/store.mjs');
const root = mkdtempSync(join(tmpdir(), 'sys-migration-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const dbpath = join(root, 'bus/bus.sqlite');
const db = new DatabaseSync(dbpath);
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
const step = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
assert.equal(ddl.split(step).length, 2);
// 3u-3v (U4a) adds store_tasks after this step; a 3t store has neither.
const later = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8');
db.exec(ddl.replace(step, '').replace(later, ''));
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3t');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', '2026-01-01T00:00:00.000Z');
db.prepare('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)').run('old', '2026-01-01T00:00:00.000Z', 'demo', 'old', null, 'jason');
db.close(); chmodSync(dbpath, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const migrated = new Store(root, { migrateSchema: true }); t.after(() => migrated.close());
assert.equal(migrated.get('SELECT name FROM projects WHERE id=?', 'old').name, 'old');
assert.equal(migrated.get('SELECT count(*) AS n FROM project_system_types').n, 0);
assert.equal(migrated.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
});
test('runtime reads the current organization file for project selections and console discovery', async t => {
const { mkdirSync } = await import('node:fs');
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'sys-runtime-'));
const rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
doc.plugins = { 'system-type': ['coding'] };
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } };
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: business }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const consoleOwner = bus.broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
const call = (verb, args = {}, cap = human) => bus.broker.request(cap, { verb, args });
const project = call('project.create', { name: 'example' }).id;
call('project.system-types.set', { project, systemTypes: ['coding'] });
const view = await call('system-types.console', { project }, consoleOwner);
assert.deepEqual(view.enabled, ['coding']);
assert.deepEqual(view.project.active, ['coding']);
assert.equal(view.installed.length, 5);
const plugins = call('plugins');
call('plugins.set', { revision: plugins.revision, plugins: { 'system-type': [] } });
assert.deepEqual(call('project.system-types', { project }).inactive, ['coding']);
});
+548
View File
@@ -0,0 +1,548 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker, STORE_FIELDS, STORE_POSITION_STEP, STORE_VERB_FIELDS, storeNewTask } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { Client } from '../src/client.mjs';
import { storeAdapter } from '../../tasks/src/store.mjs';
import { loadFields } from '../../tasks/src/fields.mjs';
// U4a (#1873, REQ-TASK-1, REQ-PLUG-1, REQ-WS-8): the task store built into each organization, the
// default task-tracker module. Roles write through the eight task verbs (packages/tasks/src/store.mjs
// over broker.storeTask), the person through tasks.create/edit/archive/unarchive/delete, and every
// read is the bus's `tasks` verb, the same shape whichever module holds the tasks.
const AUTHORITY = {
pm: {
withinRole: ['task.create', 'task.assign', 'task.reassign', 'task.schedule', 'task.update.assigned', 'task.close', 'role.launch', 'message.send'],
crossRole: ['task.priority.change', 'task.scope.change'],
},
coder: { withinRole: ['task.update.assigned', 'git.push.working', 'review.request', 'message.send'], crossRole: ['task.scope.change'] },
reviewer: { withinRole: ['review.verdict', 'message.send', 'task.update.assigned'], crossRole: [] },
cto: { withinRole: ['review.request', 'task.update.assigned', 'message.send', 'decision.resolve.technical'], crossRole: ['task.scope.change'] },
};
const roles = () => Object.fromEntries(Object.entries(AUTHORITY).map(([r, authority]) => [r, { definition: r, authority: structuredClone(authority) }]));
const business = (id, extra = {}) => ({ id, human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: roles(), ...extra });
const DIGEST = /^[0-9a-f]{64}$/;
// One bus (one data root) holding the given organizations, with the store module over it.
async function org(t, ids = ['acme'], { taskTracker = null } = {}) {
const root = mkdtempSync(join(tmpdir(), 'ts-'));
const businesses = Object.fromEntries(ids.map((id) => [id, business(id)]));
const store = new Store(root);
const broker = new Broker({ store, businesses, ...(taskTracker ? { taskTracker } : {}) });
const adapter = await storeAdapter()({ broker, businesses });
let removed = false;
t.after(() => {
adapter.close();
if (!removed) {
store.close();
rmSync(root, { recursive: true, force: true });
}
});
const caps = new Map(), humans = new Map();
const agent = (role, id = ids[0]) => {
const key = id + '/' + role;
if (!caps.has(key)) {
const cap = broker.bindLaunch({ business: id, role, run: role + '-run', harness: 'pi' });
broker.request(cap, { verb: 'role.claim' });
caps.set(key, cap);
}
return caps.get(key);
};
const human = (id = ids[0]) => {
if (!humans.has(id)) humans.set(id, broker.bindHuman({ business: id, human: 'jason', via: 'cli', outsideAgent: true }));
return humans.get(id);
};
const instruction = (id = ids[0]) => broker.request(human(id), { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
const call = (role, verb, args, id = ids[0]) => broker.requestTask(agent(role, id), { verb, args }, adapter.handle);
const person = (verb, args, id = ids[0]) => broker.request(human(id), { verb, args });
const read = (args = {}, id = ids[0]) => broker.request(broker.bindReader({ business: id }), { verb: 'tasks', args });
const create = (title, extra = {}, id = ids[0]) => call('pm', 'task.create', { title, request: instruction(id), requirement: 'REQ-TASK-1', ...extra }, id);
// A cross-role verb's approval: the role raises it in the technical domain and the cto resolves it.
const approve = (role, action, target, id = ids[0]) => {
const d = broker.request(agent(role, id), {
verb: 'decision.raise',
args: { action, domain: 'technical', target, question: 'Allow?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false },
});
broker.request(agent('cto', id), { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
return d.id;
};
const remove = () => {
removed = true;
store.close();
rmSync(root, { recursive: true, force: true });
};
return { root, store, broker, agent, human, instruction, call, person, read, create, approve, remove };
}
const codeOf = async (p) => {
try {
await p;
} catch (e) {
return e.code;
}
return null;
};
const syncCode = (fn) => {
try {
fn();
} catch (e) {
return e.code;
}
return null;
};
test('REQ-TASK-1 the store keeps the slice 1 field table: one field list per role verb, all of them store fields', () => {
assert.deepEqual(Object.keys(STORE_VERB_FIELDS).sort(), Object.keys(loadFields().verbs).sort());
for (const [verb, list] of Object.entries(STORE_VERB_FIELDS))
for (const k of list) assert.ok(STORE_FIELDS.includes(k), `${verb} names ${k}`);
});
test('REQ-TASK-1 every role verb writes the store through the module', async (t) => {
const f = await org(t);
const request = f.instruction();
const made = await f.call('pm', 'task.create', { title: 'Write the store', description: 'd', priority: 2, labels: [3, 1], group: 'build', request, requirement: 'REQ-TASK-1' });
assert.equal(made.task_ref, 'task:1');
assert.match(made.digest, DIGEST);
let row = f.read({ task_ref: 'task:1' });
assert.equal(row.source, 'store');
assert.equal(row.tracker, 'store');
assert.equal(row.role, 'pm');
assert.equal(row.verb, 'task.create');
assert.equal(row.revision, 1);
assert.equal(row.digest, made.digest);
assert.equal(row.archived, false);
assert.deepEqual(row.fields, { ...storeNewTask({ title: 'Write the store', request, requirement: 'REQ-TASK-1' }), description: 'd', priority: 2, labels: [1, 3], group: 'build', position: 0 });
// The row's event is the action.allowed the write recorded, on the task's subject.
const event = f.store.get('SELECT kind, subject, body FROM events WHERE id=?', row.event);
assert.equal(event.kind, 'action.allowed');
assert.equal(event.subject, 'task:1');
assert.equal(JSON.parse(event.body).action, 'task.create');
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'reviewer' })), 'task-assigned');
await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'reviewer' });
assert.deepEqual(f.read({ task_ref: 'task:1' }).fields.assignees, ['reviewer']);
await f.call('pm', 'task.reassign', { task_ref: 'task:1', role: 'coder' });
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'nobody' })), 'unknown-role');
await f.call('pm', 'task.schedule', { task_ref: 'task:1', due_date: '2026-12-01T00:00:00.000Z', labels: { add: [5], remove: [1] } });
row = f.read({ task_ref: 'task:1' });
assert.equal(row.fields.due_date, '2026-12-01T00:00:00.000Z');
assert.deepEqual(row.fields.labels, [3, 5]);
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1' })), 'invalid-request');
await f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.5, state: 'in-progress', comment: 'started' });
row = f.read({ task_ref: 'task:1' });
assert.equal(row.role, 'coder');
assert.equal(row.fields.state, 'in-progress');
assert.equal(row.fields.bucket, 'in-progress');
assert.equal(row.fields.percent_done, 0.5);
assert.equal(row.fields.comments, 1);
assert.deepEqual(row.fields.comment, { text: 'started', by: 'coder' });
// done is task.close's, never the assignee's.
assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', state: 'done' })), 'invalid-state');
// Cross-role verbs need a resolved decision, as they do on Vikunja, and spend it once.
assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4 })), 'decision-required');
const d = f.approve('pm', 'task.priority.change', 'task:1');
await f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 4, decision: d });
assert.equal(f.read({ task_ref: 'task:1' }).priority, 4);
assert.equal(await codeOf(f.call('pm', 'task.priority.change', { task_ref: 'task:1', priority: 5, decision: d })), 'decision-consumed');
const scope = f.approve('coder', 'task.scope.change', 'task:1');
await f.call('coder', 'task.scope.change', { task_ref: 'task:1', title: 'Write the store well', decision: scope });
assert.equal(f.read({ task_ref: 'task:1' }).fields.title, 'Write the store well');
const closed = await f.call('pm', 'task.close', { task_ref: 'task:1', verdict: 'review-1' });
row = f.read({ task_ref: 'task:1' });
assert.equal(row.digest, closed.digest);
assert.equal(row.fields.done, true);
assert.equal(row.fields.state, 'done');
assert.equal(row.fields.verdict, 'review-1');
// A done task takes no role write; reopening it is the person's.
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', labels: { add: [9] } })), 'task-done');
assert.equal(await codeOf(f.call('coder', 'task.update.assigned', { task_ref: 'task:1', percent_done: 1 })), 'task-done');
// Nine writes; no refusal wrote a row.
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 9);
assert.deepEqual(f.store.all('SELECT revision FROM store_tasks ORDER BY seq').map((r) => r.revision), [1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
test('REQ-TASK-1 field writers: a role cannot write a field it does not own', async (t) => {
const f = await org(t);
await f.create('Owned fields');
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
// The module holds the pm verbs to the pm definition.
assert.equal(await codeOf(f.call('coder', 'task.create', { title: 'x', request: f.instruction(), requirement: 'REQ-TASK-1' })), 'field-writer');
assert.equal(await codeOf(f.call('coder', 'task.assign', { task_ref: 'task:1', role: 'coder' })), 'field-writer');
assert.equal(await codeOf(f.call('coder', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'field-writer');
assert.equal(await codeOf(f.call('reviewer', 'task.close', { task_ref: 'task:1', verdict: 'v' })), 'field-writer');
// task.update.assigned needs the role among the assignees.
assert.equal(await codeOf(f.call('reviewer', 'task.update.assigned', { task_ref: 'task:1', percent_done: 0.1 })), 'not-assigned');
// The broker holds every write to its verb's fields, whatever the module asks for.
const pm = f.agent('pm'), coder = f.agent('coder');
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.schedule', task_ref: 'task:1', change: (x) => ({ ...x, title: 'sneaky' }) })), 'field-writer');
assert.equal(syncCode(() => f.broker.storeTask(coder, { verb: 'task.update.assigned', task_ref: 'task:1', change: (x) => ({ ...x, priority: 5 }) })), 'field-writer');
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.close', task_ref: 'task:1', verdict: 'v', change: (x) => ({ ...x, done: true, state: 'done', bucket: 'done', assignees: [] }) })), 'field-writer');
assert.equal(syncCode(() => f.broker.storeTask(pm, { verb: 'task.assign', task_ref: 'task:1', change: (x) => ({ ...x, extra: 1 }) })), 'field-writer');
// A person's session is not a role here.
assert.equal(syncCode(() => f.broker.storeTask(f.human(), { verb: 'task.schedule', task_ref: 'task:1', change: (x) => x })), 'agent-required');
// Vikunja-only arguments refuse; nothing was written by any refusal.
for (const extra of [{ project: 1 }, { relations: [] }, { project_key: 'k' }, { requirement_snapshot: 's' }])
assert.equal(await codeOf(f.create('vikunja args', extra)), 'invalid-request', JSON.stringify(extra));
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 2);
// A refusal on a store task is filed on its trail.
const trail = f.person('trail', { subject: 'task:1' });
assert.ok(trail.some((r) => r.table === 'events' && r.kind === 'action.refused' && r.body.code === 'not-assigned'));
});
test('REQ-TASK-1 a person creates, edits, archives, unarchives and deletes; each write is a human.input event', async (t) => {
const f = await org(t);
const made = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Plan', group: 'todo', labels: [2] });
assert.equal(made.task_ref, 'task:1');
assert.equal(made.human, 'jason');
assert.equal(made.role, null);
assert.equal(made.fields.position, 0);
assert.equal(made.fields.request, null);
const event = f.store.get('SELECT kind, subject, actor_role, body FROM events WHERE id=?', made.event);
assert.equal(event.kind, 'human.input');
assert.equal(event.subject, 'task:1');
assert.equal(event.actor_role, null);
assert.deepEqual(JSON.parse(event.body), { kind: 'task', task_ref: 'task:1', tracker: 'store', op: 'create', verb: 'tasks.create' });
assert.equal(syncCode(() => f.person('tasks.create', {})), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x', request: 'r' })), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1' })), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', state: 'shipped' })), 'invalid-state');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', assignees: ['nobody'] })), 'unknown-role');
// expect is the digest the person last read; a later write refuses.
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', expect: 'a'.repeat(64), title: 'x' })), 'task-conflict');
const done = f.person('tasks.edit', { task_ref: 'task:1', expect: made.digest, state: 'done', assignees: ['coder'] });
assert.equal(done.fields.done, true);
assert.equal(done.fields.bucket, 'done');
// The person may reopen a done task.
assert.equal(f.person('tasks.edit', { task_ref: 'task:1', state: 'todo' }).fields.done, false);
// Agents and the reader don't reach the person's verbs.
assert.equal(syncCode(() => f.broker.request(f.agent('pm'), { verb: 'tasks.create', args: { title: 'x' } })), 'human-required');
assert.notEqual(syncCode(() => f.broker.request(f.broker.bindReader({ business: 'acme' }), { verb: 'tasks.create', args: { title: 'x' } })), null);
const other = f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Kept' });
f.person('tasks.archive', { task_ref: 'task:1' });
assert.equal(syncCode(() => f.person('tasks.archive', { task_ref: 'task:1' })), 'task-already-archived');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'x' })), 'task-archived');
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', group: 'x' })), 'task-archived');
// An archived task leaves the board read and the filtered read, unless archived: true asks.
assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]);
assert.deepEqual(f.read({ limit: 10 }).tasks.map((r) => r.task_ref), [other.task_ref]);
const archived = f.read({ archived: true });
assert.deepEqual(archived.tasks.map((r) => [r.task_ref, r.archived]), [['task:1', true]]);
assert.equal(archived.total, 1);
assert.equal(f.read({ task_ref: 'task:1' }).archived, true);
f.person('tasks.unarchive', { task_ref: 'task:1' });
assert.equal(syncCode(() => f.person('tasks.unarchive', { task_ref: 'task:1' })), 'task-not-archived');
assert.equal(f.read({ task_ref: 'task:1' }).archived, false);
const gone = f.person('tasks.delete', { task_ref: 'task:1' });
assert.equal(gone.task_ref, 'task:1');
assert.equal(gone.deleted, true);
assert.equal(syncCode(() => f.read({ task_ref: 'task:1' })), 'task-not-found');
assert.deepEqual(f.read().map((r) => r.task_ref), [other.task_ref]);
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', title: 'back' })), 'task-not-found');
assert.equal(syncCode(() => f.person('tasks.delete', { task_ref: 'task:1' })), 'task-not-found');
// The history stays in the organization's bus until the organization is removed.
const trail = f.person('trail', { subject: 'task:1' }).filter((r) => r.table === 'store_tasks');
assert.deepEqual(trail.map((r) => r.op), ['create', 'change', 'change', 'archive', 'unarchive', 'delete']);
assert.equal(trail[0].fields.title, 'Plan');
// Numbers are never reused.
assert.equal(f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'Next' }).task_ref, 'task:3');
});
test('REQ-TASK-1 a role create cites the instruction; the trail carries it, and an op answers the same task again', async (t) => {
const f = await org(t);
const request = f.instruction();
const args = { title: 'Once', request, requirement: 'REQ-TASK-1', op: 'op-1' };
const first = await f.call('pm', 'task.create', args);
const again = await f.call('pm', 'task.create', args);
assert.deepEqual(again, first);
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 1);
assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, title: 'Other' })), 'task-create-conflict');
assert.equal(await codeOf(f.call('pm', 'task.create', { ...args, op: undefined, request: 'not-an-instruction' })), 'request-not-found');
assert.equal(await codeOf(f.call('pm', 'task.create', { title: 'x', request, requirement: 'nope' })), 'invalid-request');
const trail = f.person('trail', { subject: 'task:1' });
assert.ok(trail.some((r) => r.table === 'events' && r.id === request && r.kind === 'human.input'));
assert.ok(trail.some((r) => r.table === 'messages'));
// A stale expect refuses a role write too.
assert.equal(await codeOf(f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: 'a'.repeat(64) })), 'task-conflict');
await f.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder', expect: first.digest });
});
test('REQ-TASK-1 ordering and grouping: positions step by 1024 inside a group, moves go to the end, reads in board order', async (t) => {
const f = await org(t);
for (const title of ['A', 'B', 'C']) await f.create(title);
await f.create('D', { group: 'review' });
await f.create('E', { group: 'build' });
const at = () => f.read().map((r) => [r.fields.title, r.fields.group, r.fields.position]);
assert.deepEqual(at(), [['A', null, 0], ['B', null, STORE_POSITION_STEP], ['C', null, 2 * STORE_POSITION_STEP], ['E', 'build', 0], ['D', 'review', 0]]);
// A move to another group with no position goes to its end; a position alone reorders in place.
await f.call('pm', 'task.schedule', { task_ref: 'task:2', group: 'review' });
await f.call('pm', 'task.schedule', { task_ref: 'task:3', position: 10 });
f.person('tasks.edit', { task_ref: 'task:1', position: 5000 });
assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', 'build', 0], ['D', 'review', 0], ['B', 'review', STORE_POSITION_STEP]]);
// A person's move to a group with no position also goes to its end (after E, still in build then); ungrouping too.
f.person('tasks.edit', { task_ref: 'task:4', group: 'build' });
f.person('tasks.edit', { task_ref: 'task:5', group: null });
assert.deepEqual(at(), [['C', null, 10], ['A', null, 5000], ['E', null, 6024], ['D', 'build', STORE_POSITION_STEP], ['B', 'review', STORE_POSITION_STEP]]);
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', group: ' ' })), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', { task_ref: 'task:1', position: -1 })), 'invalid-request');
assert.equal(await codeOf(f.call('pm', 'task.schedule', { task_ref: 'task:1', position: 1.5 })), 'invalid-request');
// The filtered short form pages in board order with the store's group, position and archived.
const one = f.read({ limit: 2 });
assert.deepEqual(one.tasks.map((r) => r.task_ref), ['task:3', 'task:1']);
assert.equal(one.total, 5);
assert.equal(one.next, 'task:1');
assert.deepEqual(Object.keys(one.tasks[0]).sort(), ['archived', 'assignees', 'bucket', 'changed_at', 'done', 'group', 'labels', 'position', 'priority', 'requirement', 'task_ref', 'title']);
const two = f.read({ limit: 2, after: one.next });
assert.deepEqual(two.tasks.map((r) => r.task_ref), ['task:5', 'task:4']);
const three = f.read({ limit: 2, after: two.next });
assert.deepEqual(three.tasks.map((r) => r.task_ref), ['task:2']);
assert.equal(three.next, null);
assert.equal(syncCode(() => f.read({ after: 'task:99' })), 'task-not-found');
// The other filters apply to store tasks as to Vikunja's.
await f.call('pm', 'task.assign', { task_ref: 'task:4', role: 'coder' });
assert.deepEqual(f.read({ assignee: 'coder' }).tasks.map((r) => r.task_ref), ['task:4']);
assert.deepEqual(f.read({ requirement: 'REQ-TASK-1', done: false }).total, 5);
assert.deepEqual(f.read({ project_key: 'k'.repeat(8) }).tasks, []);
});
test('REQ-TASK-1 two organizations keep their own tasks, and removing one removes only its tasks', async (t) => {
// Jason 11-07: each organization has its own bus and data root.
const a = await org(t, ['acme']), b = await org(t, ['beta']);
await a.create('acme task');
await b.create('beta task');
b.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'beta by hand' });
assert.deepEqual(a.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'acme task']]);
assert.deepEqual(b.read().map((r) => [r.task_ref, r.fields.title]), [['task:1', 'beta task'], ['task:2', 'beta by hand']]);
// Removing an organization removes its data root, and its tasks with it.
a.remove();
assert.equal(existsSync(a.root), false);
assert.deepEqual(b.read().map((r) => r.fields.title), ['beta task', 'beta by hand']);
await b.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' });
// Within one bus, too, a business reads and writes only its own rows.
const both = await org(t, ['one', 'two']);
await both.create('one task', {}, 'one');
assert.deepEqual(both.read({}, 'two'), []);
assert.equal(syncCode(() => both.read({ task_ref: 'task:1' }, 'two')), 'task-not-found');
assert.equal(await codeOf(both.call('pm', 'task.assign', { task_ref: 'task:1', role: 'coder' }, 'two')), 'task-not-found');
assert.equal(syncCode(() => both.person('tasks.archive', { task_ref: 'task:1' }, 'two')), 'task-not-found');
assert.equal((await both.create('two task', {}, 'two')).task_ref, 'task:1');
assert.deepEqual(both.read({}, 'one').map((r) => r.fields.title), ['one task']);
});
test('REQ-PLUG-1 a business on another tracker module refuses the store verbs', async (t) => {
const f = await org(t, ['acme'], { taskTracker: () => 'vikunja' });
assert.equal(syncCode(() => f.person('tasks.create', { requirement: 'REQ-TASK-1', title: 'x' })), 'tracker-not-store');
assert.equal(await codeOf(f.create('x')), 'tracker-not-store');
assert.deepEqual(f.read(), []);
assert.throws(() => new Broker({ store: f.store, businesses: { acme: business('acme') }, taskTracker: 'store' }), TypeError);
});
test('REQ-TASK-1 store_tasks guards: append-only, revisions in order, one create, nothing after a delete, archive changes state', async (t) => {
const f = await org(t);
await f.create('Guarded');
const event = f.store.get('SELECT event FROM store_tasks').event;
const fields = f.store.get('SELECT fields FROM store_tasks').fields;
const insert = (task, revision, op, by = ['pm', 'pm-run', null]) =>
f.store.run('INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
'2026-11-09T00:00:00.000Z', 'acme', task, revision, op, 'x', ...by, event, fields);
for (const [sql, ...args] of [["UPDATE store_tasks SET verb='y'"], ['DELETE FROM store_tasks'],
["INSERT OR REPLACE INTO store_tasks(seq,at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(1,'t','acme',1,2,'change','x','pm','pm-run',NULL,?,?)", event, fields]])
assert.throws(() => f.store.run(sql, ...args), /store_tasks is append-only/, sql);
assert.throws(() => insert(1, 3, 'change'), /a store task revision follows the last one/);
assert.throws(() => insert(1, 2, 'create'), /a store task is created once/);
assert.throws(() => insert(3, 1, 'create'), /with the next number/);
assert.throws(() => insert(1, 2, 'unarchive'), /a store task archive row changes state/);
assert.throws(() => insert(1, 2, 'change', ['pm', null, null]), /CHECK/);
assert.throws(() => insert(1, 2, 'change', ['pm', 'pm-run', 'jason']), /CHECK/);
assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,?,'[]')", event), /CHECK/);
assert.throws(() => f.store.run("INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES('t','acme',1,2,'change','x','pm','pm-run',NULL,'no-such-event',?)", fields), /FOREIGN KEY/);
insert(1, 2, 'archive', [null, null, 'jason']);
assert.throws(() => insert(1, 3, 'archive', [null, null, 'jason']), /a store task archive row changes state/);
insert(1, 3, 'delete', [null, null, 'jason']);
assert.throws(() => insert(1, 4, 'change'), /a deleted store task takes no more rows/);
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, 3);
});
// --- The 3u→3v migration: store_tasks is new and empty, and everything else stays. ---
const here = (p) => new URL(p, import.meta.url);
const stepSQL = readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8');
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
const master = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r }));
} finally {
db.close();
}
};
const version = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value;
} finally {
db.close();
}
};
test('REQ-TASK-1 a 3u store migrates to 3v: store_tasks is new and empty, every row stays, and the schema is the fresh one', (t) => {
assert.equal(currentDDL.split(stepSQL).length, 2, 'schema.sql holds the 3u→3v step exactly once');
assert.ok(currentDDL.endsWith(stepSQL), 'the 3u→3v step is the last in schema.sql');
const root = mkdtempSync(join(tmpdir(), 'ts-3u-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
db.exec(currentDDL.replace(stepSQL, ''));
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3u');
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES (?,?)').run('comment_cursor_epoch', '2026-11-09T00:00:00.000Z');
db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('preserved','2026-11-09T00:00:00.000Z','acme','action.allowed','{}')").run();
db.close();
chmodSync(path, 0o600);
const was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
assert.deepEqual(master(path), was, 'the refused open changed nothing');
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(version(path), '3v');
assert.equal(store.get('SELECT id FROM events').id, 'preserved');
assert.deepEqual(store.all('SELECT * FROM store_tasks'), []);
const freshRoot = mkdtempSync(join(tmpdir(), 'ts-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
new Store(freshRoot).close();
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
});
// --- The bus runtime: the store is the default module; Vikunja stays the host's. ---
test('REQ-PLUG-1 the runtime gives a business with no tracker the store and routes a Vikunja business to the host adapter', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'ts-rt-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const businesses = {
acme: business('acme', { plugins: { harness: ['pi'] } }),
beta: business('beta', { plugins: { harness: ['pi'], 'task-tracker': 'vikunja' } }),
};
const seen = [];
let closed = 0;
const launch = (id) => ({ business: id, role: 'pm', run: id + '-pm', harness: 'pi', pid: process.pid, startTime: '1' });
const runtime = await startBroker({
dataRoot: root,
businesses,
launches: [launch('acme'), launch('beta')],
trackers: { beta: { baseUrl: 'http://127.0.0.1:9', project: 1 } },
tasks: async () => ({ handle: async (cap, verb) => (seen.push(verb), { routed: 'vikunja' }), timeout: 1000, close: async () => closed++ }),
});
try {
const [acme, beta] = runtime.launches.map((l) => new Client({ path: runtime.path, cap: l.cap }));
await acme.call('role.claim');
await beta.call('role.claim');
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const request = runtime.broker.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } }).request;
const made = await acme.call('task.create', { title: 'From the socket', request, requirement: 'REQ-TASK-1' });
assert.equal(made.task_ref, 'task:1');
assert.deepEqual(await beta.call('task.create', { title: 'x', request: 'r', requirement: 'REQ-TASK-1' }), { routed: 'vikunja' });
assert.deepEqual(seen, ['task.create']);
// The Console's read: the `tasks` verb on the organization's reader cap, the same rows either way.
const rows = runtime.broker.request(runtime.broker.bindReader({ business: 'acme' }), { verb: 'tasks' });
assert.deepEqual(rows.map((r) => [r.task_ref, r.source, r.fields.title]), [['task:1', 'store', 'From the socket']]);
assert.equal(syncCode(() => runtime.broker.request(runtime.broker.bindHuman({ business: 'beta', human: 'jason', via: 'cli', outsideAgent: true }), { verb: 'tasks.create', args: { title: 'x' } })), 'tracker-not-store');
} finally {
await runtime.close();
}
assert.equal(closed, 1);
// A business that names the store while the boot config also gives it a Vikunja tracker refuses the boot.
await assert.rejects(
startBroker({ dataRoot: root, businesses: { acme: business('acme', { plugins: { 'task-tracker': 'store' } }) }, trackers: { acme: { baseUrl: 'http://127.0.0.1:9', project: 1 } } }),
(e) => e.code === 'tracker-config',
);
assert.equal(existsSync(join(root, 'bus/writer.lock')), false);
});
test('review: an unknown task-tracker module must refuse boot', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'unknown-'));
t.after(() => rmSync(root, {recursive:true, force:true}));
let runtime;
try {
await assert.rejects(async () => {
runtime = await startBroker({dataRoot:root, businesses:{acme:business('acme', {plugins:{harness:['pi'], 'task-tracker':'no-such-tracker'}})}});
}, (e) => e.code === 'tracker-config');
} finally { await runtime?.close(); }
// Startup cleanup releases the same store so a corrected configuration can start.
const corrected = await startBroker({dataRoot:root, businesses:{acme:business('acme')}});
await corrected.close();
});
test('review: a stale expect must reject a delete after an intervening archive', async (t) => {
const f=await org(t);
const old=f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Read before archiving'});
const archived=f.person('tasks.archive', {task_ref:old.task_ref});
assert.equal(archived.revision, old.revision+1);
assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:old.digest})), 'task-conflict');
const unarchived = f.person('tasks.unarchive', {task_ref:old.task_ref});
assert.equal(syncCode(()=>f.person('tasks.edit', {task_ref:old.task_ref, title:'stale', expect:old.digest})), 'task-conflict');
assert.equal(syncCode(()=>f.person('tasks.delete', {task_ref:old.task_ref, expect:archived.digest})), 'task-conflict');
assert.equal(f.person('tasks.edit', {task_ref:old.task_ref, title:'current', expect:unarchived.digest}).fields.title, 'current');
});
test('review: human tasks can cite the PRD-required requirement id', async (t) => {
const f=await org(t);
const made=f.person('tasks.create', { title:'Traceable',requirement:'REQ-TASK-1'});
assert.equal(made.fields.requirement,'REQ-TASK-1');
assert.equal(syncCode(() => f.person('tasks.create', {title:'Missing'})), 'invalid-request');
for (const requirement of [null, '', 'REQ-foo-1', 'REQ-TASK-0']) {
assert.equal(syncCode(() => f.person('tasks.create', {title:'Invalid', requirement})), 'invalid-request');
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:made.task_ref, requirement})), 'invalid-request');
}
const edited = f.person('tasks.edit', {task_ref:made.task_ref, requirement:'REQ-TASK-2'});
assert.equal(edited.fields.requirement, 'REQ-TASK-2');
assert.equal(f.store.get('SELECT kind FROM events WHERE id=?', edited.event).kind, 'human.input');
});
test('review: a newly hired assignee can use the store after the business reloads', async (t) => {
const f=await org(t);
const made=await f.create('For a new coder');
const next=business('acme');
next.roles.newcoder=structuredClone(next.roles.coder);
f.broker.reloadBusiness('acme',next);
f.person('tasks.edit',{task_ref:made.task_ref,assignees:['newcoder']});
await f.call('newcoder','task.update.assigned',{task_ref:made.task_ref,state:'in-progress'});
assert.equal(f.read({task_ref:made.task_ref}).fields.state,'in-progress');
});
test('review: deleting an assignee does not prevent archiving the existing task', async (t) => {
const f=await org(t);
const made=f.person('tasks.create',{requirement:'REQ-TASK-1',title:'Assigned history',assignees:['coder']});
const next=business('acme'); delete next.roles.coder; next.retired=['coder'];
f.broker.reloadBusiness('acme',next);
const edited=f.person('tasks.edit',{task_ref:made.task_ref,title:'Edited',assignees:['coder']});
assert.deepEqual(edited.fields.assignees,['coder']);
assert.equal(syncCode(() => f.person('tasks.edit',{task_ref:made.task_ref,assignees:['coder','missing']})), 'unknown-role');
const archived=f.person('tasks.archive',{task_ref:made.task_ref});
assert.equal(archived.archived,true);
assert.deepEqual(archived.fields.assignees,['coder']);
f.person('tasks.delete',{task_ref:made.task_ref});
const last=JSON.parse(f.store.get('SELECT fields FROM store_tasks ORDER BY seq DESC LIMIT 1').fields);
assert.deepEqual(last.assignees,['coder']);
});
test('review: saturated and adjacent positions refuse without a write', async (t) => {
const f = await org(t);
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Moved', group:'other'});
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'Tail', group:'target', position:1_000_000_000});
const before = f.store.get('SELECT count(*) n FROM store_tasks').n;
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'target'})), 'task-position-full');
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before);
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'A', group:'adjacent', position:10});
f.person('tasks.create', { requirement: 'REQ-TASK-1',title:'B', group:'adjacent', position:11});
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'adjacent', position:10})), 'task-position-full');
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before + 2);
});
+5 -2
View File
@@ -301,12 +301,15 @@ test('the process loads the S3 adapter from plain-data trackers', async (t) => {
const bad = await boot(t, { dataRoot: root, businesses, launches, trackers: { nope: { baseUrl: 'http://127.0.0.1:9', project: 1 } } });
assert.deepEqual(bad.reply, { ok: false, error: 'tracker-config' });
await once(bad.child, 'exit');
// An empty tracker map loads the adapter; a business without an entry refuses task verbs.
// An empty tracker map loads the adapter. U4a (#1873): a business without an entry gets the
// built-in store, not Vikunja. These fixture roles carry no definition, so the store refuses the
// caller (tracker-role) where the Vikunja adapter refused tracker-unconfigured; task-store.test.mjs
// runs the store's verbs.
const { child, reply } = await boot(t, { dataRoot: root, businesses, launches, trackers: {} });
assert.equal(reply.ok, true);
const pm = new Client({ path: reply.path, cap: reply.launches[0].cap });
await pm.call('role.claim');
await assert.rejects(pm.call('task.create', { title: 'x' }), /tracker-unconfigured/);
await assert.rejects(pm.call('task.create', { title: 'x' }), /tracker-role/);
const exit = once(child, 'exit');
child.send({ op: 'close' });
assert.equal((await exit)[0], 0);
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3t ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3v ready', ''].join('\n'));
});
+4 -4
View File
@@ -403,12 +403,12 @@ test('a 3l store migrates to 3m: thread_shares is new and empty, and every other
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
const after = tables(path);
assert.deepEqual(after.thread_shares, []);
delete after.thread_shares;
// Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too.
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives']) {
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) {
assert.deepEqual(after[name], [], name);
delete after[name];
}
@@ -439,7 +439,7 @@ async function engine3l(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3l';")
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3l';")
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -456,7 +456,7 @@ test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it wa
const Store3l = await engine3l(root);
assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3t');
assert.equal(version(path), '3v');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
+9
View File
@@ -1289,3 +1289,12 @@ first attempt. Completed moves are not rolled back. A held lifecycle lock can
mean a bus start is still in progress; if a process crashed and left its lock,
inspect it before removing it and resuming. Moves use rename and therefore
require the source and deleted folder to be on the same filesystem.
System types are installed plug-ins. `mosaic org system-types installed` prints
module metadata; `mosaic org system-types enabled ID` prints an organization's enabled
ids. The owner changes the enabled list through the existing organization file
update or `mosaic plugins set`. `mosaic project system-types PROJECT --org ID`
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.
+2 -1
View File
@@ -74,7 +74,8 @@ export function readAccounting({ dataRoot,business,start,end }) {
const db = new DatabaseSync(join(dataRoot,'bus/bus.sqlite'),{readOnly:true});
try {
db.exec('BEGIN');
if (!['3s','3t'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
// U4a (#1873) 3u-3v adds only store_tasks, which accounting never reads, so 3v joins the closed list.
if (!['3s','3t','3u','3v'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
const store = { all:(sql,...args) => db.prepare(sql).all(...args) };
const records = turnRecords(store,business);
const raw = store.all("SELECT * FROM usage_records WHERE business=? AND source<>'@proxy/cliproxy-queue' ORDER BY seq",business);
+1 -1
View File
@@ -79,7 +79,7 @@ test('accounting reads a real store at the current bus schema version',t=>{
const records=readAccounting({dataRoot:f.dataRoot,business:f.c.business,start:f.c.start,end:f.c.end});
assert.equal(records.length,1);assert.equal(records[0].input,1);
});
for(const version of ['3s','3r','3u',null]) test(`accounting ${version==='3s'?'accepts':'refuses'} schema ${version??'missing'}`,t=>{
for(const version of ['3s','3r','3v',null]) test(`accounting ${version==='3s'?'accepts':'refuses'} schema ${version??'missing'}`,t=>{
const f=setup(t,100);
// Only this disposable fixture is altered; the production meta table is append-only.
f.store.run('DROP TRIGGER meta_no_update');f.store.run('DROP TRIGGER meta_no_delete');
+7 -2
View File
@@ -648,7 +648,12 @@ async function bus(rest, io, deps) {
// A dev fixture serves only the organizations that use it; their launches carry no provider credential.
const orgDoc = instance ? JSON.parse(readFileSync(file, "utf8")) : undefined;
const launchEnv = instance ? fixtureLaunchEnv(orgDoc, io.env) : io.env;
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker: Boolean(boot.trackers?.[businessId]), launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
// U4a (#1873): agents get the task tools when the bus serves task verbs for the organization: a
// Vikunja tracker in the boot config, or the built-in store, which an organization with no
// tracker gets (the bus runtime makes the same choice, runtime.mjs trackerOf).
const taskModule = boot.businesses?.[businessId]?.plugins?.["task-tracker"];
const tracker = Boolean(boot.trackers?.[businessId]) || taskModule === undefined || taskModule === "store";
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
await launcher.listen();
if (instance) io.stdout.write(`DEV LOCAL OWNER MODE: console owner local-dev accepted (named instance ${instance})\n`);
if (instance && parsed.flags.has("--pm")) io.stdout.write('DEV LOCAL OWNER MODE: launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only\n');
@@ -727,7 +732,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
if (verb === "project") return ["create", "list", "archive", "unarchive"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
if (verb === "project") return ["create", "list", "archive", "unarchive", "system-types", "system-types-set"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
if (verb === "session") return sessionCommand(rest, io, (p) => channelCallFor(p, io, deps));
if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
+3
View File
@@ -83,6 +83,9 @@ export function trailSummary(r) {
return `${r.op} ${r.role} by ${r.holder_run}`;
case "task_snapshots":
return `snapshot ${r.task_ref ?? ""}`.trim();
// U4a (#1873): a built-in store task's write: what it did, under which verb, by which role or person.
case "store_tasks":
return [r.op, r.verb, `r${r.revision}`, `by ${r.by_role ?? r.by_human}`, r.fields?.title ? one(r.fields.title, 100) : null].filter(Boolean).join(" ");
default:
return "";
}
+5 -2
View File
@@ -2,13 +2,14 @@ import { readFile, writeFile } from 'node:fs/promises';
import { configDir } from '../../organization/src/organization-config.mjs';
import { listOrgs, orgLock } from '../../organization/src/org-registry.mjs';
import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs';
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
import { loadSystem } from './config.mjs';
import { CliError } from './errors.mjs';
import { busStopForDelete } from './org-bus.mjs';
import { consoleBusRestart } from './org-console.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ORG_USAGE = 'mosaic org lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e.message}`, 2); } }
export async function orgCommand(args, io, deps = {}) {
@@ -20,8 +21,10 @@ export async function orgCommand(args, io, deps = {}) {
if (words[0] === 'validate' && words.length === 2) { allowed([], []); result = await validateOrg(await document(words[1])); if (!result.ok) throw new CliError(result.errors.map(e => `${e.path}: ${e.message}`).join('\n'), 2); io.stdout.write('valid\n'); return; }
// U1 (#1873): `org reload` is a human action; an agent run is refused before any config is read.
if (words[0] === 'reload') refuseInsideAgent(io.env);
if (words[0] === 'system-types' && words[1] === 'installed' && words.length === 2) { allowed(['--json'], []); io.stdout.write(JSON.stringify(await installedSystemTypes(), null, 2) + '\n'); return; }
const instance = devOwnerInstance(io.env); const system = deps.system ?? loadSystem({ env: io.env, instance }); const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); }
if (words[0] === 'system-types' && words[1] === 'enabled' && words.length === 3) { allowed(['--json'], []); const org = await showOrg(ctx, words[2]); result = { organization: words[2], enabled: org.doc.plugins?.['system-type'] ?? [] }; }
else if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); }
else if (words[0] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); }
else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); }
else if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json'], ['--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); const opts = { ifRevision: Number(r) }; result = words[0] === 'archive' ? await archiveOrg(ctx, words[1], { ...opts, busStop: busStopForDelete }) : await restoreOrg(ctx, words[1], opts); }
+15 -2
View File
@@ -14,6 +14,8 @@ export const PROJECT_USAGE = 'mosaic project init <business> <project> [--tester
// and check above work on the repository on disk and never contact the bus, except that
// init --tester (row 175, #1673) binds the tester once before it writes.
export const PROJECT_BUS_USAGE = [
'mosaic project system-types <name-or-id> [--business <id>] [--json]',
'mosaic project system-types-set <name-or-id> --types <comma-separated-ids> [--business <id>]',
'mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]',
'mosaic project list [--archived] [--business <id>] [--json] [--tester]',
'mosaic project archive <name-or-id> [--business <id>]',
@@ -28,8 +30,8 @@ export async function projectBusCommand(args, io, callFor) {
? { flags: ['--tester'], values: ['--repo', '--business'] }
: sub === 'list'
? { flags: ['--json', '--tester', '--archived'], values: ['--business'] }
: { flags: [], values: ['--business'] });
const one = sub === 'create' || sub === 'archive' || sub === 'unarchive';
: { flags: sub === 'system-types' ? ['--json'] : [], values: sub === 'system-types-set' ? ['--business', '--types'] : ['--business'] });
const one = ['create', 'archive', 'unarchive', 'system-types', 'system-types-set'].includes(sub);
if (parsed.positional.length !== (one ? 1 : 0))
throw new CliError(`project ${sub} takes ${one ? (sub === 'create' ? 'one name' : 'one name or id') : 'no arguments'}\n${PROJECT_BUS_USAGE}`, 4);
const call = callFor(parsed);
@@ -45,6 +47,17 @@ export async function projectBusCommand(args, io, callFor) {
}
return io.stdout.write(`created project ${name} id ${made.id}\n`);
}
if (sub === 'system-types' || sub === 'system-types-set') {
const projects = await call('project.list', { includeArchived: true });
const ref = parsed.positional[0];
const hit = projects.find(p => p.id === ref) ?? projects.find(p => p.name === ref);
if (!hit) throw new CliError(`no project is named or numbered ${JSON.stringify(ref)}`, 2);
const write = sub === 'system-types-set';
if (write && parsed.values['--types'] === undefined) throw new CliError('--types is required (an empty string clears the list)', 4);
const systemTypes = parsed.values['--types'] ? parsed.values['--types'].split(',') : [];
const result = await call(write ? 'project.system-types.set' : 'project.system-types', { project: hit.id, ...(write ? { systemTypes } : {}) });
return io.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
// #797 MVP3: the owner's reversible archive. Resolution reads the full list, archived included,
// so an archived project can be named for unarchive.
if (sub === 'archive' || sub === 'unarchive') {
+15
View File
@@ -168,6 +168,21 @@ test("agents and tasks print through the broker", async (t) => {
assert.equal(tasks.io.out.text, "tasks: none\n");
});
test("U4a: tasks and trail print the organization's built-in store tasks", async (t) => {
const s = setup(t);
s.b.request(s.human, { verb: "tasks.create", args: { title: "Plan the store", requirement: "REQ-TASK-1" } });
s.b.request(s.human, { verb: "tasks.edit", args: { task_ref: "task:1", state: "in-progress" } });
const tasks = await s.run(["tasks", "--business", "demo"]);
assert.equal(tasks.code, 0, tasks.io.err.text);
assert.equal(tasks.io.out.text, "task:1 in-progress Plan the store\n");
const trail = await s.run(["trail", "task:1", "--business", "demo"]);
assert.equal(trail.code, 0, trail.io.err.text);
const lines = trail.io.out.text.trimEnd().split("\n");
assert.equal(lines[0], `trail task:1: ${s.read("trail", { subject: "task:1" }).length} row(s)`);
assert.ok(lines.some((l) => / {2}store_tasks#1 {2}create {2}tasks\.create {2}r1 {2}by jason {2}Plan the store$/.test(l)), trail.io.out.text);
assert.ok(lines.some((l) => / {2}store_tasks#2 {2}change {2}tasks\.edit {2}r2 {2}by jason {2}Plan the store$/.test(l)), trail.io.out.text);
});
test("notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes", (t) => {
const dataRoot = tmp(t);
assert.throws(() => readNotifyConfig(dataRoot, "acme"), (e) => e.exitCode === 3 && /no notifier config/.test(e.message));
+11
View File
@@ -37,3 +37,14 @@ test("the trail keeps the broker's order and names a decision's task without its
test("tasks print the tracker fields the snapshot carries", () => {
assert.equal(formatTasks([{ task_ref: "vikunja:1/7", fields: { title: "Build S4", done: false } }]), "vikunja:1/7 open Build S4\n");
});
test("U4a: store tasks print like tracker tasks, and their trail rows name the write", () => {
assert.equal(formatTasks([{ task_ref: "task:3", source: "store", fields: { title: "Plan", state: "in-progress", done: false } }]), "task:3 in-progress Plan\n");
const rows = [
{ at: "2026-11-09T10:00:00Z", table: "store_tasks", seq: 1, op: "create", verb: "task.create", revision: 1, by_role: "pm", by_human: null, fields: { title: "Plan" } },
{ at: "2026-11-09T11:00:00Z", table: "store_tasks", seq: 2, op: "archive", verb: "tasks.archive", revision: 2, by_role: null, by_human: "jason", fields: { title: "Plan" } },
];
const lines = formatTrail("task:3", rows).trimEnd().split("\n");
assert.equal(lines[1], "2026-11-09T10:00:00Z store_tasks#1 create task.create r1 by pm Plan");
assert.equal(lines[2], "2026-11-09T11:00:00Z store_tasks#2 archive tasks.archive r2 by jason Plan");
});
+1
View File
@@ -12,6 +12,7 @@ test('organization CLI renders, validates, creates, updates and initializes git'
const file = join(home, 'alpha.json'); const invoke = (...args) => { const r = run(env, ...args); assert.equal(r.status, 0, r.stderr); return r.stdout; };
invoke('org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file);
invoke('org', 'validate', file); invoke('org', 'create', 'alpha', '--from', file);
assert.deepEqual(JSON.parse(invoke('org', 'system-types', 'enabled', 'alpha')), { organization: 'alpha', enabled: [] });
const bootCheck = spawnSync(process.execPath, ['--input-type=module', '-e', `import {loadSystem, bootConfig} from './packages/cli/src/config.mjs'; const system=loadSystem({env:process.env, instance:'orgtest'}); bootConfig({system,businessId:'alpha',env:process.env});`], { cwd: repo, env, encoding: 'utf8' }); assert.equal(bootCheck.status, 0, bootCheck.stderr);
let info = JSON.parse(invoke('org', 'show', 'alpha', '--json')); assert.equal(info.id, 'alpha'); assert.equal(JSON.parse(invoke('org', 'list', '--json')).length, 1);
invoke('org', 'update', 'alpha', '--from', file, '--if-revision', String(info.revision));
+9 -6
View File
@@ -306,7 +306,7 @@ test('#811 migration readers follow the engine order and refuse malformed pairs'
assert.notEqual(engineSource,source);
const engine=await import(`data:text/javascript;base64,${Buffer.from(engineSource).toString('base64')}`);
const pairs=engine.migrations.map(({from,to})=>[from,to]);
assert.equal(pairs.length,18);assert.deepEqual(pairs.at(-1),['3s','3t']);
assert.equal(pairs.length,20);assert.deepEqual(pairs.at(-1),['3u','3v']);
const release=readFileSync(join(ROOT,'scripts/stack-release.sh'),'utf8');
const fn=(name)=>{const m=release.match(new RegExp(`^${name}\\(\\) \{[\\s\\S]*?^\}`, 'm'));assert.ok(m,name);return m[0]};
const functions=['copy_schema','target_schema','plan_steps'].map(fn).join('\n');
@@ -885,7 +885,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' ');
const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1];
// sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
// well as T46.
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','project_archives','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events'];
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','project_archives','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_system_types'];
const LATE='2999-01-01T00:00:00.000Z';
const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()};
@@ -903,7 +903,7 @@ test('T45 row 181 items 2 and 3: the restore guard counts events and every COUNT
both(store('3f.sqlite',['events'],['events','instance_names']),'1\t1 event(s)\n');
both(store('none.sqlite',[],['instance_names']),'0\t0 event(s)\n');
});
for(const x of ['instance_names','usage_records'])await t.test(`one ${x} row and no event refuses before any stop; --events 1 restores`,async(t)=>{
for(const x of ['instance_names','usage_records','project_system_types'])await t.test(`one ${x} row and no event refuses before any stop; --events 1 restores`,async(t)=>{
const e=await mk(t);await seed(e);
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
addRow(e.store,x);
@@ -925,10 +925,10 @@ test('T45 row 181 items 2 and 3: the restore guard counts events and every COUNT
assert.equal(r.code,3,r.out+r.err);assert.ok(r.err.endsWith(`stack-release: refusing restore-mismatch: cannot count the rows after ${stamp}\n`),r.err);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).at(-1).code,'restore-mismatch');
});
await t.test('the in-run recount counts the listed tables too',async(t)=>{
for(const x of ['thread_opens','project_system_types'])await t.test(`the in-run recount counts the listed tables too (${x})`,async(t)=>{
const e=await mk(t);await seed(e);
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
fault(e,'stop-writes-row','thread_opens');
fault(e,'stop-writes-row',x);
const r=await run(e,'rollback','acme','--restore',act);assert.equal(r.code,1,r.out+r.err);
assert.equal(r.err,rolledBack('post-backup-events',C));
assert.ok(r.out.includes('; 0 event(s) were written after the backup\n'),r.out);
@@ -943,7 +943,10 @@ test('T46 row 181 item 4: every table in the bus schema is in exactly one of fou
const db=new DatabaseSync(':memory:');db.exec(readFileSync(join(ROOT,'packages','bus','schema.sql'),'utf8'));
const tables=db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all().map((r)=>r.name);
const hasAt=(n)=>db.prepare(`SELECT count(*) AS n FROM pragma_table_info(?) WHERE name='at'`).get(n).n===1;
const always={decisions:'decision.raise writes action.allowed in the same transaction'};
const always={
decisions:'decision.raise writes action.allowed in the same transaction',
store_tasks:'U4a #1873: each row goes in one transaction with the action.allowed or human.input events row it cites',
};
const untimed={
gitea_grants:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
gitea_users:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
+53
View File
@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { orgCommand } from '../src/org.mjs';
import { projectBusCommand } from '../src/project.mjs';
test('org installed system types and project selection reads print JSON', async () => {
let output = '';
const io = { stdout: { write: s => { output += s; } }, env: {} };
await orgCommand(['system-types', 'installed'], io);
assert.deepEqual(JSON.parse(output).map(x => x.id), ['business-management', 'coding', 'creative-writing', 'marketing', 'montage']);
output = '';
const calls = [];
await projectBusCommand(['system-types', 'work', '--business', 'demo', '--json'], io, () => async (verb, args) => {
calls.push([verb, args]);
return verb === 'project.list' ? [{ id: 'project-id', name: 'work' }] : { project: 'project-id', selected: ['coding'], active: ['coding'], inactive: [] };
});
assert.deepEqual(calls.map(x => x[0]), ['project.list', 'project.system-types']);
assert.deepEqual(JSON.parse(output).active, ['coding']);
});
// sys1-review P2: both commands go through run() and the real CLI channel module, so a verb the
// channel does not admit fails here. A disabled channel and an agent run still refuse.
test('project system-types and system-types-set pass the CLI channel', async t => {
const { run } = await import('../src/cli.mjs');
const { fixture, humanEnv, io: mkio, tmp } = await import('./helpers.mjs');
const root = tmp(t), f = fixture(root);
const P = '10000000-0000-4000-8000-000000000001';
const transportFor = channels => {
const calls = [];
const transport = () => async (verb, args) => {
if (verb === 'plugins') return { business: 'acme', revision: 'a'.repeat(64), plugins: { channel: channels } };
calls.push([verb, args]);
if (verb === 'project.list') return [{ id: P, at: '2026-11-09T00:00:00.000Z', name: 'work', repo: null }];
if (verb === 'project.system-types') return { project: P, selected: ['coding'], active: ['coding'], inactive: [] };
if (verb === 'project.system-types.set') return { project: P, selected: args.systemTypes, active: args.systemTypes, inactive: [] };
throw Object.assign(new Error(verb), { code: 'bus-refused' });
};
return { calls, transport };
};
const read = transportFor(['cli']), rx = mkio(humanEnv(f.env));
assert.equal(await run(['project', 'system-types', 'work', '--business', 'acme'], rx, { transport: read.transport }), 0, rx.err?.text);
assert.deepEqual(read.calls, [['project.list', { includeArchived: true }], ['project.system-types', { project: P }]]);
assert.deepEqual(JSON.parse(rx.out.text).active, ['coding']);
const write = transportFor(['cli']), wx = mkio(humanEnv(f.env));
assert.equal(await run(['project', 'system-types-set', 'work', '--types', 'coding,marketing', '--business', 'acme'], wx, { transport: write.transport }), 0, wx.err?.text);
assert.deepEqual(write.calls.at(-1), ['project.system-types.set', { project: P, systemTypes: ['coding', 'marketing'] }]);
const off = transportFor(['webui']), ox = mkio(humanEnv(f.env));
assert.equal(await run(['project', 'system-types', 'work', '--business', 'acme'], ox, { transport: off.transport }), 3);
assert.deepEqual(off.calls, []);
const agent = transportFor(['cli']);
assert.notEqual(await run(['project', 'system-types-set', 'work', '--types', 'coding', '--business', 'acme'], mkio({ ...humanEnv(f.env), CODEX_THREAD_ID: 'fixture-agent' }), { transport: agent.transport }), 0);
assert.deepEqual(agent.calls, []);
});
+45 -13
View File
@@ -5,7 +5,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { existsSync, lstatSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { main } from "../src/cli.mjs";
@@ -124,10 +124,10 @@ test("row 175: --tester on limits set, reset and migrate and every write form ou
// W7 through the real tester transport: the CLI runs as its own process, so the tester-cli.mjs it
// spawns can reach the runtime in this one.
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
function mosaic(args, env) {
function command(executable, args, env) {
const clean = Object.fromEntries(Object.entries(env).filter(([k]) => !/^MOSAIC_BUS_(TESTER|CLI)_NONCE$/.test(k)));
return new Promise((resolve, reject) => {
const child = spawn(MOSAIC, args, { env: clean, timeout: 60000 });
const child = spawn(executable, args, { env: clean, timeout: 60000 });
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
@@ -136,6 +136,8 @@ function mosaic(args, env) {
child.on("close", (status) => resolve({ status, out, err }));
});
}
const mosaic = (args, env) => command(MOSAIC, args, env);
// sage-1081: these six fell through to the seat CLI, which refused with its own usage, so W1
// (`plugins set`) and W4 (`name`) failed through the wrapper. Each now reaches packages/cli.
test("row 175: scripts/mosaic sends plugins, name, names, follow, threads and thread to packages/cli, not the seat CLI", (t) => {
@@ -157,36 +159,55 @@ test("row 175: scripts/mosaic sends plugins, name, names, follow, threads and th
});
const listing = (dir) => readdirSync(dir, { recursive: true }).map(String).sort();
// Names, types and file bytes: a rewrite of an existing file shows up, not just a new path.
const contents = (dir) => listing(dir).map((name) => {
const path = join(dir, name), st = lstatSync(path);
return [name, st.isFile() ? readFileSync(path).toString("base64") : st.isDirectory() ? "dir" : `mode ${st.mode}`];
});
async function w7(t, vars, edit) {
const { root, f, org } = await orgFixture(t, "acme", edit);
const rt = await startBroker({ dataRoot: org.root, businesses: bus(vars) });
const root = tmp(t, "w7-");
const f = fixture(root, "acme", edit);
const rt = await startBroker({ dataRoot: f.dataRoot, businesses: bus(vars) });
t.after(() => rt.close());
return { root, f, agent: { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } };
// Production CLI routes its transport through an organization. Inject the real tester transport
// in a child so the legacy non-org project path can still exercise the broker and its proof.
const driver = join(root, "driver.mjs");
writeFileSync(driver, `
import { run } from ${JSON.stringify(new URL("../src/cli.mjs", import.meta.url).href)};
import { testerTransport } from ${JSON.stringify(new URL("../src/transport.mjs", import.meta.url).href)};
const [socket, ...args] = process.argv.slice(2);
process.exitCode = await run(args, undefined, {
testerTransport: business => testerTransport({ socket, business }),
});
`);
const agent = { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" };
const project = (args, env = agent) => command(process.execPath, [driver, join(f.dataRoot, "bus", "broker.sock"), ...args], env);
return { root, f, agent, project };
}
test("row 175 W7: project init --tester makes the repository under the project area, committed by tester", async (t) => {
const { f, agent } = await w7(t);
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
const { f, agent, project } = await w7(t);
const r = await project(["project", "init", "acme", "stack", "--tester"], agent);
assert.equal(r.status, 0, r.err);
const repo = join(f.dataRoot, "projects", "acme", "stack");
assert.match(r.out, new RegExp(`^initialized ${repo}\ncommit [0-9a-f]{40}\n$`));
const log = spawnSync("git", ["-C", repo, "log", "--format=%an|%cn"], { encoding: "utf8" });
assert.equal(log.stdout, "tester|tester\n");
// Without --tester the same init inside an agent refuses, as before.
const again = await mosaic(["project", "init", "acme", "stack"], agent);
const again = await project(["project", "init", "acme", "stack"], agent);
assert.equal(again.status, 3);
assert.match(again.err, /outside any agent run/);
});
test("row 175 W7: a repo outside the project area refuses tester-project-area before any bus call, and nothing changes", async (t) => {
// The switch is off: a bind would refuse tester-off (exit 3), so exit 2 shows the area check ran first.
const { f, agent } = await w7(t, null, (doc) => {
const { f, agent, project } = await w7(t, null, (doc) => {
doc.projects.stack.repo = join(doc.projects.stack.root, "..", "records");
return doc;
});
const records = f.doc.projects.stack.repo;
const data = listing(f.dataRoot);
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
const r = await project(["project", "init", "acme", "stack", "--tester"], agent);
assert.equal(r.status, 2, r.err);
assert.match(r.err, /tester-project-area/);
assert.equal(existsSync(records), false);
@@ -196,7 +217,7 @@ test("row 175 W7: a repo outside the project area refuses tester-project-area be
test("row 175 W7: the tester bind refuses tester-off and tester-required before any directory", async (t) => {
const off = await w7(t, null);
const data = listing(off.f.dataRoot);
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], off.agent);
const r = await off.project(["project", "init", "acme", "stack", "--tester"], off.agent);
assert.equal(r.status, 3, r.err);
assert.match(r.err, /tester-off/);
assert.deepEqual(listing(off.f.dataRoot), data);
@@ -204,10 +225,21 @@ test("row 175 W7: the tester bind refuses tester-off and tester-required before
const on = await w7(t);
const before = listing(on.f.dataRoot);
for (const marker of ["MOSAIC_RUN_ID", "MOSAIC_BUS_CAP", "MOSAIC_AGENT_RUN"]) {
const under = await mosaic(["project", "init", "acme", "stack", "--tester"], { ...on.agent, [marker]: "x" });
const under = await on.project(["project", "init", "acme", "stack", "--tester"], { ...on.agent, [marker]: "x" });
assert.equal(under.status, 3, `${marker}: ${under.err}`);
assert.match(under.err, /tester-required/, marker);
}
assert.deepEqual(listing(on.f.dataRoot), before);
assert.equal(existsSync(join(on.f.dataRoot, "projects")), false);
});
// Organization setup deliberately refuses tester init before creating a project record.
test("organization project init --tester refuses without writing", async (t) => {
const { f, org } = await orgFixture(t);
const before = contents(org.root);
const agent = { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" };
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
assert.equal(r.status, 3, r.err);
assert.match(r.err, /organization project init --tester is unavailable/);
assert.deepEqual(contents(org.root), before);
});
+5
View File
@@ -578,3 +578,8 @@ The local development owner is accepted only through the existing named-instance
selection on this release's throwaway development instances. Receipt subjects
name that principal, not Jason. The dev-purpose bootstrap fence is a separate
follow-up before person-used deployment.
The optional `plugins.system-type` list enables installed system-type modules for
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.
@@ -12,6 +12,7 @@ import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
import { END_PROTECTED } from "./vocabulary.mjs";
import { checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
import { hasModule } from "../../plugins/src/loader.mjs";
import { checkMapping } from "../../library/src/mapping.mjs";
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
import { checkAppearance } from "./appearance.mjs";
@@ -322,6 +323,9 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
// here; whether each module loads is the loader's check, at load time.
const plugins = document.plugins === undefined ? {}
: structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
for (const id of plugins["system-type"] ?? []) {
if (!hasModule("system-type", id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
}
const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage);
const appearance = checkAppearance(document.appearance, `${file} appearance`);
const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file);
@@ -105,6 +105,13 @@ function usageFence(old, document, actor) {
fail("organization-usage-human-only");
}
function systemTypesFence(old, document, actor) {
if (canonicalJson(old.plugins?.['system-type'] ?? []) === canonicalJson(document?.plugins?.['system-type'] ?? [])) return;
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
if (actor === old.human && actor !== 'tester' && !agents.includes(actor)) return;
fail('organization-system-types-human-only');
}
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is
// the business's human as the file stood before the write, and no agent or the tester under that
// name. Compared as M9 compares projects, on the document and again on what lands on disk.
@@ -286,6 +293,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
replyLimitsFence(old, document, actor);
modelQuotaFence(old, document, actor);
usageFence(old, document, actor);
systemTypesFence(old, document, actor);
validateBusinessDocument(document, file, { rolesDir });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
@@ -295,6 +303,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
replyLimitsFence(old, written, actor);
modelQuotaFence(old, written, actor);
usageFence(old, written, actor);
systemTypesFence(old, written, actor);
validateBusinessDocument(written, file, { rolesDir });
if (beforeWrite) {
beforeWrite();
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { validateBusinessDocument } from '../src/organization-config.mjs';
import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs';
test('organization enables only installed system types; absent means none', () => {
const root = tmp(), doc = businessDoc(root), file = join(root, 'businesses/acme.json');
const validate = () => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES });
assert.deepEqual(validate().plugins['system-type'] ?? [], []);
doc.plugins = { 'system-type': ['coding', 'montage'] };
assert.deepEqual(validate().plugins['system-type'], ['coding', 'montage']);
doc.plugins['system-type'] = ['not-installed'];
assert.throws(validate, /system-type.*not-installed/);
});
test('system type changes through the organization writer are owner-only', async t => {
const { rolesCopy, writeJson } = await import('./helpers.mjs');
const { pluginSettings } = await import('../src/plugin-settings.mjs');
const { rmSync } = await import('node:fs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root), doc = businessDoc(root), file = join(root, 'businesses/acme.json');
writeJson(file, doc);
const settings = pluginSettings({ dir: root, rolesDir });
const revision = settings.read('acme').revision;
assert.throws(() => settings.set('acme', { revision, plugins: { 'system-type': ['coding'] } }, 'tester'), /human-only/);
assert.deepEqual(settings.read('acme').plugins, {});
assert.deepEqual(settings.set('acme', { revision, plugins: { 'system-type': ['coding'] } }, doc.human).plugins['system-type'], ['coding']);
});
+9
View File
@@ -496,3 +496,12 @@ counts; it has no quota authority. See
record separation, bounded reads and per-key quota settings.
A harness declares `outputPolicy({ proxy })` as `adapter`, `non-newline-stdout`, or `stdout`. The launcher validates and serializes that declaration; old sessions retain their recorded activity behavior.
### System types
`system-type` modules export `interfaceVersion = 1` and `systemType()`, returning
`{ id, label, description, toolsets }`. The id is the module directory name;
`toolsets` is a distinct list of toolset ids. The installed list is discovered
from module directories, and malformed modules refuse discovery. An organization
selects modules with `plugins.system-type`; an omitted list enables none. The
initial five modules declare no toolsets yet.
+2
View File
@@ -6,6 +6,8 @@ const VERBS = new Set([
"project.create",
"project.archive",
"project.unarchive",
"project.system-types",
"project.system-types.set",
"thread.list",
"thread.create",
"thread.messages",
+13
View File
@@ -80,6 +80,10 @@ export const KINDS = Object.freeze({
"model-provider": Object.freeze({
kind: "model-provider", interfaceVersion: 1, exports: Object.freeze(["catalogue", "preflight", "route"]), switch: "list",
}),
// Project system types are installed modules, enabled per organization.
"system-type": Object.freeze({
kind: "system-type", interfaceVersion: 1, exports: Object.freeze(["systemType"]), switch: "list",
}),
// A Console colour theme (stream S4, PLAN C5). A module is data only: theme() takes nothing and returns
// { displayName, tokens: { light, dim, dark } }
// where each mode is a flat map of the Console's colour custom properties to #rrggbb strings. theme.mjs
@@ -89,6 +93,15 @@ export const KINDS = Object.freeze({
theme: Object.freeze({
kind: "theme", interfaceVersion: 1, exports: Object.freeze(["theme"]), switch: "list",
}),
// Where an organization keeps its tasks (U4a, REQ-TASK-1). The organization file names one module;
// with none, the bus uses `store`, the task store built into each organization's own bus. `vikunja`
// is the optional Vikunja tracker, set up from the boot config's `trackers` as before. Core calls
// adapter({ trackers }) -> async ({ broker, credentials, businesses, processing }) -> { handle, timeout, close }
// once per bus, where handle(cap, verb, args) answers the eight task verbs for a role's cap and
// timeout is in milliseconds. Every module answers the same `tasks` read through the broker.
"task-tracker": Object.freeze({
kind: "task-tracker", interfaceVersion: 1, exports: Object.freeze(["adapter"]), switch: "one",
}),
});
export class PluginError extends Error {
+19
View File
@@ -0,0 +1,19 @@
import { loadInstalled } from './loader.mjs';
import { NAME } from './kinds.mjs';
// Discovery is fail-closed: malformed or unloadable installed modules cannot be advertised.
export async function installedSystemTypes() {
const { modules, refused } = await loadInstalled('system-type');
if (refused.length) throw Object.assign(new Error('system type module refused'), { code: 'system-type-invalid' });
return modules.map(({ name, module }) => {
const value = module.systemType();
if (!value || Object.keys(value).sort().join(',') !== 'description,id,label,toolsets' || value.id !== name ||
typeof value.label !== 'string' || !value.label.trim() || /[\r\n]/.test(value.label) ||
typeof value.description !== 'string' || !value.description.trim() || /[\r\n]/.test(value.description) ||
!Array.isArray(value.toolsets) || value.toolsets.some(t => typeof t !== 'string' || !NAME.test(t)) ||
new Set(value.toolsets).size !== value.toolsets.length) {
throw Object.assign(new Error(`invalid system type ${name}`), { code: 'system-type-invalid' });
}
return structuredClone(value);
});
}
@@ -0,0 +1,5 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "business-management", label: "Business management", description: "Coordinate and manage business operations.", toolsets: [] };
}
@@ -0,0 +1,5 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "coding", label: "Coding", description: "Build and maintain software.", toolsets: [] };
}
@@ -0,0 +1,5 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "creative-writing", label: "Creative writing", description: "Develop and write creative works.", toolsets: [] };
}
@@ -0,0 +1,5 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "marketing", label: "Marketing", description: "Plan and produce marketing work.", toolsets: [] };
}
@@ -0,0 +1,5 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "montage", label: "Mosaic Montage", description: "Create and manage media productions.", toolsets: [] };
}
@@ -0,0 +1,9 @@
// U4a (#1873, REQ-TASK-1): the default task-tracker module, the task store built into each
// organization's own bus. It needs no setting and no credential; packages/tasks/src/store.mjs has it.
import { storeAdapter } from '../../../tasks/src/store.mjs';
export const interfaceVersion = 1;
export function adapter() {
return storeAdapter();
}
@@ -0,0 +1,9 @@
// U4a (#1873): the optional Vikunja task-tracker module. It is the Vikunja adapter that was the only
// tracker before, unchanged, set up from the boot config's `trackers` (packages/tasks/src/adapter.mjs).
import { tasksAdapter } from '../../../tasks/src/adapter.mjs';
export const interfaceVersion = 1;
export function adapter({ trackers = {} } = {}) {
return tasksAdapter({ trackers });
}
+3 -1
View File
@@ -6,7 +6,9 @@ import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs";
const code = expected => e => e instanceof PluginError && e.code === expected;
test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1, limit-check at 2 (row 125), each a list", () => {
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "theme"]);
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "system-type", "theme", "task-tracker"]);
assert.deepEqual({ ...KINDS["task-tracker"], exports: [...KINDS["task-tracker"].exports] }, { kind: "task-tracker", interfaceVersion: 1, exports: ["adapter"], switch: "one" });
assert.ok(Object.isFrozen(KINDS["task-tracker"]) && Object.isFrozen(KINDS["task-tracker"].exports));
// Stream S4 (PLAN C5): a theme is data. interfaceVersion 1, one export, a list switch.
assert.deepEqual({ ...KINDS.theme, exports: [...KINDS.theme.exports] }, { kind: "theme", interfaceVersion: 1, exports: ["theme"], switch: "list" });
assert.ok(Object.isFrozen(KINDS.theme) && Object.isFrozen(KINDS.theme.exports));
+5 -5
View File
@@ -12,7 +12,7 @@ import { validateBusinessDocument } from "../../organization/src/organization-co
const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] };
// What loaded() reports for GOOD: search-source is a kind too, switched off here.
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] };
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] };
// A tree with one good module of each kind, already loaded.
async function loadedTree(t, options) {
@@ -56,7 +56,7 @@ test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization
const loader = await s.loader();
const result = await loader.load(business.plugins);
assert.deepEqual(result.refused, []);
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage");
assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit");
assert.equal(loader.modules("limit-check")[0].mustStop(), false);
@@ -170,7 +170,7 @@ test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses a
assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/);
assert.deepEqual(result.refused[0].kept, ["meter"]);
assert.deepEqual(s.loader.logged.slice(before), result.refused);
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
} finally {
if (lock) chmodSync(lock, 0o755);
}
@@ -232,7 +232,7 @@ test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and t
const loader = await s.loader();
// meter loads, absent refuses: the whole kind stays off, not half on.
const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
assert.deepEqual(loader.loaded(), result.loaded);
assert.deepEqual(loader.modules("usage-source"), []);
assert.equal(loader.logged.length, 1);
@@ -260,7 +260,7 @@ test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole se
await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/);
// An absent key switches the kind off.
result = await s.loader.load({ "limit-check": ["cap"] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [], "task-tracker": [] });
});
test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => {
@@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createLoader, loadInstalled } from '../src/loader.mjs';
test('installed system types describe the five modules and unknown names refuse', async () => {
const { modules, refused } = await loadInstalled('system-type');
assert.deepEqual(refused, []);
assert.deepEqual(modules.map(m => m.name), ['business-management', 'coding', 'creative-writing', 'marketing', 'montage']);
for (const { name, module } of modules) {
const info = module.systemType();
assert.equal(info.id, name);
assert.ok(info.label && info.description && Array.isArray(info.toolsets));
if (name === 'montage') assert.equal(info.label, 'Mosaic Montage');
}
const loader = createLoader({ log() {} });
const result = await loader.load({ 'system-type': ['unknown'] });
assert.equal(result.refused.length, 1);
assert.deepEqual(result.loaded['system-type'], []);
});
+183
View File
@@ -0,0 +1,183 @@
import { BusError, TASK_VERBS, ASSIGNED_STATES, STORE_POSITION_MAX } from '../../bus/src/broker.mjs';
import { loadFields } from './fields.mjs';
// U4a (#1873, REQ-TASK-1): the task store built into each organization, the default task-tracker
// module. It answers the eight task verbs as verbs.mjs does for Vikunja, with the same arguments
// where the store has the thing they name, and writes through broker.storeTask, which keeps the rows
// in the organization's own bus.sqlite. Nothing here talks to a network or holds a credential.
//
// What the module checks before the broker sees the call, in this order:
// - the caller is one of the organization's roles (tracker-role);
// - the field table's writer for the verb (packages/tasks/fields.json): a pm verb needs the pm
// definition (field-writer), task.update.assigned needs the role among the task's assignees
// (not-assigned), task.scope.change any role;
// - the arguments, as verbs.mjs takes them. The store has no Vikunja projects, relations or scoped
// requirement snapshots, so `project`, `relations`, `project_key` and `requirement_snapshot`
// refuse (invalid-request). task.schedule also takes `group` and `position`, task.create too.
// The broker then holds the write to the fields STORE_VERB_FIELDS gives the verb, whatever this
// module asked for, and checks the holder, the authority and a close's verdict as it does for Vikunja.
export const STORE_TIMEOUT = 60000;
const refuse = (code) => {
throw new BusError(code);
};
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
function keys(x, allowed, required = []) {
if (!object(x) || Object.keys(x).some((k) => !allowed.includes(k)) || required.some((k) => x[k] === undefined))
refuse('invalid-request');
}
const text = (x, max, min = 0) => {
if (typeof x !== 'string' || x.length < min || x.length > max) refuse('invalid-request');
return x;
};
const canonical = (x) =>
typeof x === 'string' &&
/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(x) &&
Number.isFinite(Date.parse(x)) &&
new Date(x).toISOString() === x;
const due = (x) => (x === null || canonical(x) ? x : refuse('invalid-request'));
const priority = (x) => (Number.isSafeInteger(x) && x >= 0 && x <= 5 ? x : refuse('invalid-request'));
const percent = (x) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1 ? x : refuse('invalid-request'));
const ids = (list) => {
if (!Array.isArray(list) || list.length > 50 || list.some((x) => !Number.isSafeInteger(x) || x < 1)) refuse('invalid-request');
return [...new Set(list)];
};
const group = (x) => (x === null || (typeof x === 'string' && x.length <= 64 && x.trim() && !/[\u0000-\u001f\u007f]/.test(x)) ? x : refuse('invalid-request'));
const position = (x) => (Number.isSafeInteger(x) && x >= 0 && x <= STORE_POSITION_MAX ? x : refuse('invalid-request'));
const decision = (x) => (x === undefined ? null : text(x, 128, 1));
// Arguments a Vikunja verb takes that name something the store doesn't have.
const VIKUNJA_ONLY = ['project', 'relations', 'project_key', 'requirement_snapshot'];
const COMMON = ['task_ref', 'decision', 'expect'];
export function storeAdapter({ fields = loadFields() } = {}) {
const writers = fields.verbs;
return async function factory({ broker }) {
let closed = false;
function prepare(verb, args, roles, role) {
if (object(args) && VIKUNJA_ONLY.some((k) => Object.hasOwn(args, k))) refuse('invalid-request');
if (args?.expect !== undefined && !(typeof args.expect === 'string' && /^[0-9a-f]{64}$/.test(args.expect))) refuse('invalid-request');
switch (verb) {
case 'task.create': {
keys(args, ['title', 'description', 'due_date', 'priority', 'labels', 'request', 'requirement', 'decision', 'op', 'group', 'position'], ['title', 'request', 'requirement']);
const set = { title: text(args.title, 250, 1) };
if (!set.title.trim()) refuse('invalid-request');
if (args.description !== undefined) set.description = text(args.description, 65536);
if (args.due_date !== undefined) set.due_date = due(args.due_date);
if (args.priority !== undefined) set.priority = priority(args.priority);
if (args.labels !== undefined) set.labels = ids(args.labels).sort((a, b) => a - b);
if (args.group !== undefined) set.group = group(args.group);
if (args.position !== undefined) set.position = position(args.position);
set.request = text(args.request, 64, 1);
set.requirement = text(args.requirement, 64);
if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(set.requirement)) refuse('invalid-request');
if (args.op !== undefined) {
set.op = text(args.op, 256, 1);
if (/[;\r\n]/.test(set.op)) refuse('invalid-request');
}
return (f) => ({ ...f, ...set });
}
case 'task.assign':
case 'task.reassign': {
keys(args, [...COMMON, 'role'], ['task_ref', 'role']);
const to = text(args.role, 64, 1);
if (!Object.hasOwn(roles, to)) refuse('unknown-role');
return (f) => {
const held = f.assignees;
if (verb === 'task.assign' && held.length) refuse('task-assigned');
if (verb === 'task.reassign' && !held.length) refuse('task-unassigned');
if (verb === 'task.reassign' && held.length === 1 && held[0] === to) refuse('task-assigned');
return { ...f, assignees: [to] };
};
}
case 'task.schedule': {
keys(args, [...COMMON, 'due_date', 'labels', 'group', 'position'], ['task_ref']);
if (['due_date', 'labels', 'group', 'position'].every((k) => args[k] === undefined)) refuse('invalid-request');
const dueDate = args.due_date === undefined ? undefined : due(args.due_date);
const labels = { add: [], remove: [] };
if (args.labels !== undefined) {
keys(args.labels, ['add', 'remove']);
labels.add = ids(args.labels.add ?? []);
labels.remove = ids(args.labels.remove ?? []);
}
const g = args.group === undefined ? undefined : group(args.group);
const p = args.position === undefined ? undefined : position(args.position);
return (f) => {
const out = { ...f };
if (dueDate !== undefined) out.due_date = dueDate;
out.labels = [...new Set([...f.labels.filter((l) => !labels.remove.includes(l)), ...labels.add])].sort((a, b) => a - b);
// A move to another group with no position goes to the end of it (the broker settles a null).
if (g !== undefined && g !== f.group) Object.assign(out, { group: g, position: p ?? null });
else if (p !== undefined) out.position = p;
return out;
};
}
case 'task.priority.change': {
keys(args, [...COMMON, 'priority'], ['task_ref', 'priority']);
const p = priority(args.priority);
return (f) => ({ ...f, priority: p });
}
case 'task.scope.change': {
keys(args, [...COMMON, 'title', 'description'], ['task_ref']);
const set = {};
if (args.title !== undefined) set.title = text(args.title, 250, 1);
if (set.title !== undefined && !set.title.trim()) refuse('invalid-request');
if (args.description !== undefined) set.description = text(args.description, 65536);
if (!Object.keys(set).length) refuse('invalid-request');
return (f) => ({ ...f, ...set });
}
case 'task.update.assigned': {
keys(args, [...COMMON, 'percent_done', 'state', 'comment'], ['task_ref']);
const pct = args.percent_done === undefined ? undefined : percent(args.percent_done);
// done is not a target here: closing is the pm's task.close after a review verdict.
if (args.state !== undefined && !ASSIGNED_STATES.includes(args.state)) refuse('invalid-state');
const note = args.comment === undefined ? undefined : text(args.comment, 10000, 1);
if (pct === undefined && args.state === undefined && note === undefined) refuse('invalid-request');
return (f) => {
if (!f.assignees.includes(role)) refuse('not-assigned');
const out = { ...f };
if (pct !== undefined) out.percent_done = pct;
if (args.state !== undefined) Object.assign(out, { state: args.state, bucket: args.state });
// The store keeps the latest comment and the count; the event records only that there was one.
if (note !== undefined) Object.assign(out, { comments: f.comments + 1, comment: { text: note, by: role } });
return out;
};
}
case 'task.close': {
keys(args, [...COMMON, 'verdict'], ['task_ref', 'verdict']);
const verdict = text(args.verdict, 256, 1);
return (f) => ({ ...f, state: 'done', bucket: 'done', done: true, verdict });
}
default:
return refuse('unknown-verb');
}
}
return {
timeout: STORE_TIMEOUT,
async handle(cap, verb, args) {
if (closed) refuse('tracker-closed');
const me = broker.identity(cap);
const roles = broker.taskRoles(me.business);
const mine = roles[me.role];
if (!mine || typeof mine.definition !== 'string') refuse('tracker-role');
if (!TASK_VERBS.includes(verb)) refuse('unknown-verb');
if (writers[verb] === 'pm' && mine.definition !== 'pm') refuse('field-writer');
const apply = prepare(verb, args, roles, me.role);
const creating = verb === 'task.create';
const row = broker.storeTask(cap, {
verb,
task_ref: creating ? null : args.task_ref,
decision: decision(args.decision),
expect: args.expect ?? null,
verdict: verb === 'task.close' ? args.verdict : null,
// Reopening a done task is a person's act; no role verb writes to one.
change: (f) => {
if (f?.done) refuse('task-done');
return apply(f);
},
});
return row.deleted ? { task_ref: row.task_ref, deleted: true } : { task_ref: row.task_ref, digest: row.digest, updated: row.updated };
},
close() {
closed = true;
},
};
};
}
+53 -8
View File
@@ -264,9 +264,32 @@
// ---------------------------------------------------------------- views
let inboxCount = null, messageCount = null;
// Organization mode (sample 01): Dashboard, Inbox, Tasks, Projects, Side by side, Agents and
// Assistant, with Settings pinned to the foot of the sidebar. Queue opens from the organization
// dashboard and Docs from its Docs row, so neither is a sidebar item here.
function orgSections(route) {
const nav = $('console-nav');
let foot = $('sections-foot');
if (!foot) { foot = document.createElement('div'); foot.id = 'sections-foot'; foot.className = 'ws-foot'; nav.append(foot); }
const active = document.activeElement;
const focused = $('sections').contains(active) || foot.contains(active) ? active.getAttribute('href') : null;
const icon = id => window.MosaicWorkspace?.icon?.(id) ?? `<svg class="i" aria-hidden="true"><use href="/icons.svg#i-${id}"/></svg>`;
const n = (count, what) => count > 0 ? `<span class="ws-n hot" aria-label="${count} ${what}">${count}</span>` : '';
const items = [['/', 'Dashboard', '', 'board'], ['/inbox', 'Inbox', n(inboxCount?.open, 'open for you'), 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/projects', 'Projects', '', 'folder'], ['/side-by-side', 'Side by side', '', 'columns'], ['/agents', 'Agents', '', 'agents'], ['/messages', 'Assistant', n(messageCount, 'unread messages'), 'talk']];
const link = ([h, l, extra, id]) => {
const cur = h === '/' ? (route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
return `<a href="#${h}" class="s1-section ws-nav"${cur ? ' aria-current="page"' : ''}>${icon(id)}<span class="t">${l}</span>${extra}</a>`;
};
// No project tree under Projects here: the sidebar's Projects group lists every organization's
// projects and opens the same project page, which lists its conversations.
$('sections').innerHTML = items.map(item => `<li>${link(item)}</li>`).join('');
foot.innerHTML = link(['/settings', 'Settings', '', 'settings']);
if (focused) [...$('sections').querySelectorAll('a[href]'), ...foot.querySelectorAll('a[href]')].find(a => a.getAttribute('href') === focused)?.focus({ preventScroll: true });
}
function sections(route) {
if (window.MosaicOrg) return orgSections(route);
const focused = $('sections').contains(document.activeElement) ? document.activeElement.getAttribute('href') : null;
const items = [['/', 'Dashboard', '', 'board'], ['/projects', 'Projects', '', 'inbox'], ['/inbox', 'Inbox', inboxCount?.open > 0 ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount > 0 ? `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>` : '', 'talk'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
const items = [['/', 'Dashboard', '', 'board'], ['/projects', 'Projects', '', 'inbox'], ['/inbox', 'Inbox', inboxCount?.open > 0 ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Assistant', messageCount > 0 ? `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>` : '', 'talk'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
const cur = h === '/' ? (route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true">${h === '/projects' ? '<path d="M2 5h6l2 2h8v11H2z"/>' : `<use href="/icons.svg${spriteVersion ? `?retry=${spriteVersion}` : ''}#i-${icon}"/>`}</svg>${l}${extra ? ` ${extra}` : ''}</a>${h === '/projects' ? '<div id="project-tree" aria-label="Project conversations"></div>' : ''}</li>`;
@@ -668,7 +691,12 @@
}
}
async function agentsView(get) {
const claims = await get('agents');
let claims;
try { claims = await get('agents'); } catch (err) {
if (!window.MosaicOrg || err.code === 'signed-out') throw err;
if (refusal(err)) forget();
return `${await window.MosaicWorkspace.agentGrid()}<div class="banner err" role="alert">The selected organization's held roles could not be read. ${why(err)}</div>`;
}
let instances = null, threads = [];
if (!isTester) {
const reads = await Promise.allSettled([get('instances'), get.api('chat/threads')]);
@@ -683,7 +711,9 @@
for (const claim of claims) if (claim.holder_run && claim.role && !holders.has(claim.holder_run)) holders.set(claim.holder_run, claim.role);
const threadSection = await threadClaimsSection(get, holders, threads, !!instances, running);
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${txt(holders.get(c.holder_run) || c.role)}<span class="source">run ${txt(c.holder_run)}${!instances ? ', running status unavailable' : running.has(c.holder_run) ? '' : ', not running'}</span></td><td>${txt(c.harness)}${c.address ? `<span class="source">${txt(c.address)}</span>` : ''}</td><td>${when(c.at)}</td></tr>`).join('');
return `${head('Agents', readNote(get))}
// Organization mode (sample 08): the levels grid carries the page heading; held roles follow.
const top = window.MosaicOrg ? await window.MosaicWorkspace.agentGrid(readNote(get)) : head('Agents', readNote(get));
return `${top}
<section aria-labelledby="b-claims"><h2 id="b-claims">Held roles <span class="count">${claims.length}</span></h2><p class="small muted">The current holder of each claimed role. A role nobody holds does not appear. Working: the holder has messages it received and hasn't answered yet. Waiting: a decision the role raised is open in your inbox. Otherwise idle.</p>
${claims.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles, scroll for all columns"><table class="s1-table s1-agents"><thead><tr>${['Role', 'State', 'Attention', 'Holder', 'Harness', 'Held since'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div>` : empty('No role is held right now.', 'A role shows here while a run holds its claim on the bus.')}</section>
${threadSection}
@@ -753,7 +783,7 @@
const b = (await get.api('business')).business || {};
const orgs = window.MosaicOrg ? await window.MosaicOrg.list : null;
if (orgs) await configRead; // orgSetup decides the "New organization" link
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}${org.invalid ? ' disabled' : ''}>${txt(org.label || org.id)}${org.invalid ? ' · Invalid' : org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select>${orgSetup ? `<p><a href="${NEW_ORG}">New organization</a></p>` : ''}</section>` : '';
const picker = '';
const instances = Array.isArray(b.instances) ? b.instances : [], creds = Array.isArray(b.credentials) ? b.credentials : [], actions = Array.isArray(b.actions) ? b.actions : [];
const launch = b.launch || {};
const authKnown = instances.some(i => i.authority);
@@ -1172,7 +1202,7 @@
const label=pages.find(([id])=>id===page)?.[1] || 'Appearance';
const isDirty=id=>(id==='routing'&&routesDraft)||(id==='limits'&&settingsInputs)||(id==='assistant'&&(Object.keys(assistantDrafts).length||Object.keys(nameDrafts).length))||(id==='agents'&&(instanceDrafts.add||instanceDrafts.change));
const dot='<span class="settings-dot"><span class="sr-only">unsaved changes</span></span>';
const links=pages.map(([id,name],i)=>`${i===0?'<div class="settings-group">Your browser</div>':i===1?'<div class="settings-group">This instance</div>':''}<a href="#/settings/${id}"${id===page?' aria-current="page"':''}>${name}${isDirty(id)?dot:''}</a>`).join('');
const links=pages.map(([id,name],i)=>`${i===0?'<div class="settings-group">Your browser</div>':i===1?'<div class="settings-group">This instance</div>':''}<a href="#/settings/${id}"${id===page?' aria-current="page"':''}>${name}${isDirty(id)?dot:''}</a>`).join('') + (!isTester ? '<div class="settings-group">Members and invites <span class="muted">not built</span></div>' : '');
return `${head('Settings','Your browser and this instance.')}<div class="settings-layout"><div><button type="button" id="settings-disclosure" aria-expanded="false" aria-controls="settings-nav"><small>Section</small><strong>${label}${isDirty(page)?dot:''}</strong><span aria-hidden="true">⌄</span></button><nav id="settings-nav" aria-label="Settings sections">${links}</nav></div><div class="settings-content settings-${page}${readonlyPage(page)?' readonly':''}"><h2 id="settings-page-title" tabindex="-1">${label}</h2><p class="settings-helper">${helper}</p>${readonlyPage(page)?'<p class="settings-readonly">Read only. Saving requires signed-in send mode.</p>':''}${body}</div></div>`;
}
async function settingsView(get, page='appearance') {
@@ -1412,10 +1442,20 @@
// ---------------------------------------------------------------- router
// #/ and anything that is not a #/ route is the control board; the rest is this file's.
const routeOf = () => window.MosaicOrg?.route() ?? (location.hash.startsWith('#/') ? location.hash.slice(1) : '/');
// A one-line scope note right under a view's page heading.
function afterHead(html, note) {
const start = html.indexOf('<div class="page-head">'), end = start < 0 ? -1 : html.indexOf('</div></div>', start);
const line = `<p class="small muted ws-scope" data-not-built>${esc(note)}</p>`;
return end < 0 ? `${line}${html}` : `${html.slice(0, end + 12)}${line}${html.slice(end + 12)}`;
}
function view(get, path, q, opts, current) {
const parts = path.split('/').filter(Boolean).map(p => { try { return decodeURIComponent(p); } catch { return null; } });
if (parts.includes(null)) return notFound();
const [a, b, c] = parts, n = parts.length;
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
if (a === 'side-by-side' && n === 1 && window.MosaicOrg) return `${head('Side by side', 'Not built yet.')}${empty('Side by side is not built yet.', 'It arrives with a later unified workspace update. Projects and Agents work today.')}`;
if (a === 'inbox' && n === 1 && window.MosaicOrg) return inboxView(get).then(html => afterHead(html, 'The inbox across all organizations is not built yet. This is the selected organization\'s inbox.'));
if (a === 'tasks' && n === 1 && window.MosaicOrg) return tasksView(get).then(html => afterHead(html, 'Tasks across all organizations are not built yet. These are the selected organization\'s tasks.'));
if (a === 'inbox' && n === 1) return inboxView(get);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
if (a === 'messages' && n === 1) return messagesView(get, q);
@@ -1441,6 +1481,7 @@
function keep() {
const a = document.activeElement, view = $('bus-view');
return {
focusKey: view.contains(a) ? a.dataset?.focusKey || (a.hasAttribute?.('data-bus-restart') ? 'bus-restart' : null) : null,
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'), mirror: view.contains(a) ? a.dataset?.mirror : null, h1: view.contains(a) && a.tagName === 'H1',
settingsChoice: view.contains(a) && a.type==='radio' ? [a.name,a.value] : null,
settingsField: view.contains(a) && a.closest('.settings-content') && (a.dataset?.instanceField || a.dataset?.assistantField || a.dataset?.route || a.dataset?.setting) ? {form:a.closest('form')?.id || null, instance:a.closest('[data-instance]')?.dataset.instance || null, key:a.dataset.instanceField?'instanceField':a.dataset.assistantField?'assistantField':a.dataset.route?'route':'setting', value:a.dataset.instanceField || a.dataset.assistantField || a.dataset.route || a.dataset.setting} : null,
@@ -1459,7 +1500,10 @@
const dec = k.decide ? $('bus-view').querySelector(k.decide === 'status' ? '#decide-status' : `[data-decide="${k.decide}"]`) : null;
const choice=k.settingsChoice && [...$('bus-view').querySelectorAll('input[type=radio]')].find(x=>x.name===k.settingsChoice[0]&&x.value===k.settingsChoice[1]);
const setting=k.settingsField && [...$('bus-view').querySelectorAll('[data-instance-field],[data-assistant-field],[data-route],[data-setting]')].find(e=>e.dataset[k.settingsField.key]===k.settingsField.value && (!k.settingsField.form || e.closest('form')?.id===k.settingsField.form) && (!k.settingsField.instance || e.closest('[data-instance]')?.dataset.instance===k.settingsField.instance));
if (setting) { setting.focus({preventScroll:true}); if(k.caret && typeof setting.setSelectionRange==='function'){setting.setSelectionRange(k.caret[0],k.caret[1]);setting.scrollTop=k.caret[2];} }
// The workspace screens mark their controls with data-focus-key: several Open links share an href.
const keyed = k.focusKey ? $('bus-view').querySelector(k.focusKey === 'bus-restart' ? '[data-bus-restart]' : `[data-focus-key="${CSS.escape(k.focusKey)}"]`) : null;
if (keyed) keyed.focus({ preventScroll: true });
else if (setting) { setting.focus({preventScroll:true}); if(k.caret && typeof setting.setSelectionRange==='function'){setting.setSelectionRange(k.caret[0],k.caret[1]);setting.scrollTop=k.caret[2];} }
else if (choice) choice.focus({preventScroll:true});
else if (dec) dec.focus({ preventScroll: true });
else if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
@@ -1557,7 +1601,7 @@
let html, state = 'fresh';
const live = reader(true);
try {
if (path !== '/messages' && !['/settings','/settings/appearance','/settings/model-proxy','/settings/organizations'].includes(path)) {
if (path !== '/organization' && !(path === '/agents' && window.MosaicOrg) && path !== '/messages' && !['/settings','/settings/appearance','/settings/model-proxy','/settings/organizations'].includes(path)) {
try { await live('messages'); }
catch (err) { messageCount = null; if (err.status === 403 || err.code === 'signed-out') throw err; }
}
@@ -1578,6 +1622,7 @@
$('bus-view').innerHTML = html; shown = route;
syncAppearance(true);
if (path.startsWith('/docs/')) paintDoc();
if (path === '/organization') window.MosaicConsole?.attachBus(document.querySelector('[data-dashboard-bus]'));
if (path === '/settings/model-proxy') proxyCleanup=window.MosaicProxy.mount();
if (path === '/settings/organizations') orgCleanup=window.MosaicOrgCreate.mount();
sections(path);
@@ -1639,7 +1684,7 @@
// Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and
// tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus
// back where it was. It only navigates.
const VIEWS = [['#/', 'Dashboard'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/settings/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']];
const VIEWS = [['#/', 'Dashboard'], ['#/projects', 'Projects'], ['#/inbox', 'Inbox'], ['#/messages', 'Assistant'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/settings/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings'], ['#/settings/display', 'Sidebar display']];
let pkAll = [], pkShown = [], pkAt = 0, pkBack = null;
function pkItems() {
const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
+2 -2
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script>
<script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<body>
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
<a class="skip" href="#main">Skip to content</a>
@@ -19,7 +19,7 @@
<div id="processing-mobile"></div>
<div class="frame">
<div id="nav-scrim" hidden></div><nav class="tree" id="console-nav" aria-label="Console"><div class="drawer-head"><strong>Navigation</strong><button type="button" class="btn" id="nav-close">Close</button></div><p id="nav-overflow" class="small muted" hidden>Scroll for more destinations</p><div id="project-context" aria-label="Current project and conversation"></div>
<ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Board project filter</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
<ul class="s1-sections" id="sections"></ul><div id="workspace-nav" aria-label="Organizations and projects"></div><div id="board-tree"><h2 class="tree-title">Board project filter</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div id="theme-warning" class="banner ref" role="status" hidden><p></p><div class="actions"><button id="theme-clear" type="button" class="btn" hidden>Use the default theme</button></div></div><p id="org-cue" class="org-cue" hidden></p><nav class="breadcrumbs" id="breadcrumbs" aria-label="Breadcrumb"><span aria-current="page">Dashboard</span></nav><div class="page-head" id="board-head"><div><h1>Dashboard</h1><p id="dashboard-business" class="muted">Business overview</p><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
<section id="dashboard-summary" aria-label="Dashboard metrics"><div id="dashboard-kpis" class="kpi-grid"></div><p id="dashboard-updated" class="kpi-footer">Not read yet</p></section>
<section id="held-section" aria-labelledby="held-title"><h2 id="held-title">Held roles <span id="held-count" class="count">–</span></h2><p class="small muted">The stack's seats: each role a run holds on the bus, from the bus <code>agents</code> read. Machine sessions are under Show machine sessions.</p><div id="held"><div class="skel-rows" aria-busy="true"><span class="sr-only">Reading the held roles.</span><span class="skel"></span><span class="skel"></span></div></div></section>
+203
View File
@@ -351,3 +351,206 @@ body:not(:has(.project-chat)) .content { padding-bottom:calc(22px + var(--foot-h
.settings-content:has(.route-actions){padding-bottom:100px}
}
.assistant-form textarea[data-assistant-field="vars"] { field-sizing:content; min-height:6em; max-height:16em; overflow-y:auto; }
/* Unified workspace, organization mode (PRD 1.20, U2). The sample 01, 03 and 08 layouts, ported
from docs/design/samples/2026-11-09-unified-workspace/workspace.css with ws- names, so nothing
collides with the Console's own .btn, .chip, .panel, .split, .label or .cfg. */
html[data-organizations]{--glyph:"DejaVu Sans","Segoe UI Symbol","Apple Symbols","Noto Sans Symbols 2",var(--font)}
@media (min-width:960px){html[data-organizations]{--sidebar-width:240px}}
/* An organization's colour for the page's mode: workspace.js sets the three pairs inline. */
html[data-organizations] .ws-o{--org:var(--org-l);--org-on:var(--org-on-l)}
@media (prefers-color-scheme:dark){html[data-organizations]:not([data-mode="light"]):not([data-mode="dim"]) .ws-o{--org:var(--org-d);--org-on:var(--org-on-d)}}
html[data-organizations][data-mode="dark"] .ws-o{--org:var(--org-d);--org-on:var(--org-on-d)}
html[data-organizations][data-mode="dim"] .ws-o{--org:var(--org-m);--org-on:var(--org-on-m)}
html[data-organizations] .ws-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
html[data-organizations] .ws-label{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
html[data-organizations] .ws-line{margin:0;padding:3px 8px 3px 10px;font-size:12px;color:var(--muted);list-style:none}
/* Sidebar (sample 01). */
html[data-organizations] .tree{padding:10px 8px 14px;gap:2px}
html[data-organizations] #project-tree{border-bottom:0;padding-bottom:0}
html[data-organizations] .drawer-head{background:var(--nav-surface,var(--surface))}
html[data-organizations] #workspace-nav{display:flex;flex-direction:column;gap:2px;min-width:0;border:0;padding:0}
html[data-organizations] .s1-sections{display:grid;gap:2px;flex-basis:auto;overflow:visible;padding:0}
html[data-organizations] .s1-sections>li{width:100%;flex:none}
html[data-organizations] .ws-nav,html[data-organizations] .s1-section.ws-nav{display:flex;align-items:center;gap:9px;padding:5px 8px;min-height:0;min-width:0;border:0;border-radius:var(--r);box-shadow:none;background:transparent;color:var(--text);text-decoration:none;font:600 13.5px/1.5 var(--font);flex-wrap:nowrap;white-space:nowrap}
html[data-organizations] .ws-nav:hover{background:var(--raised)}
html[data-organizations] .ws-nav[aria-current=page],html[data-organizations] .s1-section.ws-nav[aria-current=page]{background:color-mix(in srgb,var(--action) 13%,var(--surface));box-shadow:none;color:var(--strong)}
html[data-organizations] .ws-nav svg.i{width:18px;height:18px;stroke-width:1.75;color:inherit}
html[data-organizations] .ws-nav[aria-current=page] svg.i{color:var(--action)}
html[data-organizations] .ws-nav .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
html[data-organizations] .ws-nav.sel{color:var(--strong)}
html[data-organizations] .ws-n{margin-left:auto;font:600 11px var(--mono);color:var(--muted);font-variant-numeric:tabular-nums}
html[data-organizations] .ws-n.hot{color:var(--surface);background:var(--danger);border-radius:999px;padding:0 6px}
html[data-organizations] .ws-hr{border:0;border-top:1px solid var(--line);margin:6px 4px;width:auto}
html[data-organizations] .ws-foot{margin-top:auto;padding-top:8px;border-top:1px solid var(--line)}
html[data-organizations] .ws-grp{display:flex;flex-direction:column;gap:1px}
html[data-organizations] .ws-grp-h{display:flex;align-items:center;gap:6px;padding:10px 8px 4px;color:var(--muted)}
html[data-organizations] .ws-grp-t{display:flex;align-items:center;gap:6px;flex:1;min-width:0;padding:0;margin:0;border:0;background:none;color:inherit;font:inherit;cursor:pointer;text-align:left}
html[data-organizations] .ws-grp-t .ws-label{flex:1}
html[data-organizations] .ws-grp-h>.ws-label{flex:1}
html[data-organizations] .ws-grp-h svg.i{width:14px;height:14px;transition:transform .15s}
html[data-organizations] .ws-grp-t[aria-expanded=false] svg.chev{transform:rotate(-90deg)}
html[data-organizations] .ws-grp-h .ws-n{margin-left:0}
html[data-organizations] .ws-add{display:inline-grid;place-items:center;flex:none;width:22px;height:22px;border-radius:var(--r);color:var(--muted);border:1px solid var(--line)}
html[data-organizations] .ws-add:hover{background:var(--raised);color:var(--strong)}
html[data-organizations] .ws-marks{display:flex;gap:4px;padding:0 8px 6px 28px}
html[data-organizations] .ws-orgs{display:flex;flex-direction:column;gap:1px;list-style:none;margin:0;padding:0}
html[data-organizations] .ws-orgs[hidden]{display:none}
html[data-organizations] .ws-project-list[hidden]{display:none}
html[data-organizations] .ws-seg{display:inline-flex;margin-left:auto;border:1px solid var(--line);border-radius:var(--r);overflow:hidden;background:var(--surface)}
html[data-organizations] .ws-seg>button{border:0;background:none;padding:2px 8px;color:var(--muted);font:600 12px/1.5 var(--font);display:inline-flex;align-items:center;white-space:nowrap;cursor:pointer;min-height:0}
html[data-organizations] .ws-seg.xs>button{padding:0 6px;font-size:11px}
html[data-organizations] .ws-seg [aria-pressed=true]{background:var(--action);color:var(--onAction)}
html[data-organizations] .ws-pgroup{display:flex;flex-direction:column;gap:1px;margin-bottom:4px}
html[data-organizations] .ws-pgroup-h{display:flex;align-items:center;gap:7px;padding:4px 8px 2px;font-size:12px;font-weight:700;color:var(--strong)}
html[data-organizations] .ws-pgroup-h .t{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
html[data-organizations] .ws-projs{margin-left:17px;border-left:2px solid var(--org);display:flex;flex-direction:column;gap:1px}
html[data-organizations] .ws-proj{padding:3px 8px 3px 10px;font-weight:500;font-size:13px}
html[data-organizations] .ws-proj.flat{padding-left:8px}
html[data-organizations] .ws-flat{display:flex;flex-direction:column;gap:1px}
/* Avatars, status marks, chips, buttons. */
html[data-organizations] .ws-av{display:inline-grid;place-items:center;flex:none;width:20px;height:20px;border-radius:5px;background:var(--org,var(--muted));color:var(--org-on,var(--surface));font:700 9.5px/1 var(--font);letter-spacing:.02em}
html[data-organizations] .ws-av.xs{width:16px;height:16px;border-radius:4px;font-size:8.5px;letter-spacing:0}
html[data-organizations] .ws-av.md{width:28px;height:28px;border-radius:7px;font-size:12px}
html[data-organizations] .ws-av.lg{width:44px;height:44px;border-radius:10px;font-size:17px}
html[data-organizations] .ws-av.all{background:var(--raised);color:var(--strong)}
html[data-organizations] .ws-av.all .mosaic-logo{width:18px;height:18px;fill:currentColor}
html[data-organizations] .ws-dot{display:inline-flex;align-items:center;gap:6px;flex:none}
html[data-organizations] .ws-dot b{font:400 13px/1 var(--glyph);width:14px;text-align:center}
html[data-organizations] .ws-dot.s-working{color:var(--success)}
html[data-organizations] .ws-dot.s-waiting{color:var(--warning)}
html[data-organizations] .ws-dot.s-error{color:var(--danger)}
html[data-organizations] .ws-dot.s-offline,html[data-organizations] .ws-dot.s-idle,html[data-organizations] .ws-dot.s-unknown{color:var(--muted)}
html[data-organizations] .ws-nav .ws-dot{margin-left:auto}
html[data-organizations] .ws-org-row .ws-dot{font-size:12.5px;font-weight:500}
html[data-organizations] .ws-org-row .ws-dot.s-offline,html[data-organizations] .ws-org-row .ws-dot.s-error{color:var(--text)}
html[data-organizations] .ws-org-row .ws-dot.s-offline b{color:var(--muted)}
html[data-organizations] .ws-org-row .ws-dot.s-error b{color:var(--danger)}
html[data-organizations] .ws-btn,html[data-organizations] .ws-busctl [data-bus-restart]{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font:600 13px/1.4 var(--font);color:var(--strong);text-decoration:none;white-space:nowrap;cursor:pointer;box-shadow:none}
html[data-organizations] .ws-btn:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-restart]:hover:not(:disabled){background:var(--raised)}
html[data-organizations] .ws-btn:disabled,html[data-organizations] .ws-busctl [data-bus-restart]:disabled{color:var(--muted);cursor:not-allowed;opacity:1}
html[data-organizations] .ws-btn svg.i{width:15px;height:15px}
/* The live bus restart is console-restart.js's own button; its icon sits beside it, outside the
part that file repaints. */
html[data-organizations] .ws-busctl{position:relative;display:inline-flex;flex-direction:column;align-items:flex-end}
html[data-organizations] .ws-busctl .bus-restart{display:inline-flex;flex-direction:column;align-items:flex-end;gap:4px}
html[data-organizations] .ws-busctl [data-bus-restart]{padding-left:31px}
html[data-organizations] .ws-busctl .ws-busicon{position:absolute;left:11px;top:7px;width:15px;height:15px;pointer-events:none;color:var(--strong);z-index:1}
html[data-organizations] .ws-busctl [data-bus-restart-receipt]{font-size:12px;color:var(--muted);max-width:28em;text-align:right}
html[data-organizations] .ws-busctl [data-bus-restart-receipt]:empty{display:none}
html[data-organizations] .ws-chips{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
/* Page frame (sample main column). */
html[data-organizations] .content:has(.ws){padding-top:18px;padding-inline:24px}
body:has([data-org-dashboard]) #breadcrumbs,body:has([data-org-agents]) #breadcrumbs{display:none}
html[data-organizations] .ws{font-size:14px;line-height:1.5;color:var(--text);min-width:0}
html[data-organizations] .ws h1{margin:0;font-size:28px;font-weight:800;letter-spacing:-.02em;line-height:1.15;color:var(--strong)}
html[data-organizations] .ws h1:focus,html[data-organizations] .ws h1:focus-visible{outline:none}
html[data-organizations] .ws h2{margin:0;font-size:15px;font-weight:700;color:var(--strong)}
html[data-organizations] .ws code,html[data-organizations] .ws .mono{font-family:var(--mono);font-size:12.5px}
html[data-organizations] .ws .muted,html[data-organizations] .ws .note{color:var(--muted)}
html[data-organizations] .ws .note{font-size:13px}
html[data-organizations] .ws-crumb{display:flex;gap:6px;align-items:center;font-size:12.5px;color:var(--muted);margin-bottom:4px}
html[data-organizations] .ws-crumb a{color:var(--muted);text-decoration:none}
html[data-organizations] .ws-crumb a:hover{color:var(--action);text-decoration:underline}
html[data-organizations] .ws-vh{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-bottom:14px}
html[data-organizations] .ws-vh .sub{flex-basis:100%;margin:0;font-size:13.5px;color:var(--muted)}
html[data-organizations] .ws-right{margin-left:auto;display:flex;flex-wrap:wrap;align-items:flex-start;gap:8px}
html[data-organizations] .ws-sec-h{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 10px;margin:22px 0 8px}
html[data-organizations] .ws-sec-h .note{flex:1}
html[data-organizations] .ws-sec-h a{margin-left:auto;font-size:13px;font-weight:600;color:var(--action);text-decoration:none}
html[data-organizations] .ws-sec-h a:hover{text-decoration:underline}
html[data-organizations] .ws-panel{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:16px;margin:0}
html[data-organizations] .ws-panel.tight{padding:12px 14px}
html[data-organizations] .ws-split{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(0,1fr);gap:16px;align-items:start}
html[data-organizations] .ws-split{margin-top:22px}
html[data-organizations] .ws-split>section>.ws-sec-h:first-child{margin-top:0}
/* Organization dashboard (sample 03). */
html[data-organizations] .ws-orghead{display:flex;flex-wrap:wrap;align-items:center;gap:14px;margin-bottom:14px}
html[data-organizations] .ws-orghead h1{display:flex;align-items:center;gap:10px}
html[data-organizations] .ws-orghead h1 svg.i{width:20px;height:20px;color:var(--muted)}
html[data-organizations] .ws-meta{flex-basis:100%;display:flex;flex-wrap:wrap;align-items:center;gap:4px 14px;margin:-6px 0 0;padding-left:58px;font-size:13px;color:var(--muted)}
html[data-organizations] .ws-meta code{color:var(--text)}
html[data-organizations] .ws-meta .ws-dot{color:var(--muted)}
html[data-organizations] .ws-meta .ws-dot.s-working b{color:var(--success)}
html[data-organizations] .ws-meta .ws-dot.s-offline b,html[data-organizations] .ws-meta .ws-dot.s-error b{color:var(--danger)}
html[data-organizations] .ws-typechips,html[data-organizations] .ws-docrow{margin-top:8px}
html[data-organizations] .ws-typechips .ws-label,html[data-organizations] .ws-docrow .ws-label{min-width:104px}
html[data-organizations] .ws-doc{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:2px 8px;font-size:12.5px;font-weight:600;color:var(--strong);text-decoration:none}
html[data-organizations] .ws-doc:hover{background:var(--raised)}
html[data-organizations] .ws-doc svg.i{width:14px;height:14px;color:var(--muted)}
html[data-organizations] .ws-doc small{font-weight:500;color:var(--muted);font-size:11px}
html[data-organizations] .ws-more{font-size:13px;font-weight:600;margin-left:4px;color:var(--action);text-decoration:none}
html[data-organizations] .ws-tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
html[data-organizations] .ws-tile{background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:grid;gap:6px;align-content:start}
html[data-organizations] .ws-tile.is-empty{grid-column:1/-1;border-style:dashed;background:transparent}
html[data-organizations] .ws-empty-t{font-size:13.5px;font-weight:600;color:var(--strong)}
html[data-organizations] .ws-tbl{overflow-x:auto;border:1px solid var(--line);border-radius:8px;background:var(--surface)}
html[data-organizations] .ws-tbl table{width:100%;border-collapse:collapse;font-size:13px;margin:0}
html[data-organizations] .ws-tbl th{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);background:none;white-space:nowrap}
html[data-organizations] .ws-tbl td{padding:7px 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);vertical-align:middle}
html[data-organizations] .ws-tbl tr:last-child td{border-bottom:0}
html[data-organizations] .ws-tbl .t1{color:var(--strong)}
html[data-organizations] .ws-tbl .t2{display:block;font-size:12px;color:var(--muted)}
html[data-organizations] .ws-tbl td.mono{font-family:var(--mono);font-size:12.5px;white-space:nowrap}
html[data-organizations] .ws-cfg{display:grid;list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)}
html[data-organizations] .ws-cfg li{display:grid;align-items:center;grid-template-columns:18px 132px minmax(0,1fr) auto auto;gap:0 10px;padding:7px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
html[data-organizations] .ws-cfg li:last-child{border-bottom:0}
html[data-organizations] .ws-cfg svg.i{width:16px;height:16px;color:var(--muted)}
html[data-organizations] .ws-cfg .k{font-size:13px;font-weight:600;color:var(--strong)}
html[data-organizations] .ws-cfg .v{font-size:12px;color:var(--muted);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
html[data-organizations] .ws-cfg .src-tag{font:500 11px var(--mono);color:var(--accent);white-space:nowrap}
html[data-organizations] .ws-cfg .go{font-size:12.5px;font-weight:600;color:var(--action);text-decoration:none;min-width:32px;text-align:right}
html[data-organizations] .ws-cfg a.go:hover{text-decoration:underline}
html[data-organizations] .ws-cfg li.danger .k,html[data-organizations] .ws-cfg li.danger svg.i,html[data-organizations] .ws-cfg li.danger .go,html[data-organizations] .ws-cfg li.danger .src-tag{color:var(--danger)}
/* Agents (sample 08). */
html[data-organizations] .ws-lvl{display:inline-block;font:700 10.5px/1.5 var(--font);letter-spacing:.05em;text-transform:uppercase;padding:1px 6px;border-radius:3px;margin-right:6px;vertical-align:1px}
html[data-organizations] .ws-lvl.root{background:var(--strong);color:var(--surface)}
html[data-organizations] .ws-lvl.org{background:color-mix(in srgb,var(--org,var(--action)) 18%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--org,var(--action)) 50%,transparent);color:var(--strong)}
html[data-organizations] .ws-lvl.proj{box-shadow:inset 0 0 0 1px var(--line);color:var(--text)}
html[data-organizations] .ws-span-band{display:flex;flex-wrap:wrap;align-items:center;gap:8px 10px;padding:8px 12px;margin-bottom:14px;border-radius:8px;border:1px solid var(--action);background:color-mix(in srgb,var(--action) 8%,var(--surface));font-size:13px;color:var(--strong)}
html[data-organizations] .ws-span-band .orgs{display:inline-flex;gap:4px}
html[data-organizations] .ws-band-note{flex-basis:100%;font-size:12.5px}
html[data-organizations] .ws-levels{display:grid;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--surface)}
html[data-organizations] .ws-levels>div{padding:10px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);border-right:1px solid color-mix(in srgb,var(--line) 60%,transparent);min-width:0}
html[data-organizations] .ws-levels>.endc{border-right:0}
html[data-organizations] .ws-levels>.last{border-bottom:0}
html[data-organizations] .ws-levels>.lh{background:color-mix(in srgb,var(--raised) 45%,transparent);display:grid;gap:4px;align-content:start}
html[data-organizations] .ws-levels>.lh .ws-lvl{justify-self:start}
html[data-organizations] .ws-levels>.lh small{font-size:11.5px;color:var(--muted);line-height:1.4}
html[data-organizations] .ws-levels>.oh{display:flex;align-items:center;gap:8px;font-weight:700;color:var(--strong);background:color-mix(in srgb,var(--raised) 45%,transparent)}
html[data-organizations] .ws-levels>.span{grid-column:2/-1;display:flex;align-items:center}
html[data-organizations] .ws-levels .mlabel{display:none;align-items:center;gap:6px;font-size:12px;font-weight:700;color:var(--strong);margin-bottom:4px}
html[data-organizations] .ws-agent{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:2px 10px 2px 8px;font-size:12.5px;font-weight:600;background:var(--canvas);margin:2px 4px 2px 0;color:var(--strong)}
html[data-organizations] .ws-agent small{color:var(--muted);font-weight:500;font-size:11px}
html[data-organizations] .ws-agent .ws-dot b{font-size:11px;width:10px}
html[data-organizations] .ws-rules{display:grid;gap:10px;font-size:13px}
html[data-organizations] .ws-rules p{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;margin:0}
html[data-organizations] .ws-rules .ws-lvl{margin-right:0}
html[data-organizations] .ws-scope{margin:-8px 0 14px}
@media (pointer:coarse){html[data-organizations] .ws-nav,html[data-organizations] .s1-section.ws-nav{min-height:40px}}
@media (max-width:759px){
html[data-organizations] .content:has(.ws){padding:14px 16px 24px}
html[data-organizations] .tree{padding:10px 12px 20px}
html[data-organizations] .ws h1{font-size:24px}
html[data-organizations] .ws-split,html[data-organizations] .ws-tiles{grid-template-columns:minmax(0,1fr)}
html[data-organizations] .ws-tile.is-empty{grid-column:auto}
html[data-organizations] .ws-sec-h .note{flex-basis:100%;order:3}
html[data-organizations] .ws-cfg li{grid-template-columns:18px minmax(0,1fr) auto auto}
html[data-organizations] .ws-cfg .v{grid-column:2/-1;grid-row:2}
html[data-organizations] .ws-meta{padding-left:0;margin-top:0}
html[data-organizations] .ws-orghead .ws-right{margin-left:0}
html[data-organizations] .ws-busctl,html[data-organizations] .ws-busctl .bus-restart{align-items:flex-start}
html[data-organizations] .ws-busctl [data-bus-restart-receipt]{text-align:left}
html[data-organizations] .ws-typechips .ws-label,html[data-organizations] .ws-docrow .ws-label{min-width:0}
html[data-organizations] .ws-levels{grid-template-columns:minmax(0,1fr)!important}
html[data-organizations] .ws-levels>div{border-right:0}
html[data-organizations] .ws-levels>.hide-m,html[data-organizations] .ws .hide-m{display:none}
html[data-organizations] .ws-levels>.span{grid-column:auto}
html[data-organizations] .ws-levels>.last.lh{border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
html[data-organizations] .ws-levels .mlabel{display:flex}
}
@@ -0,0 +1,24 @@
// Every read the unified workspace screens make: the organization list, the theme table and the
// per-organization routes under /api/orgs/<id>/. Nothing here writes. A read that fails throws, so
// each caller says "unavailable" for that one part instead of guessing.
(() => {
const read = async path => {
const response = await fetch(path, { cache: 'no-store', redirect: 'manual' });
if (!response.ok || response.type === 'opaqueredirect') throw new Error('Read unavailable');
return response.json();
};
const orgPath = (id, path) => `/api/orgs/${encodeURIComponent(id)}/${path}`;
const organizations = () => read('/api/orgs');
const themes = () => read('/api/themes');
const projects = id => read(orgPath(id, 'chat/projects'));
const threads = id => read(orgPath(id, 'chat/threads'));
const instances = id => read(orgPath(id, 'bus/instances'));
// Held roles with the state the bus derives (working, waiting, idle), and which run holds which thread.
const agents = id => read(orgPath(id, 'bus/agents'));
const claims = id => read(orgPath(id, 'bus/thread.claims'));
const queue = id => read(orgPath(id, 'queue'));
const business = id => read(orgPath(id, 'business'));
const settings = id => read(orgPath(id, 'settings'));
const projectFiles = (id, project) => read(orgPath(id, `project-files?project=${encodeURIComponent(project)}`));
window.MosaicWorkspaceData = Object.freeze({ organizations, themes, projects, threads, instances, agents, claims, queue, business, settings, projectFiles });
})();
+299
View File
@@ -0,0 +1,299 @@
// The unified workspace screens in organization mode: the sidebar's Organizations and Projects
// groups (sample 01), the organization dashboard (sample 03) and the Agents levels (sample 08).
// Every read goes through MosaicWorkspaceData (workspace-data.js). Nothing here writes; the bus
// restart button is console-restart.js's own control, with its gating and trail unchanged.
(() => {
'use strict';
if (!document.documentElement.hasAttribute('data-organizations')) return;
const data = window.MosaicWorkspaceData;
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
const GROUPING = 'mosaic-workspace-project-grouping-v1', ORGS_OPEN = 'mosaic-workspace-orgs-open-v1', PROJECTS_OPEN = 'mosaic-workspace-projects-open-v1';
const load = key => { try { return localStorage.getItem(key); } catch { return null; } };
const save = (key, value) => { try { localStorage.setItem(key, value); } catch {} };
let grouping = load(GROUPING) === 'flat' ? 'flat' : 'org';
let expanded = load(ORGS_OPEN) !== 'closed';
let projectsOpen = load(PROJECTS_OPEN) !== 'closed';
let orgs = null, orgError = false, themes = null, projects = new Map();
// Icons: the Console sprite where it has one, inline paths from the U0 sample where it does not.
const PATHS = {
folder: '<path d="M3.5 6.5a1 1 0 0 1 1-1h4.5l2 2h8.5a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/>',
columns: '<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M12 4.5v15"/>',
chevron: '<path d="m6 9 6 6 6-6"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
restart: '<path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3"/><path d="M19.5 4.5v4h-4"/>',
doc: '<path d="M6.5 3.5h7l4 4v13h-11z"/><path d="M13.5 3.5v4h4M9 12h6M9 15.5h6"/>',
people: '<circle cx="9" cy="8.5" r="3"/><path d="M3.5 19.5a5.5 5.5 0 0 1 11 0M15.5 5.8a3 3 0 0 1 0 5.4M17.5 19.5a5.5 5.5 0 0 0-2.5-4.6"/>',
cube: '<path d="M12 3.5 20 8v8l-8 4.5L4 16V8z"/><path d="m4 8 8 4.5L20 8M12 12.5v8"/>',
};
const icon = (id, cls = '') => `<svg class="i${cls ? ` ${cls}` : ''}"${PATHS[id] ? ' viewBox="0 0 24 24"' : ''} aria-hidden="true" focusable="false">${PATHS[id] || `<use href="/icons.svg#i-${id}"/>`}</svg>`;
const LOGO = '<svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg>';
// An organization's colour is its theme's action colour, per mode, with that mode's onAction for
// the initials. The CSS picks the pair for the page's mode (.ws-o in shell.css).
const HEX = /^#[0-9a-f]{6}$/i;
const themeOf = org => (themes || []).find(t => t.id === (org.theme || 'harbor')) || (themes || []).find(t => t.id === 'harbor');
function colours(org) {
const tokens = themeOf(org)?.tokens || {};
const pick = (mode, name, fallback) => (HEX.test(tokens[mode]?.[name] || '') ? tokens[mode][name] : fallback);
const l = [pick('light', '--action', '#2266bf'), pick('light', '--onAction', '#ffffff')];
const d = [pick('dark', '--action', '#74a6e7'), pick('dark', '--onAction', '#10141a')];
const m = [pick('dim', '--action', d[0]), pick('dim', '--onAction', d[1])];
return `--org-l:${l[0]};--org-on-l:${l[1]};--org-d:${d[0]};--org-on-d:${d[1]};--org-m:${m[0]};--org-on-m:${m[1]}`;
}
// Two words give their first letters (Distinctly Digital, DD); one word gives its first letter and
// its last consonant (Forge, FG; Home, HM), as the U0 sample marks them.
function initials(org) {
const words = String(org.label || org.id || '?').trim().split(/[\s_.-]+/).filter(Boolean);
if (words.length > 1) return (words[0][0] + words[1][0]).toUpperCase();
const word = words[0] || '?', rest = word.slice(1).replace(/[^a-z0-9]/gi, ''), consonants = rest.match(/[bcdfghjklmnpqrstvwxz]/gi);
return (word[0] + (consonants ? consonants[consonants.length - 1] : rest[0] || '')).toUpperCase();
}
const av = (org, size = '') => `<span class="ws-av ws-o${size ? ` ${size}` : ''}" style="${colours(org)}" aria-hidden="true">${esc(initials(org))}</span>`;
const label = org => org.label || org.id;
const state = org => (org.invalid ? ['s-error', '▲', 'invalid'] : org.busHealthy ? ['s-working', '●', 'bus running'] : ['s-offline', '○', 'bus unavailable']);
// A status mark: a glyph plus its word, shown or read aloud only.
const dot = (cls, glyph, word, shown = false) => `<span class="ws-dot ${cls}" title="${esc(word)}"><b aria-hidden="true">${glyph}</b>${shown ? esc(word) : `<span class="ws-sr">${esc(word)}</span>`}</span>`;
const orgHref = id => `#/orgs/${enc(id)}`;
const route = () => location.hash.replace(/^#\/orgs\/[^/]+/, '') || '/';
const ensureThemes = async () => { if (!themes) themes = await data.themes().then(b => (Array.isArray(b?.themes) ? b.themes : [])).catch(() => []); return themes; };
const instancesOf = value => { const v = value?.rows?.[0]?.instances ?? value?.instances; return v && typeof v === 'object' && !Array.isArray(v) ? v : null; };
const running = item => (Array.isArray(item?.running) ? item.running.length : 0);
const rowsOf = v => (Array.isArray(v) ? v : Array.isArray(v?.rows) ? v.rows : null);
// An agent's activity is the state the bus derives for the run holding its role. A live process
// alone says only running, never working or idle.
const ACTIVITY = { working: ['s-working', '●', 'working'], waiting: ['s-waiting', '◐', 'waiting on you'], idle: ['s-idle', '–', 'idle'] };
const activity = (name, item, agents) => {
const on = running(item) > 0;
const held = on && agents ? agents.find(a => a?.role === name && item.running.includes(a.holder_run)) : null;
return ACTIVITY[held?.state] || (on ? ['s-working', '●', 'running'] : ['s-idle', '–', 'not running']);
};
// The inline model control: the change itself happens on Settings, Routing, with its own checks.
const modelCell = (agent, model) => (model ? `${esc(model)} <a class="ws-change" href="#/settings/routing" data-focus-key="${esc(`model:${agent}`)}" aria-label="${esc(`Change the model for ${agent}`)}">Change</a>` : '<span class="muted">not reported</span>');
// Focus survives a repaint: the focused control's data-focus-key is found again afterwards.
const focusKey = root => { const a = document.activeElement; return root && a && root.contains(a) ? a.dataset?.focusKey || null : null; };
const refocus = (root, key) => { if (key) root.querySelector(`[data-focus-key="${CSS.escape(key)}"]`)?.focus({ preventScroll: true }); };
// ---------- sidebar (sample 01) ----------
function projectLinks(org, flat) {
const rows = projects.get(org.id);
if (rows === undefined) return flat ? '' : '<p class="ws-line">Reading projects…</p>';
if (rows === null) return `<p class="ws-line">${flat ? `${esc(label(org))}: projects unavailable` : 'Projects unavailable'}</p>`;
const live = rows.filter(p => p && !p.archived);
if (!live.length) return flat ? '' : '<p class="ws-line">No projects yet</p>';
const hash = location.hash;
return live.map(p => {
const href = `${orgHref(org.id)}/projects/${enc(p.id)}`, current = hash === href || hash.startsWith(`${href}/`);
return `<a class="ws-nav ws-proj${flat ? ' flat' : ''}" href="${esc(href)}" data-focus-key="${esc(`proj:${org.id}:${p.id}`)}"${current ? ' aria-current="page"' : ''}>${flat ? av(org, 'xs') : ''}<span class="t">${esc(p.name || p.id)}</span></a>`;
}).join('');
}
function projectGroups(list) {
if (grouping === 'flat') {
const body = list.map(org => projectLinks(org, true)).join('');
if (body) return `<div class="ws-flat">${body}</div>`;
return list.some(org => projects.get(org.id) === undefined) ? '<p class="ws-line">Reading projects…</p>' : '<p class="ws-line">No projects yet</p>';
}
return list.map(org => `<div class="ws-pgroup ws-o" style="${colours(org)}" data-org="${esc(org.id)}"><div class="ws-pgroup-h">${av(org, 'xs')}<span class="t">${esc(label(org))}</span></div><div class="ws-projs">${projectLinks(org, false)}</div></div>`).join('') || '<p class="ws-line">No projects yet</p>';
}
function paint() {
const root = document.getElementById('workspace-nav'); if (!root) return;
const key = focusKey(root);
if (orgError || !orgs) { root.innerHTML = `<hr class="ws-hr"><p class="ws-line">${orgError ? 'Organizations unavailable' : 'Reading organizations…'}</p>`; return; }
const here = route(), selected = window.MosaicOrg?.id;
const rows = orgs.map(org => {
const [cls, glyph, word] = state(org), mine = org.id === selected, current = mine && here === '/organization';
return `<li data-workspace-org="${esc(org.id)}"><a class="ws-nav ws-org-row${mine && !current ? ' sel' : ''}" href="${esc(orgHref(org.id))}/organization" data-focus-key="${esc(`org:${org.id}`)}"${current ? ' aria-current="page"' : ''}>${av(org)}<span class="t">${esc(label(org))}</span>${dot(cls, glyph, word, cls !== 's-working')}</a></li>`;
}).join('');
const marks = expanded || !orgs.length ? '' : `<div class="ws-marks" aria-hidden="true">${orgs.map(org => av(org, 'xs')).join('')}</div>`;
root.innerHTML = `<hr class="ws-hr"><div class="ws-grp"><div class="ws-grp-h"><button type="button" class="ws-grp-t" data-org-toggle data-focus-key="org-toggle" aria-expanded="${expanded}" aria-controls="ws-org-list">${icon('chevron', 'chev')}<span class="ws-label">Organizations</span><span class="ws-n">${orgs.length}</span></button><a class="ws-add" href="#/settings/organizations?new=1" data-focus-key="org-new" aria-label="New organization" title="New organization">${icon('plus')}</a></div>${marks}<ul class="ws-orgs" id="ws-org-list" data-org-list${expanded ? '' : ' hidden'}>${rows || '<li class="ws-line">No organizations yet</li>'}</ul></div><hr class="ws-hr"><div class="ws-grp"><div class="ws-grp-h"><button type="button" class="ws-grp-t" data-projects-toggle data-focus-key="projects-toggle" aria-expanded="${projectsOpen}" aria-controls="ws-project-list">${icon('chevron', 'chev')}<span class="ws-label">Projects</span></button><span class="ws-seg xs" role="group" aria-label="Project layout"><button type="button" data-group="org" data-focus-key="group-org" aria-pressed="${grouping === 'org'}">By org</button><button type="button" data-group="flat" data-focus-key="group-flat" aria-pressed="${grouping === 'flat'}">Flat</button></span></div><div class="ws-project-list" id="ws-project-list"${projectsOpen ? '' : ' hidden'}>${projectGroups(orgs.filter(org => !org.invalid))}</div></div>`;
refocus(root, key);
}
let reading = 0;
async function refresh(rows) {
const turn = ++reading;
try {
const [list] = await Promise.all([Array.isArray(rows) ? rows : data.organizations(), ensureThemes()]);
if (turn !== reading) return;
orgs = Array.isArray(list) ? list : []; orgError = false;
} catch { if (turn === reading) { orgError = true; paint(); } return; }
paint();
const results = await Promise.all(orgs.filter(org => !org.invalid).map(org => data.projects(org.id).then(b => [org.id, Array.isArray(b?.rows) ? b.rows : null]).catch(() => [org.id, null])));
if (turn !== reading) return;
projects = new Map(results); paint();
}
document.addEventListener('click', e => {
const toggle = e.target.closest?.('[data-org-toggle]');
if (toggle) { expanded = !expanded; save(ORGS_OPEN, expanded ? 'open' : 'closed'); paint(); document.querySelector('[data-org-toggle]')?.focus(); return; }
if (e.target.closest?.('[data-projects-toggle]')) { projectsOpen = !projectsOpen; save(PROJECTS_OPEN, projectsOpen ? 'open' : 'closed'); paint(); document.querySelector('[data-projects-toggle]')?.focus(); return; }
const group = e.target.closest?.('#workspace-nav [data-group]');
if (group) { grouping = group.dataset.group === 'flat' ? 'flat' : 'org'; save(GROUPING, grouping); paint(); document.querySelector(`#workspace-nav [data-group="${grouping}"]`)?.focus(); }
});
window.addEventListener('hashchange', () => { if (orgs) paint(); });
document.addEventListener('mosaic-org-list', e => refresh(e.detail?.rows));
// ---------- organization dashboard (sample 03) ----------
const SAFE = /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]{0,159}$/;
const DOC_PATH = /^(spec\/north-star\.md|docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md)$/;
const docCache = new Map();
// Docs are read at most once a minute per organization: the dashboard repaints every 10 s.
async function docs(id, names) {
const hit = docCache.get(id);
if (hit && Date.now() - hit.at < 60000) return hit.value;
const list = (Array.isArray(names) ? names : []).filter(p => typeof p === 'string' && SAFE.test(p)).slice(0, 6);
const read = await Promise.allSettled(list.map(p => data.projectFiles(id, p).then(body => (Array.isArray(body?.files) ? body.files : []).filter(f => typeof f?.path === 'string' && DOC_PATH.test(f.path)).map(f => ({ project: p, path: f.path })))));
const value = { rows: read.flatMap(r => (r.status === 'fulfilled' ? r.value : [])), failed: read.length > 0 && read.every(r => r.status === 'rejected') };
docCache.set(id, { at: Date.now(), value });
return value;
}
const docName = path => (path === 'spec/north-star.md' ? 'North star' : path.replace(/^.*\//, '').replace(/\.md$/, '').replace(/[-_]+/g, ' ').replace(/^./, c => c.toUpperCase()));
const titleCase = s => String(s || '').replace(/(^|[-_\s])(\w)/g, (_, sep, c) => `${sep ? ' ' : ''}${c.toUpperCase()}`);
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
const secH = (title, note = '', link = '') => `<div class="ws-sec-h"><h2>${title}</h2>${note ? `<span class="note">${note}</span>` : ''}${link}</div>`;
function settingsList(org, ctx) {
const { business, settings, instances, orgCount } = ctx;
const b = business?.business, s = settings;
const unread = 'Could not be read';
const names = instances ? Object.keys(instances) : null;
const models = b && Array.isArray(b.instances) ? [...new Set(b.instances.map(i => i?.vars?.model).filter(m => typeof m === 'string' && m))] : null;
const arbiters = b?.arbiters && typeof b.arbiters === 'object' ? Object.entries(b.arbiters).filter(([, v]) => typeof v === 'string').map(([k, v]) => `${v} (${k})`).join(' · ') : null;
const total = s?.launch && !s.launch.refused ? s.launch.limits?.total : undefined;
const notifier = s?.notifier;
const theme = b?.appearance?.theme || org.theme || 'harbor';
const rows = [
['business', 'Identity', b ? `${label(org)} · ${org.id} · kind not recorded` : unread, 'org file', 'business'],
['flag', 'Appearance', `${titleCase(theme)} theme · ${initials(org)} avatar`, b?.appearance?.theme || org.theme ? 'org file' : 'system', 'appearance'],
['cube', 'System types', 'Not built yet', 'not built', null],
['folder', 'Projects', b ? (Array.isArray(b.projects) && b.projects.length ? plural(b.projects.length, 'project') : 'No projects') : unread, 'org file', '#/projects'],
['people', 'Agents and roles', names ? (names.length ? `${plural(names.length, 'agent')} · ${Object.values(instances).filter(running).length} running` : 'No agents') : unread, 'org file', 'agents'],
['term', 'Models and routing', models ? (models.length ? models.join(' · ') : 'No models set') : unread, 'org file', 'routing'],
['queue', 'Arbiters', b ? arbiters || 'None set' : unread, 'org file', 'business'],
['tasks', 'Task tracker', b ? (b.vars?.['tracker.kind'] ? titleCase(b.vars['tracker.kind']) : 'Not set') : unread, 'org file', 'business'],
['talk', 'Channels', !s ? unread : typeof notifier?.binding === 'string' ? `Notifier ${notifier.binding}` : notifier?.refused ? unread : 'No notifier set', 'system', 'about'],
['ledger', 'Launch limits', !s ? unread : s.launch?.refused ? unread : Number.isFinite(total) ? `${plural(total, 'session')} at once` : 'No session limit', 'org file', 'limits'],
['talk', 'Assistant', 'The assistant agent and its seat names', 'org file', 'assistant'],
['ledger', 'Token use', 'Usage and limits per provider and model', 'system', 'tokens'],
['term', 'Model proxy', 'Accounts, priority and client keys', 'system', 'model-proxy'],
['restart', 'Console', 'Console restart', 'system', 'console'],
['business', 'Organizations', orgCount === null ? unread : plural(orgCount, 'organization'), 'system', 'organizations'],
['flag', 'About', typeof s?.release === 'string' ? `Release ${s.release}` : 'Address, release and notifier', 'system', 'about'],
['people', 'Members and invites', 'Not built yet', 'not built', null],
['restart', 'Bus', `${org.busHealthy ? 'Running' : 'Unavailable'} · restart from this page's header`, 'system', null],
['close', 'Archive or delete', "Archive or delete this organization's file", 'danger', 'organizations'],
];
return `<ul class="ws-cfg one" data-org-settings>${rows.map(([ic, name, value, tag, page]) => {
const href = page === null ? null : page.startsWith('#') ? page : `#/settings/${page}`;
const go = href ? `<a class="go" href="${esc(href)}" data-focus-key="${esc(`set:${name}`)}" aria-label="Open ${esc(name)}">Open</a>` : '<span class="go" aria-hidden="true"></span>';
return `<li${tag === 'danger' ? ' class="danger"' : ''} data-org-setting="${esc(name)}">${icon(ic)}<span class="k">${esc(name)}</span><span class="v" title="${esc(value)}">${esc(value)}</span><span class="src-tag">${esc(tag)}</span>${go}</li>`;
}).join('')}</ul>`;
}
async function dashboard() {
const id = window.MosaicOrg.id;
// Only an organization the membership list names is read; anything else reads nothing more.
const [listRead] = await Promise.all([data.organizations().catch(() => null), ensureThemes()]);
const list = Array.isArray(listRead) ? listRead : null;
const org = list?.find(o => o.id === id && !o.invalid);
if (!org) return `<article class="ws ws-dash" data-org-dashboard data-org-unavailable><div class="ws-vh"><h1 tabindex="-1">Organization unavailable</h1><p class="sub">${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}</p></div></article>`;
const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id)]);
const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null);
const business = value(0), settings = value(1), instances = instancesOf(value(2));
const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5));
const agents = rowsOf(value(6)), claims = rowsOf(value(7));
const docRead = business?.business ? await docs(id, business.business.projects).catch(() => ({ rows: [], failed: true })) : null;
await window.MosaicConsole?.ready;
const name = esc(label(org));
const tracker = business?.business?.vars?.['tracker.kind'];
// Header: the bus restart is console-restart.js's button where the Console allows it, otherwise
// the same control, present and disabled, with the reason.
const restart = window.MosaicConsole?.busButton?.('Restart bus') || '';
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${icon('restart', 'ws-busicon')}${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="Restarting the bus is the owner's trailed action, on a Console started with send and the action trail">${icon('restart')}Restart bus</button></span>`;
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="ws-reload-why" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
const [bcls, bglyph] = state(org);
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="ws-reload-why">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid;
const typechips = '<div class="ws-typechips ws-chips"><span class="ws-label">System types</span><span class="note">No system types yet. They arrive with the system types update.</span></div>';
const docChips = !docRead ? '<span class="note">Docs unavailable</span>' : docRead.failed ? '<span class="note">Docs unavailable</span>' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `<a class="ws-doc" href="#/docs/${enc(d.project)}/${enc(d.path)}" data-focus-key="${esc(`doc:${d.project}:${d.path}`)}" title="${esc(`${d.project}/${d.path}`)}">${icon('doc')}${esc(docName(d.path))}<small>${esc(projectName(d.project))}</small></a>`).join('') : '<span class="note">No docs yet</span>';
const docrow = `<div class="ws-docrow ws-chips"><span class="ws-label">Docs</span>${docChips}<a class="ws-more" href="#/docs" data-focus-key="docs-all">All docs</a></div>`;
const tiles = (title, note, empty) => `${secH(title, note)}<div class="ws-tiles"><div class="ws-tile is-empty"><span class="ws-empty-t">${empty}</span></div></div>`;
const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model;
const live = instances ? Object.entries(instances).filter(([, item]) => running(item)) : null;
// A session's project and task is the thread its live run holds a claim on (thread.claims), not
// any older conversation of the same agent. A failed read says unavailable, never "none".
const sessionRows = (live || []).map(([agent, item]) => {
const held = claims ? claims.filter(c => item.running.includes(c?.holder_run)) : null;
const first = held?.[0], thread = first && threads ? threads.find(t => t?.id === first.thread) : null;
const model = modelOf(agent), count = running(item), [acls, aglyph, aword] = activity(agent, item, agents);
const more = held && held.length > 1 ? ` · ${held.length - 1} more` : '';
const where = held === null ? '<span class="muted">Conversation unavailable</span>'
: !first ? '<span class="muted">No conversation held right now</span>'
: thread ? `${esc(projectName(thread.project))}<span class="t2">${esc(thread.name || thread.id)}${more}</span>`
: threads ? `<span class="muted">Conversation</span><span class="t2">${esc(first.thread)}${more}</span>` : '<span class="muted">Conversation unavailable</span>';
return `<tr><td>${esc(agent)}</td><td>${where}</td><td class="hide-m mono">${modelCell(agent, model)}</td><td>${dot(acls, aglyph, aword, true)}${count > 1 ? ` <span class="muted">${count} sessions</span>` : ''}</td></tr>`;
}).join('');
const sessions = live === null ? '<p class="ws-panel tight note">Sessions unavailable. The bus did not answer the instance read.</p>' : live.length ? `<div class="ws-tbl"><table><thead><tr><th scope="col">Agent</th><th scope="col">Project and task</th><th scope="col" class="hide-m">Model</th><th scope="col">Status</th></tr></thead><tbody>${sessionRows}</tbody></table></div>` : '<p class="ws-panel tight note">No active agent sessions</p>';
const open = queue ? queue.filter(r => r?.state !== 'done').length : null;
const queueNote = queue === null ? 'unavailable' : queue.length ? `${plural(queue.length, 'row')}, ${open} not done` : 'no rows';
return `<article class="ws ws-dash" data-org-dashboard>
<nav class="ws-crumb" aria-label="Breadcrumb"><a href="#/settings/organizations">Organizations</a><span aria-hidden="true">›</span><span>${name}</span></nav>
<div class="ws-orghead ws-o" style="${colours(org)}">${av(org, 'lg')}<h1 tabindex="-1">${name}</h1><div class="ws-right">${bus}${reload}</div>${meta}</div>
${typechips}${docrow}
<div class="ws-split">
<section aria-label="Measures and sessions">
${tiles('KPIs', 'defined in the organization file', 'No KPIs defined in the organization file')}
${tiles('Metrics', 'measured by the bus', 'No metrics recorded yet')}
${secH('Active agent sessions', live ? (live.length ? `${live.length} running` : 'none running') : '', '<a href="#/agents" data-focus-key="agents-all">All agents</a>')}
${sessions}
${secH('Queue', queueNote, '<a href="#/queue" data-org-queue data-focus-key="queue-open">Open queue</a>')}
</section>
<section aria-label="Settings">
${secH('Settings', 'every setting, with where its value comes from')}
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })}
</section>
</div>
</article>`;
}
// ---------- agents levels (sample 08) ----------
async function agentGrid(note = '') {
const [list] = await Promise.all([data.organizations().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. Only organization agents can be read today.${note ? ` ${note}` : ''}</p></div>`;
if (!Array.isArray(list)) return `<section class="ws ws-agents" data-org-agents>${head}<p class="ws-panel tight note">Organizations unavailable.</p></section>`;
const valid = list.filter(org => !org.invalid);
const admitted = valid.some(org => org.id === selected);
const [results, business] = await Promise.all([
Promise.all(valid.map(org => Promise.all([data.instances(org.id).then(instancesOf).catch(() => null), data.agents(org.id).then(rowsOf).catch(() => null)]).then(([inst, agents]) => ({ org, inst, agents })))),
admitted ? data.business(selected).catch(() => null) : null,
]);
const band = `<div class="ws-span-band"><span class="ws-av all md" aria-hidden="true">${LOGO}</span><b>assistant</b><span class="ws-lvl root">root</span>${dot('s-unknown', '?', 'status not reported', true)}<span>Works across</span><span class="orgs">${valid.map(org => av(org)).join('')}</span><span class="muted ws-band-note">The assistant talks with you across organizations. Its status is not reported on this page; set it up under Settings, Assistant.</span></div>`;
const n = Math.max(valid.length, 1);
const cell = (org, inner, cls = '') => `<div class="ws-o${cls}" style="${colours(org)}" data-org="${esc(org.id)}"><span class="mlabel">${av(org, 'xs')}${esc(label(org))}</span>${inner}</div>`;
const pill = (name, item, agents) => { const cos = item?.definition === 'chief-of-staff' || name === 'chief'; const [acls, aglyph, aword] = activity(name, item, agents); return `<span class="ws-agent${cos ? ' cos' : ''}">${dot(acls, aglyph, aword)}${esc(name)}${cos && name !== 'chief' ? ' <small>chief</small>' : ''}</span>`; };
const orgRow = results.map(({ org, inst, agents }, i) => cell(org, inst === null ? '<span class="muted">Agents unavailable</span>' : Object.keys(inst).length ? Object.entries(inst).map(([k, v]) => pill(k, v, agents)).join('') : '<span class="muted">No agents configured</span>', i === results.length - 1 ? ' endc' : '')).join('');
const levels = valid.length ? `<div class="ws-levels" style="grid-template-columns:200px repeat(${n},minmax(0,1fr))">
<div class="lh hide-m"></div>${valid.map((org, i) => `<div class="oh hide-m ws-o${i === valid.length - 1 ? ' endc' : ''}" style="${colours(org)}">${av(org)}${esc(label(org))}</div>`).join('')}
<div class="lh"><span class="ws-lvl root">root</span><small>Your root configuration, shared by every organization.</small></div><div class="span endc"><span class="muted">Root agents are not available yet.</span></div>
<div class="lh"><span class="ws-lvl org">org</span><small>Defined in the organization file. Each organization's agents work only inside it.</small></div>${orgRow}
<div class="lh last"><span class="ws-lvl proj">project</span><small>Defined in one project and available only there.</small></div><div class="span endc last"><span class="muted">Project agents are not available yet.</span></div>
</div>` : '<p class="ws-panel tight note">No organizations yet.</p>';
const mine = results.find(r => r.org.id === selected);
const selOrg = list.find(o => o.id === selected) || { id: selected, label: selected };
const modelOf = inst => business?.business?.instances?.find(i => i?.instance === inst)?.vars?.model;
const table = !mine ? '<p class="ws-panel tight note">The selected organization is not on the list.</p>' : mine.inst === null ? '<p class="ws-panel tight note">Agents unavailable. The bus did not answer the instance read.</p>' : !Object.keys(mine.inst).length ? '<p class="ws-panel tight note">No agents configured</p>' : `<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">Status</th><th scope="col" class="hide-m">Model</th></tr></thead><tbody>${Object.entries(mine.inst).map(([k, v]) => { const [acls, aglyph, aword] = activity(k, v, mine.agents), model = modelOf(k); return `<tr><td class="t1">${esc(k)}${v?.definition && v.definition !== k ? `<span class="t2">${esc(v.definition)}</span>` : ''}</td><td><span class="ws-lvl org ws-o" style="${colours(selOrg)}">org</span></td><td class="hide-m">${esc(label(selOrg))} file</td><td>${dot(acls, aglyph, aword, true)}</td><td class="hide-m mono">${modelCell(k, model)}</td></tr>`; }).join('')}</tbody></table></div>`;
const rules = `<div class="ws-panel tight ws-rules"><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><span class="ws-lvl root">root</span>Not built yet, so nothing can be defined at these levels.</p><p class="muted">An agent's harness, model and thinking are set under Settings, Routing.</p></div>`;
return `<section class="ws ws-agents" data-org-agents>${head}${band}${levels}
<div class="ws-split ws-split-agents"><section>${secH(`Agents in ${esc(label(selOrg))}`, 'organization level', '')}${table}</section><section>${secH('Who changes what')}${rules}</section></div>
</section>`;
}
window.MosaicWorkspace = Object.freeze({ dashboard, agentGrid, icon, refresh: () => refresh() });
refresh();
})();
+1 -1
View File
@@ -58,7 +58,7 @@ const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
+2 -2
View File
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false);
@@ -137,14 +137,15 @@ for (const [status, body] of [[201, { outcome: 'done', step: 'delete' }], [503,
});
}
test('review: deleting a non-current org refreshes the organization switcher', async t => {
// U2 (#1873): the sidebar's Organizations group replaced the organization switcher.
test('review: deleting a non-current org refreshes the sidebar organization list', async t => {
const f = await reviewFixture(t);
await f.confirm('beta');
assert.deepEqual((await registry.listOrgs(f.ctx)).map(e=>e.id), ['alpha']);
await f.b.evaluate(`location.hash='#/orgs/alpha/settings/business'`);
await f.wait(`!!document.querySelector('#org-picker')`);
assert.deepEqual(await f.b.evaluate(`[...document.querySelector('#org-picker').options].map(e=>e.value)`), ['alpha']);
assert.equal(await f.b.evaluate(`document.querySelector('#org-picker').value`), 'alpha');
await f.b.evaluate(`location.hash='#/orgs/alpha/organization'`);
await f.wait(`document.querySelectorAll('[data-workspace-org]').length === 1`);
assert.deepEqual(await f.b.evaluate(`[...document.querySelectorAll('[data-workspace-org]')].map(e=>e.dataset.workspaceOrg)`), ['alpha']);
assert.equal(await f.b.evaluate(`document.querySelector('[data-workspace-org="alpha"] a').getAttribute('aria-current')`), 'page');
assert.deepEqual(await f.b.evaluate(`window.MosaicOrg.list.then(rows => rows.map(row => row.id))`), ['alpha']);
});
@@ -60,15 +60,16 @@ for (const [width, height] of [[1400, 900], [390, 844]]) {
});
}
test('the organization picker offers New organization', { timeout: 60000 }, async t => {
// U2 (#1873): the sidebar's Organizations group replaced the organization picker.
test('the sidebar Organizations group offers New organization', { timeout: 60000 }, async t => {
const { base, b, wait, formFocused } = await consoleWith(t, ['alpha', 'beta']);
await b.viewport(1400, 900);
await b.navigate(`${base}#/orgs/alpha/settings/business`);
const link = `document.querySelector('section[aria-labelledby="org-picker-title"] a[href="#/settings/organizations?new=1"]')`;
await wait(`document.querySelector('#org-picker') && ${link}`, 'picker link');
assert.equal(await b.evaluate(`${link}.textContent`), 'New organization');
const link = `document.querySelector('#workspace-nav a.ws-add[href="#/settings/organizations?new=1"]')`;
await wait(`document.querySelectorAll('[data-workspace-org]').length === 2 && ${link}`, 'sidebar link');
assert.equal(await b.evaluate(`${link}.getAttribute('aria-label')`), 'New organization');
await b.evaluate(`${link}.click()`);
await formFocused('#/orgs/alpha', 'picker');
await formFocused('#/orgs/alpha', 'sidebar');
});
test('the no-organization empty state offers New organization next to its Settings link', { timeout: 60000 }, async t => {
@@ -128,7 +128,7 @@ test('project creation offers repositories from the scoped business read', async
assert.equal(await b.evaluate(`document.querySelector('#chat-repo option[value=work]').value`), 'work');
});
test('Settings lists organizations and switching changes the route and header', async t => {
test('sidebar lists organizations and navigating changes the route and header', async t => {
const organizations = {
list: async () => [{ id: 'alpha', label: 'Alpha', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: false }],
resolve: async id => ['alpha', 'beta'].includes(id) ? {
@@ -141,11 +141,12 @@ test('Settings lists organizations and switching changes the route and header',
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(390, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/settings/business`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#org-picker'))return;await new Promise(r=>setTimeout(r,30))}throw Error('Organization picker did not appear')})()`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelectorAll('[data-workspace-org]').length===2 && document.querySelector('#settings-page-title'))return;await new Promise(r=>setTimeout(r,30))}throw Error('Organizations did not appear')})()`);
assert.match(await b.evaluate(`document.querySelector('header').textContent`), /Alpha/);
assert.equal(await b.evaluate(`document.querySelector('#settings-page-title').textContent`), 'Organization');
assert.deepEqual(await b.evaluate(`[...document.querySelector('#org-picker').options].map(o=>o.value)`), ['alpha', 'beta']);
await b.evaluate(`(()=>{const e=document.querySelector('#org-picker');e.value='beta';e.dispatchEvent(new Event('change',{bubbles:true}))})()`);
assert.equal(await b.evaluate(`!!document.querySelector('#org-picker')`), false);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-workspace-org]')].map(o=>o.dataset.workspaceOrg)`), ['alpha', 'beta']);
await b.evaluate(`document.querySelector('[data-workspace-org=beta] a').click()`);
let switched = false;
for (let i = 0; i < 150 && !switched; i++) {
try { switched = await b.evaluate(`location.hash.startsWith('#/orgs/beta/') && document.querySelector('header')?.textContent.includes('Beta')`); } catch { /* reload replaced the page */ }
@@ -178,12 +179,12 @@ test('a chat draft is kept on Stay and discarded on an organization switch', asy
assert.equal(await b.evaluate(`!!document.querySelector('#chat-session-form, #chat-share-form, #chat-clear')`), false, 'unmapped session and sharing controls are unavailable');
await b.evaluate(`(()=>{const el=document.querySelector('#chat-body');el.value='unsent alpha';el.dispatchEvent(new Event('input',{bubbles:true}))})()`);
await b.evaluate(`location.hash='#/settings/business'`);
await wait('!!document.querySelector("#org-picker")');
await b.evaluate(`(()=>{const el=document.querySelector('#org-picker');el.value='beta';el.dispatchEvent(new Event('change',{bubbles:true}))})()`);
await wait('!!document.querySelector("[data-workspace-org=beta] a")');
await b.evaluate(`document.querySelector('[data-workspace-org=beta] a').click()`);
await wait('!!document.querySelector("dialog[open] [data-stay]")');
await b.evaluate(`document.querySelector('[data-stay]').click()`);
assert.match(await b.evaluate('location.hash'), /^#\/orgs\/alpha\//);
assert.equal(await b.evaluate(`document.querySelector('#org-picker').value`), 'alpha');
assert.equal(await b.evaluate(`window.MosaicOrg.id`), 'alpha');
await b.evaluate(`location.hash='#/chat/threads/${thread}'`);
await wait('!!document.querySelector("#chat-body")');
assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`), 'unsent alpha');
@@ -210,8 +211,16 @@ test('organization switch isolates browser board preferences', async t => {
const base = `http://127.0.0.1:${server.address().port}/`;
await b.navigate(base + '#/orgs/alpha/');
await b.evaluate(`document.querySelector('#show-machine').click()`);
await b.navigate(base + '#/orgs/beta/');
await b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(window.MosaicOrg?.id === 'beta')return;await new Promise(r=>setTimeout(r,30))}throw Error('beta did not load')})()`);
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), true);
// The switch reloads the page. The navigate helper's load checks can be in flight when that
// reload commits, so set the hash in the page and poll one short check at a time.
await b.evaluate(`location.hash = '#/orgs/beta/'`);
let loaded = false;
for (let i = 0; i < 150 && !loaded; i++) {
try { loaded = await b.evaluate(`window.MosaicOrg?.id === 'beta' && !!document.querySelector('#show-machine')`); } catch {}
if (!loaded) await new Promise(r => setTimeout(r, 30));
}
assert.equal(loaded, true, 'beta did not load');
assert.equal(await b.evaluate(`document.querySelector('#show-machine').checked`), false);
});
@@ -0,0 +1,373 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { createOrgAdmin } from '../src/org-admin.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
// Three organizations as sample 01 and 03 show them: two running buses and one stopped.
function workspaceOrgs({ failThreads = false, seen = null } = {}) {
const ORGS = [
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
{ id: 'home', label: 'Home', theme: 'grove', busHealthy: false },
];
const PROJECTS = { ddigital: [{ id: 'spring-launch', name: 'Spring launch' }, { id: 'brand-refresh', name: 'Brand refresh' }], forge: [{ id: 'api-gateway', name: 'API gateway' }], home: [] };
const INSTANCES = { ddigital: { chief: ['chief-of-staff', ['r1']], copywriter: ['copywriter', ['r2']], 'montage-producer': ['producer', ['r3', 'r4']], editor: ['editor', []] }, forge: { chief: ['chief-of-staff', []] } };
const THREADS = { ddigital: [{ id: 't0', name: 'Kickoff notes', project: 'spring-launch', instance: 'chief' }, { id: 't1', name: 'V-112 Launch email, draft 2', project: 'spring-launch', instance: 'copywriter' }, { id: 't2', name: 'V-118 Teaser reel', project: 'spring-launch', instance: 'montage-producer' }, { id: 't3', name: 'Weekly plan', project: 'brand-refresh', instance: 'chief' }], forge: [] };
// The bus's derived state per held role: chief waits on the human, copywriter works, montage-producer
// reports none, so its activity is not known and it shows only as running.
const AGENTS = { ddigital: [{ role: 'chief', holder_run: 'r1', state: 'waiting' }, { role: 'copywriter', holder_run: 'r2', state: 'working' }], forge: [] };
const claim = (thread, holder_run) => ({ thread, holder_run, harness: 'claude', at: '2026-11-09T12:00:00.000Z' });
const CLAIMS = { ddigital: [claim('t3', 'r1'), claim('t1', 'r2'), claim('t2', 'r3')], forge: [] };
const FILES = { 'spring-launch': ['docs/north-star.md', 'docs/launch-plan.md', 'src/index.js'], 'brand-refresh': ['docs/brand-voice-guide.md'], 'api-gateway': [] };
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])) });
const business = id => ({ business: { id, human: 'jason', arbiters: ['chief'], projects: PROJECTS[id].map(p => p.id), vars: id === 'ddigital' ? { 'tracker.kind': 'vikunja' } : {},
instances: Object.keys(INSTANCES[id]).map(n => ({ instance: n, vars: { model: 'sonnet-5.5' } })), appearance: { theme: ORGS.find(o => o.id === id).theme } } });
return {
list: async () => ORGS,
resolve: async id => {
seen?.push(id);
const org = ORGS.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
return {
busHealthy: true,
reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', notifier: { binding: 'discord' }, launch: { limits: { total: 4 } } }),
queue: async () => ({ rows: id === 'ddigital' ? [{ state: 'done' }, { state: 'in-progress' }, { state: 'queued' }] : [] }), busStatus: async () => ({}) },
bus: { instances: async () => instances(id), messages: async () => ({ messages: [], unread: 0 }), agents: async () => AGENTS[id], 'thread.claims': async () => CLAIMS[id], inbox: async () => [], tasks: async () => [] },
chat: { projects: async () => PROJECTS[id], threads: async () => { if (failThreads) throw new Error('threads down'); return THREADS[id]; } },
projectFiles: async ({ project }) => ({ head: 'a'.repeat(40), files: (FILES[project] || []).map(path => ({ path })) }),
};
},
};
}
// No bus or setup work runs in these tests: every restart below is refused before the admin is used.
const refusingAdmin = () => createOrgAdmin({ service: {}, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
const SETTINGS = ['Identity', 'Appearance', 'System types', 'Projects', 'Agents and roles', 'Models and routing', 'Arbiters', 'Task tracker', 'Channels', 'Launch limits', 'Assistant', 'Token use', 'Model proxy', 'Console', 'Organizations', 'About', 'Members and invites', 'Bus', 'Archive or delete'];
const TAGS = ['org file', 'override', 'system', 'root', 'not built', 'danger'];
// Every page the Settings view has for the owner (bus.js settingsPages).
const SETTINGS_PAGES = ['appearance', 'business', 'limits', 'organizations', 'routing', 'agents', 'assistant', 'tokens', 'model-proxy', 'console', 'about'];
test('workspace navigation exposes all organizations and remembers project grouping', async t => {
const organizations = {
list: async () => [{ id:'alpha', label:'Alpha', theme:null, busHealthy:true }, { id:'beta', label:'Beta', theme:'cobalt', busHealthy:false }],
resolve: async id => ({ busHealthy:id === 'alpha', reads:{ business:async()=>({ business:{ id, projects:[] } }) }, bus:{}, chat:{projects:async()=>[],threads:async()=>[]} }),
};
const server = await startServer({port:0, sso:createDevOwner(), organizations, log:()=>{}}); t.after(()=>close(server));
const b=await browser({machine:false}); t.after(()=>b.close());
const wait = waitFor(b);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/`);
await wait('document.querySelectorAll("[data-workspace-org]").length === 2');
assert.match(await b.evaluate('document.querySelector("[data-workspace-org=beta]").textContent'), /Beta.*bus unavailable/is);
// An organization's colour is its theme's action colour; no theme is the harbor default.
assert.equal(await b.evaluate('document.querySelector("[data-workspace-org=alpha] .ws-av").style.getPropertyValue("--org-l")'), '#2266bf');
await b.evaluate('document.querySelector("[data-org-toggle]").click()');
assert.equal(await b.evaluate('document.querySelector("[data-org-list]").hidden'),true);
await b.evaluate('document.querySelector("[data-org-toggle]").click(); document.querySelector("[data-group=flat]").click()');
// The Projects group collapses too, and each group's open state is remembered (sample 01).
await b.evaluate('document.querySelector("[data-projects-toggle]").click()');
assert.deepEqual(await b.evaluate('[document.querySelector("#ws-project-list").hidden, document.querySelector("[data-projects-toggle]").getAttribute("aria-expanded"), document.activeElement?.dataset.focusKey]'), [true, 'false', 'projects-toggle']);
await b.evaluate('location.reload()');
// The reload repaints the sidebar in stages; wait for both lists before reading them.
await wait('document.querySelector("[data-group=flat]")?.getAttribute("aria-pressed") === "true" && !!document.querySelector("#ws-project-list") && !!document.querySelector("[data-org-list]")');
assert.deepEqual(await b.evaluate('[document.querySelector("#ws-project-list").hidden, document.querySelector("[data-org-list]").hidden]'), [true, false]);
await b.evaluate('document.querySelector("[data-projects-toggle]").click()');
assert.equal(await b.evaluate('document.querySelector("#ws-project-list").hidden'), false);
await b.viewport(390,844);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
});
test('sidebar follows sample 01 and Queue opens from the organization dashboard, not the sidebar', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
await wait('document.querySelector("[data-org-dashboard] [data-org-settings]") && document.querySelectorAll("#workspace-nav .ws-proj").length === 3');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections > li > a .t")].map(e => e.textContent)'), ['Dashboard', 'Inbox', 'Tasks', 'Projects', 'Side by side', 'Agents', 'Assistant']);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections-foot a")].map(e => e.textContent.trim())'), ['Settings']);
assert.match(await b.evaluate('document.querySelector("[data-org-toggle]").textContent.replace(/\\s+/g, " ").trim()'), /^Organizations\s*3$/);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-workspace-org]")].map(li => [li.dataset.workspaceOrg, !!li.querySelector(".ws-av"), [...li.querySelector(".ws-dot").classList].find(c => c.startsWith("s-"))])'),
[['ddigital', true, 's-working'], ['forge', true, 's-working'], ['home', true, 's-offline']]);
assert.equal(await b.evaluate('document.querySelector("#workspace-nav [data-group=org]").getAttribute("aria-pressed")'), 'true');
// The four-tile logo stays.
assert.equal(await b.evaluate('document.querySelectorAll(".wordmark .mosaic-logo rect").length'), 4);
// No organization switcher remains anywhere on the page.
assert.equal(await b.evaluate('document.querySelector("#org-picker, select[name=org], [data-org-switch]")'), null);
// Projects has no conversation tree under it; the Projects group links to each project page.
assert.equal(await b.evaluate('document.querySelector("#sections #project-tree, #sections .project-tree-list")'), null);
assert.match(await b.evaluate('document.querySelector("#workspace-nav .ws-proj").getAttribute("href")'), /^#\/orgs\/[a-z0-9-]+\/projects\/[^/]+$/);
// Queue and Docs are not sidebar items.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#console-nav a[href]")].filter(a => /\\/(queue|docs)$/.test(a.getAttribute("href"))).length'), 0);
// Settings stays pinned to the foot of the sidebar.
assert.equal(await b.evaluate('(() => { const nav = document.getElementById("console-nav").getBoundingClientRect(), foot = document.getElementById("sections-foot").getBoundingClientRect(); return foot.bottom >= nav.bottom - 24; })()'), true);
const queue = await b.evaluate('document.querySelector("[data-org-dashboard] [data-org-queue]").getAttribute("href")');
assert.equal(queue, '#/queue');
await b.evaluate('document.querySelector("[data-org-queue]").click()');
await wait('location.hash === "#/orgs/ddigital/queue" && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === "Queue")');
// Views not built yet open and say so.
for (const [route, title] of [['inbox', 'Inbox'], ['tasks', 'Tasks'], ['side-by-side', 'Side by side']]) {
await b.evaluate(`location.hash = "#/orgs/ddigital/${route}"`);
await wait(`document.getElementById("bus-view").innerText.includes("not built yet") && [...document.querySelectorAll("#bus-view h1")].some(h => h.textContent.trim() === ${JSON.stringify(title)})`);
assert.doesNotMatch(await b.evaluate('document.getElementById("bus-view").innerText'), /error|failed/i, route);
}
});
test('organization dashboard renders every section from the per-organization reads', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
await wait('document.querySelectorAll("[data-org-dashboard] .ws-doc").length > 0');
const head = await b.evaluate(`(() => { const h = document.querySelector('[data-org-dashboard] .ws-orghead'); return { avatar: h.querySelector('.ws-av')?.textContent, title: h.querySelector('h1').textContent, meta: h.querySelector('.ws-meta').innerText, colour: h.style.getPropertyValue('--org-l') }; })()`);
assert.equal(head.avatar, 'DD'); assert.equal(head.title, 'Distinctly Digital');
assert.match(head.meta, /Kind not recorded/); assert.match(head.meta, /id ddigital/); assert.match(head.meta, /Tracker: Vikunja/); assert.match(head.meta, /bus running, read \d/);
assert.match(head.colour, /^#[0-9a-f]{6}$/i);
assert.match(await b.evaluate('document.querySelector(".ws-typechips").innerText'), /System types\s+No system types yet/i);
// Docs come from the project files reads: only documents, named by project.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-doc")].map(a => a.innerText.replace(/\\s+/g, " ").trim())'), ['North star Spring launch', 'Launch plan Spring launch', 'Brand voice guide Brand refresh']);
// KPIs and metrics: one honest empty tile each, with no invented number.
const tiles = await b.evaluate('[...document.querySelectorAll(".ws-tiles")].map(t => [t.querySelectorAll(".ws-tile").length, t.querySelector(".ws-tile").classList.contains("is-empty"), t.innerText.replace(/\\s+/g, " ").trim()])');
assert.deepEqual(tiles, [[1, true, 'No KPIs defined in the organization file'], [1, true, 'No metrics recorded yet']]);
// Active agent sessions: the running instances, the thread their live run holds (chief's older
// Kickoff notes is not it), the model with its Change link, and the state the bus reports. With no
// reported state (montage-producer) the row says only running.
const rows = await b.evaluate('[...document.querySelectorAll(".ws-tbl tbody tr")].map(tr => [...tr.cells].map(c => c.innerText.replace(/\\s+/g, " ").trim()))');
assert.deepEqual(rows, [
['chief', 'Brand refresh Weekly plan', 'sonnet-5.5 Change', '◐ waiting on you'],
['copywriter', 'Spring launch V-112 Launch email, draft 2', 'sonnet-5.5 Change', '● working'],
['montage-producer', 'Spring launch V-118 Teaser reel', 'sonnet-5.5 Change', '● running 2 sessions'],
]);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl a.ws-change")].map(a => [a.getAttribute("href"), a.getAttribute("aria-label")])'),
['chief', 'copywriter', 'montage-producer'].map(n => ['#/settings/routing', `Change the model for ${n}`]));
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-tbl thead th")].map(th => th.textContent)'), ['Agent', 'Project and task', 'Model', 'Status']);
assert.match(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /Queue\s+3 rows, 2 not done\s+Open queue/);
// Settings: one row per setting, each with an icon, a value, a source tag and, where a page exists, Open.
const settings = await b.evaluate(`[...document.querySelectorAll('[data-org-settings] > li')].map(li => ({ name: li.dataset.orgSetting, icon: !!li.querySelector('svg'), value: li.querySelector('.v').textContent, tag: li.querySelector('.src-tag').textContent, href: li.querySelector('a.go')?.getAttribute('href') ?? null, danger: li.classList.contains('danger') }))`);
assert.deepEqual(settings.map(s => s.name), SETTINGS);
for (const s of settings) { assert.ok(s.icon, s.name); assert.ok(s.value, s.name); assert.ok(TAGS.includes(s.tag), `${s.name}: ${s.tag}`); }
const by = Object.fromEntries(settings.map(s => [s.name, s]));
assert.deepEqual([by['Members and invites'].tag, by['Members and invites'].href], ['not built', null]);
assert.deepEqual([by['System types'].tag, by['System types'].href], ['not built', null]);
assert.deepEqual([by['Archive or delete'].tag, by['Archive or delete'].danger, by['Archive or delete'].href], ['danger', true, '#/settings/organizations']);
assert.equal(by.Identity.value, 'Distinctly Digital · ddigital · kind not recorded');
assert.equal(by['Task tracker'].value, 'Vikunja');
assert.equal(by['Launch limits'].value, '4 sessions at once');
assert.equal(by['Agents and roles'].value, '4 agents · 3 running');
for (const page of SETTINGS_PAGES) assert.ok(settings.some(s => s.href === `#/settings/${page}`), `no row opens settings/${page}`);
});
test('organization dashboard empty states say so in one short line', async t => {
const organizations = { list: async () => [{ id: 'alpha', label: 'Alpha', busHealthy: true }], resolve: async () => ({ busHealthy: true,
reads: { business: async () => ({ business: { id: 'alpha', projects: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { instances: async () => ({ business: 'alpha', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }), messages: async () => ({ messages: [], unread: 0 }), agents: async () => [] },
chat: { projects: async () => [], threads: async () => [] } }) };
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/organization`);
await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")');
const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
// Section labels are uppercased by CSS, so innerText carries the uppercase form.
for (const line of [/System types\s+No system types yet\. They arrive with the system types update\./i, /Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
assert.match(text, line);
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length);
});
test('organization dashboard presents sections and honest unavailable controls', async t => {
const organizations = { list:async()=>[{id:'alpha',label:'Alpha',busHealthy:false}], resolve:async()=>({busHealthy:false,reads:{business:async()=>({business:{id:'alpha',projects:[]}})},bus:{},chat:{projects:async()=>[],threads:async()=>[]}}) };
const server=await startServer({port:0,sso:createDevOwner(),organizations,log:()=>{}}); t.after(()=>close(server));
const b=await browser({machine:false}); t.after(()=>b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/organization`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-dashboard]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('dashboard missing')})()`);
const text=await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
for(const label of ['Docs','KPIs','Metrics','Active agent sessions','Settings','Queue']) assert.match(text,new RegExp(label,'i'));
assert.match(text,/reload organization file/i);
assert.match(text,/no metrics recorded yet/i);
assert.match(text,/bus unavailable, read \d/);
assert.match(text,/Sessions unavailable/);
assert.equal(await b.evaluate('document.querySelector("[data-org-reload]").disabled'),true);
});
test('each header control is present, reload is disabled, and restart is refused without the owner', async t => {
const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(owner));
const readOnly = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(readOnly));
const signedOut = await startServer({ port: 0, organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(signedOut));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
const header = server => b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`).then(() => wait('document.querySelector("[data-org-dashboard] [data-org-settings]")')).then(() => b.evaluate(`(() => {
const h = document.querySelector('[data-org-dashboard] .ws-orghead'), live = h.querySelector('[data-bus-restart]'), off = h.querySelector('[data-bus-restart-off]'), reload = h.querySelector('[data-org-reload]');
return { live: live ? [live.textContent, live.disabled] : null, off: off ? [off.textContent, off.disabled, !!off.title] : null,
reload: [reload.textContent, reload.disabled, reload.title, document.getElementById(reload.getAttribute('aria-describedby'))?.textContent] };
})()`));
const RELOAD = ['Reload organization file', true, 'Reload arrives with the cross-org update', 'Restart is a trailed human action; reload arrives with the cross-org update.'];
// The owner on a Console started with --send gets console-restart.js's restart button.
assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, reload: RELOAD });
// Without --send, or with nobody signed in, the control is present and disabled, and the route refuses.
const post = 'fetch("/api/orgs/ddigital/restart/bus", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async r => [r.status, (await r.json()).error])';
assert.deepEqual(await header(readOnly), { live: null, off: ['Restart bus', true, true], reload: RELOAD });
assert.deepEqual(await b.evaluate(post), [403, 'read-only']);
// With nobody signed in the organization list is not readable, so the dashboard reads nothing and
// shows no controls at all.
await b.navigate(`http://127.0.0.1:${signedOut.address().port}/#/orgs/ddigital/organization`);
await wait('document.querySelector("[data-org-unavailable]")');
assert.equal(await b.evaluate('document.querySelector("[data-bus-restart], [data-bus-restart-off], [data-org-reload]")'), null);
// With nobody signed in, the server refuses every per-org route before the admin hooks run.
assert.deepEqual(await b.evaluate(post), [404, 'org-unavailable']);
// There is no reload route.
assert.equal(await b.evaluate('fetch("/api/orgs/ddigital/reload", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(r => r.ok)'), false);
});
// Tab from the first sidebar link through the dashboard and return each stop's region and box.
async function tabStops(b, start) {
await b.evaluate(`(${start}).focus()`);
const stops = [];
for (let i = 0; i < 90; i++) {
const at = await b.evaluate(`(() => { const a = document.activeElement, sec = a.closest('[data-org-dashboard] section'); return { i: [...document.querySelectorAll('*')].indexOf(a), region: a.closest('#console-nav') ? 'nav' : sec ? sec.getAttribute('aria-label') : a.closest('[data-org-dashboard]') ? 'head' : 'other', key: a.dataset.focusKey || a.getAttribute('href') || a.textContent.trim() }; })()`);
if (stops.length && at.region === 'other' && stops.at(-1).region !== 'other') break;
stops.push(at);
await b.key('Tab');
}
const boxes = await b.evaluate(`(${JSON.stringify(stops.map(s => s.i))}).map(i => { const r = document.querySelectorAll('*')[i].getBoundingClientRect(); return { top: r.top, bottom: r.bottom, left: r.left }; })`);
return stops.map((s, n) => ({ ...s, ...boxes[n] }));
}
function assertVisualOrder(stops, regions, label) {
for (let n = 1; n < stops.length; n++) {
const a = stops[n - 1], z = stops[n], where = `${label}: ${a.key} then ${z.key}`;
assert.ok(z.i > a.i, `tab order leaves DOM order at ${where}`);
assert.ok(regions.indexOf(z.region) >= regions.indexOf(a.region), `region order at ${where}`);
if (z.region !== a.region) continue;
const sameRow = z.top < a.bottom && z.bottom > a.top;
assert.ok(sameRow ? z.left > a.left : z.top >= a.top, `visual order at ${where}`);
}
}
test('keyboard focus follows visual order and survives a data refresh', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
await wait('document.querySelectorAll("[data-org-dashboard] .ws-doc").length > 0 && document.querySelectorAll("#workspace-nav .ws-proj").length === 3');
const wide = await tabStops(b, 'document.querySelector("#sections a")');
const regions = ['nav', 'head', 'Measures and sessions', 'Settings'];
assert.deepEqual([...new Set(wide.map(s => s.region))].filter(r => r !== 'other'), regions);
assertVisualOrder(wide.filter(s => s.region !== 'other'), regions, '1440');
const keys = wide.map(s => s.key);
for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'queue-open', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`);
// The disabled reload is not a tab stop; the restart button is.
assert.ok(!keys.includes('org-reload')); assert.ok(keys.includes('Restart bus'));
await b.viewport(390, 844);
await wait('document.querySelector("[data-org-dashboard]")');
const narrow = (await tabStops(b, 'document.querySelector("[data-org-dashboard] .ws-crumb a")')).filter(s => s.region !== 'other');
assertVisualOrder(narrow, regions.slice(1), '390');
for (let n = 1; n < narrow.length; n++) assert.ok(narrow[n].top >= narrow[n - 1].top - 1 || narrow[n].region === narrow[n - 1].region, `stacked order at 390: ${narrow[n - 1].key} then ${narrow[n].key}`);
await b.viewport(1440, 900);
// A refresh repaints the dashboard, the sidebar and the restart button; focus stays on the same control.
for (const key of ['set:Projects', 'queue-open', 'doc:spring-launch:docs/launch-plan.md']) {
await wait(`document.querySelector('[data-focus-key="${key}"]')`);
await b.evaluate(`window.oldDashboard = document.querySelector('[data-org-dashboard]'); document.querySelector('[data-focus-key="${key}"]').focus(); document.getElementById('refresh').click()`);
await wait('document.querySelector("[data-org-dashboard]") && document.querySelector("[data-org-dashboard]") !== window.oldDashboard');
assert.equal(await b.evaluate('document.activeElement.dataset.focusKey'), key);
}
await b.evaluate(`window.oldDashboard = document.querySelector('[data-org-dashboard]'); document.querySelector('[data-bus-restart]').focus(); document.getElementById('refresh').click()`);
await wait('document.querySelector("[data-org-dashboard]") !== window.oldDashboard');
assert.equal(await b.evaluate('document.activeElement.hasAttribute("data-bus-restart")'), true);
await b.evaluate(`window.oldRow = document.querySelector('[data-workspace-org=forge]'); document.querySelector('[data-focus-key="org:forge"]').focus(); window.MosaicOrg.refreshList()`);
await wait('document.querySelector("[data-workspace-org=forge]") !== window.oldRow');
assert.equal(await b.evaluate('document.activeElement.dataset.focusKey'), 'org:forge');
});
test('phone drawer opens over the dashboard with no horizontal scroll', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = waitFor(b);
await b.viewport(390, 844);
for (const route of ['organization', 'agents']) {
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/${route}`);
await wait(route === 'organization' ? 'document.querySelector("[data-org-dashboard] [data-org-settings]")' : 'document.querySelector("[data-org-agents] .ws-levels")');
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, route);
}
assert.equal(await b.evaluate('getComputedStyle(document.getElementById("console-nav")).visibility === "hidden" || document.getElementById("console-nav").getBoundingClientRect().right <= 0'), true);
await b.evaluate('document.getElementById("nav-open").click()');
await wait('document.getElementById("nav-open").getAttribute("aria-expanded") === "true"');
await wait('document.querySelectorAll("[data-workspace-org]").length === 3');
const drawer = await b.evaluate('(() => { const r = document.getElementById("console-nav").getBoundingClientRect(); return { left: r.left, right: r.right, sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth }; })()');
assert.ok(drawer.left >= 0 && drawer.right <= 390 && drawer.right > 200, JSON.stringify(drawer));
assert.ok(drawer.sw <= drawer.cw, JSON.stringify(drawer));
// The open drawer itself does not scroll sideways; the only clipped children are screen-reader text.
assert.deepEqual(await b.evaluate('(() => { const nav = document.getElementById("console-nav"); return [nav.scrollWidth <= nav.clientWidth, [...nav.querySelectorAll("*")].filter(e => e.scrollWidth > e.clientWidth + 1 && getComputedStyle(e).overflowX !== "visible" && !e.classList.contains("ws-sr")).map(e => e.className)]; })()'), [true, []]);
});
test('agents view includes organization-scoped instances without inventing root or project agents', async t => {
const organizations={list:async()=>[{id:'alpha',label:'Alpha',busHealthy:true},{id:'beta',label:'Beta',busHealthy:false}],resolve:async id=>({busHealthy:id==='alpha',reads:{business:async()=>({business:{id}})},bus:{messages:async()=>({messages:[],unread:0}),agents:async()=>[],instances:async()=>({business:id,revision:'a'.repeat(64),launch:true,noHire:[],retired:[],instances:{chief:{definition:'chief-of-staff',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1'],alive:[]}},definitions:{'chief-of-staff':{services:[],accepted:true}}})},chat:{threads:async()=>[],projects:async()=>[]}})};
const server=await startServer({port:0,sso:createDevOwner(),organizations,log:()=>{}});t.after(()=>close(server));
const b=await browser({machine:false});t.after(()=>b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/alpha/agents`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-agents]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('organization agent grid missing')})()`);
const text=await b.evaluate('document.querySelector("[data-org-agents]").innerText');
assert.match(text,/Alpha/); assert.match(text,/chief/); assert.match(text,/Beta/);
assert.match(text,/Root agents are not available yet/); assert.match(text,/Project agents are not available yet/);
});
test('an unavailable selected bus does not hide other organizations on Agents', async t => {
const organizations={list:async()=>[{id:'alpha',label:'Alpha',busHealthy:true},{id:'beta',label:'Beta',busHealthy:false}],resolve:async id=>({busHealthy:id==='alpha',reads:{business:async()=>({business:{id}})},bus:id==='alpha'?{agents:async()=>[],messages:async()=>({messages:[],unread:0}),instances:async()=>({business:id,revision:'a'.repeat(64),launch:true,noHire:[],retired:[],instances:{chief:{definition:'chief-of-staff',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1'],alive:[]}},definitions:{'chief-of-staff':{services:[],accepted:true}}})}:{},chat:{threads:async()=>[],projects:async()=>[]}})};
const server=await startServer({port:0,sso:createDevOwner(),organizations,log:()=>{}});t.after(()=>close(server));
const b=await browser({machine:false});t.after(()=>b.close());
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/beta/agents`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('[data-org-agents]'))return;await new Promise(r=>setTimeout(r,30))}throw Error('organization agent grid missing')})()`);
const text=await b.evaluate('document.querySelector("[data-org-agents]").innerText');
assert.match(text,/Alpha/); assert.match(text,/chief/); assert.match(text,/Beta/); assert.match(text,/Agents unavailable/);
});
test('a failed conversation read says unavailable, never that none exists', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs({ failThreads: true }), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
await waitFor(b)('document.querySelectorAll("[data-org-dashboard] .ws-tbl tbody tr").length === 3');
const where = await b.evaluate('[...document.querySelectorAll(".ws-tbl tbody tr")].map(tr => tr.cells[1].innerText.trim())');
assert.deepEqual(where, ['Conversation unavailable', 'Conversation unavailable', 'Conversation unavailable']);
assert.doesNotMatch(await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'), /No conversation/);
});
test('an organization missing from the list, or an unreadable list, is never read', async t => {
const seen = [];
const orgs = workspaceOrgs({ seen });
for (const [label, organizations, message] of [['not listed', orgs, /not on your list/], ['list fails', { ...orgs, list: async () => { throw new Error('list down'); } }, /could not be read/]]) {
seen.length = 0;
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const target = label === 'not listed' ? 'denied' : 'ddigital';
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/${target}/organization`);
await waitFor(b)('document.querySelector("[data-org-unavailable]")');
const view = await b.evaluate('document.querySelector("[data-org-unavailable]").innerText');
assert.match(view, /Organization unavailable/, label); assert.match(view, message, label);
// None of the dashboard's per-organization reads went out for the target.
const asked = await b.evaluate(`performance.getEntriesByType('resource').map(e => new URL(e.name).pathname).filter(p => p.startsWith('/api/orgs/${target}/'))`);
// The app shell's own boot (app.js, unchanged here) reads /business once; the dashboard adds none.
const dashboardReads = /\/(settings|queue|chat\/threads|chat\/projects|bus\/instances|bus\/agents|bus\/thread\.claims)$/;
assert.deepEqual(asked.filter(p => dashboardReads.test(p)), [], `${label}: ${asked.join(' ')}`);
assert.ok(asked.filter(p => p.endsWith('/business')).length <= 1, `${label}: ${asked.join(' ')}`);
}
});
test('agents grid shows the reported state and a model Change link', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/agents`);
await waitFor(b)('document.querySelectorAll("[data-org-agents] .ws-tbl tbody tr").length === 4');
const rows = await b.evaluate('[...document.querySelectorAll("[data-org-agents] .ws-tbl tbody tr")].map(tr => [tr.cells[0].innerText.split("\\n")[0].trim(), tr.cells[3].innerText.replace(/\\s+/g, " ").trim(), tr.cells[4].innerText.replace(/\\s+/g, " ").trim()])');
assert.deepEqual(rows, [['chief', '◐ waiting on you', 'sonnet-5.5 Change'], ['copywriter', '● working', 'sonnet-5.5 Change'], ['montage-producer', '● running', 'sonnet-5.5 Change'], ['editor', '– not running', 'sonnet-5.5 Change']]);
const pills = await b.evaluate('[...document.querySelectorAll("[data-org=ddigital] .ws-agent")].map(p => p.querySelector(".ws-dot").title)');
assert.deepEqual(pills, ['waiting on you', 'working', 'running', 'not running']);
// Keyboard reaches the Change links.
const label = await b.evaluate('(() => { const a = document.querySelector("[data-org-agents] a.ws-change"); a.focus(); return document.activeElement === a && a.getAttribute("aria-label"); })()');
assert.equal(label, 'Change the model for chief');
});
+1 -1
View File
@@ -421,7 +421,7 @@ plugin_checks() {
# Row 181: the tables a bus writer fills without an event in the same transaction, in schema
# order. A restore drops their rows after STAMP as it drops events, so the guard counts them too.
# BUILD.md for row 181 names each writer; the T46 guard classifies every table in the schema.
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects project_archives threads sessions session_defaults thread_opens thread_shares instance_names usage_records session_ops message_cancels request_events request_effects request_snapshots request_snapshot_events request_project_events"
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects project_archives threads sessions session_defaults thread_opens thread_shares instance_names usage_records session_ops message_cancels request_events request_effects request_snapshots request_snapshot_events request_project_events project_system_types"
STORE_JS='
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");