From e6ab8bc1fd6cf8f021487518290aeffa979c7a54 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 12 Nov 2026 20:29:35 -0600 Subject: [PATCH] feat(bus,plugins,organization,cli,webui): SP9 remove system types outright, bus schema 3v-3w drops project_system_types (#1873) Jason's 2026-11-12 ruling: the only deployed system is the dev environment on dragon-lin, so the system-type plug-in kind goes with no retired kind, fence, conversion or new refusal code. - plugins: system-type leaves KINDS; system-types.mjs and the five modules are deleted. An org file naming the kind refuses unknown-kind. - organization: the plugins.system-type check, systemTypesFence and the system-types.console right are removed. - bus: system-types.console, project.system-types[.set], the system-type half of the tester check, the runtime reads and system-type-not-enabled are removed. Step 3v-3w drops project_system_types, its index, its four guards and its rows; SCHEMA_VERSION is 3w (32 tables, 126 triggers, 5 views). - cli: mosaic org system-types and mosaic project system-types[-set] are removed; stack-release COUNTED and the budget supervisor's closed list move to 3w. - channel: the project.system-types verbs are removed. - webui: the System types page, route, dashboard row, chips and project selection are removed. - docs: REQ-SYS-1/2 now describe starter packs; README system-type text and the requirements titles follow. --- docs/prd/mosaic-stack.md | 20 +- packages/bus/schema.sql | 16 -- packages/bus/src/broker.mjs | 45 +--- packages/bus/src/migrations/3v-3w.sql | 11 + packages/bus/src/refusals.mjs | 2 +- packages/bus/src/runtime.mjs | 3 - packages/bus/src/store.mjs | 10 +- packages/bus/tests/account-migration.test.mjs | 11 +- packages/bus/tests/chief-of-staff.test.mjs | 4 +- packages/bus/tests/comment-migration.test.mjs | 12 +- packages/bus/tests/console-decide.test.mjs | 2 +- packages/bus/tests/gitea-migration.test.mjs | 13 +- packages/bus/tests/grant-migration.test.mjs | 13 +- packages/bus/tests/name-migration.test.mjs | 2 +- packages/bus/tests/prd-activation.test.mjs | 5 +- packages/bus/tests/project-archive.test.mjs | 10 +- packages/bus/tests/request-console.test.mjs | 11 +- .../bus/tests/request-continuation.test.mjs | 5 +- packages/bus/tests/request-intake.test.mjs | 10 +- packages/bus/tests/schema-3w.test.mjs | 73 +++++ packages/bus/tests/schema.test.mjs | 5 +- packages/bus/tests/session-clear.test.mjs | 8 +- packages/bus/tests/sessions.test.mjs | 6 +- packages/bus/tests/spec-migration.test.mjs | 2 +- packages/bus/tests/system-type.test.mjs | 109 -------- packages/bus/tests/task-store.test.mjs | 5 +- packages/bus/tests/thread-migration.test.mjs | 6 +- packages/bus/tests/thread-shares.test.mjs | 8 +- packages/bus/tests/usage-migration.test.mjs | 2 +- packages/cli/README.md | 9 - packages/cli/live/budget-supervisor.mjs | 3 +- packages/cli/live/budget-supervisor.test.mjs | 2 +- packages/cli/src/cli.mjs | 2 +- packages/cli/src/org.mjs | 10 +- packages/cli/src/project.mjs | 17 +- packages/cli/tests/org.test.mjs | 1 - packages/cli/tests/stack-release.test.mjs | 8 +- packages/cli/tests/system-type.test.mjs | 53 ---- packages/organization/README.md | 5 - packages/organization/src/org-rights.mjs | 1 - .../organization/src/organization-config.mjs | 10 - packages/organization/src/organization.mjs | 9 - .../organization/tests/org-rights.test.mjs | 1 - .../tests/system-type-removed.test.mjs | 19 ++ .../organization/tests/system-type.test.mjs | 29 -- packages/plugins/README.md | 9 - packages/plugins/channel/cli/index.mjs | 2 - packages/plugins/src/kinds.mjs | 4 - packages/plugins/src/system-types.mjs | 19 -- .../system-type/business-management/index.mjs | 5 - packages/plugins/system-type/coding/index.mjs | 5 - .../system-type/creative-writing/index.mjs | 5 - .../plugins/system-type/marketing/index.mjs | 5 - .../plugins/system-type/montage/index.mjs | 5 - packages/plugins/tests/kinds.test.mjs | 2 +- packages/plugins/tests/loader.test.mjs | 10 +- packages/plugins/tests/pinned.test.mjs | 16 -- packages/plugins/tests/system-type.test.mjs | 19 -- packages/tasks/requirements.json | 4 +- packages/webui/src/cli.mjs | 4 - packages/webui/src/public/bus.js | 2 +- packages/webui/src/public/chat.js | 5 +- packages/webui/src/public/index.html | 2 +- packages/webui/src/public/shell.css | 6 +- packages/webui/src/public/system-types.css | 33 --- packages/webui/src/public/system-types.js | 195 -------------- packages/webui/src/public/workspace.js | 21 +- packages/webui/src/serve.mjs | 32 +-- packages/webui/src/system-types-http.mjs | 140 ---------- .../webui/tests/autonomy-browser.test.mjs | 1 - packages/webui/tests/browser.test.mjs | 4 +- .../webui/tests/helpers/system-types-fake.mjs | 42 --- .../webui/tests/org-archive-browser.test.mjs | 10 +- .../webui/tests/org-refresh-browser.test.mjs | 18 +- .../tests/packs-workflows-browser.test.mjs | 1 - .../webui/tests/side-by-side-browser.test.mjs | 8 +- .../webui/tests/system-types-browser.test.mjs | 244 ----------------- .../webui/tests/system-types-http.test.mjs | 250 ------------------ .../tests/unified-workspace-browser.test.mjs | 22 +- .../tests/w15-integration-browser.test.mjs | 1 - scripts/stack-release.sh | 2 +- 81 files changed, 264 insertions(+), 1497 deletions(-) create mode 100644 packages/bus/src/migrations/3v-3w.sql create mode 100644 packages/bus/tests/schema-3w.test.mjs delete mode 100644 packages/bus/tests/system-type.test.mjs delete mode 100644 packages/cli/tests/system-type.test.mjs create mode 100644 packages/organization/tests/system-type-removed.test.mjs delete mode 100644 packages/organization/tests/system-type.test.mjs delete mode 100644 packages/plugins/src/system-types.mjs delete mode 100644 packages/plugins/system-type/business-management/index.mjs delete mode 100644 packages/plugins/system-type/coding/index.mjs delete mode 100644 packages/plugins/system-type/creative-writing/index.mjs delete mode 100644 packages/plugins/system-type/marketing/index.mjs delete mode 100644 packages/plugins/system-type/montage/index.mjs delete mode 100644 packages/plugins/tests/system-type.test.mjs delete mode 100644 packages/webui/src/public/system-types.css delete mode 100644 packages/webui/src/public/system-types.js delete mode 100644 packages/webui/src/system-types-http.mjs delete mode 100644 packages/webui/tests/helpers/system-types-fake.mjs delete mode 100644 packages/webui/tests/system-types-browser.test.mjs delete mode 100644 packages/webui/tests/system-types-http.test.mjs diff --git a/docs/prd/mosaic-stack.md b/docs/prd/mosaic-stack.md index 16f53c01f..395a0f0f8 100644 --- a/docs/prd/mosaic-stack.md +++ b/docs/prd/mosaic-stack.md @@ -1240,16 +1240,22 @@ Added in version 1.3 (Q155). changes it, not in one rewrite (Q155). Until it converts, Technical considerations lists where it is hard-coded. -### System types +### Starter packs Added in version 1.20. -- **REQ-SYS-1.** System types are a plug-in kind. Its modules include - coding, creative writing, marketing, Mosaic Montage (the OpenMontage - fork) and business management. An organization enables a list of them. -- **REQ-SYS-2.** A project may use any system type its organization - enables, and none is required. An agent working in a project gets the - toolsets of the system types that project uses. +- **REQ-SYS-1.** Starter packs are a plug-in kind. Mosaic Stack ships five: + coding, creative writing, marketing, Mosaic Montage and business + management. A pack is a starting configuration of role definitions, + proposed hires, a board style and workflow templates. An organization or + project copies a pack in once and then owns and changes what it copied. A + user can save an organization or project as their own template and apply it + the same way. +- **REQ-SYS-2.** Applying a pack grants nothing by itself. Every agent it + proposes is hired through the hire decision (REQ-AGENT-3), bounded by the + chief's own role, with any line above it approved by the owner. A pack never + sets credentials, rights, the autonomy setting or plug-in choices. Removing + a pack removes nothing it copied. ### Meta-harness diff --git a/packages/bus/schema.sql b/packages/bus/schema.sql index a48df67fa..deff6a9e0 100644 --- a/packages/bus/schema.sql +++ b/packages/bus/schema.sql @@ -639,22 +639,6 @@ 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 diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 0a8d48cb6..b0f489190 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -178,7 +178,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(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', +export const CONSOLE_VERBS = Object.freeze(['project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.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. @@ -6177,28 +6177,6 @@ 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'); @@ -6394,7 +6372,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') || Object.hasOwn(args.plugins, 'system-type'))) fail('human-required'); + if (s.tester && object(args.plugins) && Object.hasOwn(args.plugins, 'model-provider')) 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). @@ -8715,25 +8693,6 @@ 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 diff --git a/packages/bus/src/migrations/3v-3w.sql b/packages/bus/src/migrations/3v-3w.sql new file mode 100644 index 000000000..105c4661d --- /dev/null +++ b/packages/bus/src/migrations/3v-3w.sql @@ -0,0 +1,11 @@ +-- #1873 SP9: the system-type kind is removed outright (Jason, 2026-11-12: "The only deployed +-- system is the dev environment on dragon-lin. It can be wiped and reconfigured. No reason to hold +-- on to legacy for this change."). This step drops what 3t-3u created: the four guards, the index +-- and the table, with its rows. It is the one deletion of append-only rows in that go, and the +-- ruling is its authority. SQLite removes the table's sqlite_sequence row with the table. +DROP TRIGGER project_system_types_no_update; +DROP TRIGGER project_system_types_no_delete; +DROP TRIGGER project_system_types_no_replace; +DROP TRIGGER project_system_types_business; +DROP INDEX project_system_types_project; +DROP TABLE project_system_types; diff --git a/packages/bus/src/refusals.mjs b/packages/bus/src/refusals.mjs index 7574799df..b0f372e30 100644 --- a/packages/bus/src/refusals.mjs +++ b/packages/bus/src/refusals.mjs @@ -183,7 +183,7 @@ group( "Read my_rails and use the applicable decision path; ask the owner to inspect grants if no supported path exists." ); group( - "not-configured project-already-archived project-archived project-exists project-not-archived project-not-configured project-not-found project-record-invalid project-remote-configured project-remote-invalid project-remote-library project-remote-protected project-remote-unknown project-spec-human-only system-type-not-enabled tracker-not-store usage-not-configured", + "not-configured project-already-archived project-archived project-exists project-not-archived project-not-configured project-not-found project-record-invalid project-remote-configured project-remote-invalid project-remote-library project-remote-protected project-remote-unknown project-spec-human-only tracker-not-store usage-not-configured", "R3/role scope: configured project and service state", "Read the supported project/service status and ask its owner to repair missing configuration or state before retrying." ); diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 7224e7ff9..8545b2197 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -27,7 +27,6 @@ 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'; @@ -1200,8 +1199,6 @@ export async function startBroker({ if (preview === undefined && typeof digest === 'string') return templateSave({ ...base, document, name, project, digest, assertClean }); throw new TemplateError('invalid-request', 'template.save takes preview: true or the preview digest'); } - if (verb === 'system-types.installed') return installedSystemTypes({dataRoot:instanceRoot ?? dataRoot}); - 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. diff --git a/packages/bus/src/store.mjs b/packages/bus/src/store.mjs index 3b5a1dab8..0d704c3b6 100644 --- a/packages/bus/src/store.mjs +++ b/packages/bus/src/store.mjs @@ -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 = 'e5868996851ee60a3c6d2798a17b9da446ba666c859d84d1b575ca5104ae61f8'; -export const SCHEMA_VERSION = '3v'; +export const SCHEMA_SHA256 = '1c305d842619360c14c8388cd43cb9de81afbe46ff0662fd5de9851be9ff3ea0'; +export const SCHEMA_VERSION = '3w'; // 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'; @@ -120,6 +120,11 @@ const migrations = [{ // 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'), +}, { + // SP9 (#1873, Jason's ruling 2026-11-12): drops the table 3t-3u created, its index and its four + // guards, with its rows. + from: '3v', to: '3w', + sql: readFileSync(new URL('./migrations/3v-3w.sql', import.meta.url), 'utf8'), }]; const timedTables = [ 'events', @@ -146,7 +151,6 @@ const timedTables = [ 'request_snapshot_events', 'request_project_events', 'project_archives', - 'project_system_types', 'store_tasks', ]; const hex = (value) => createHash('sha256').update(value).digest('hex'); diff --git a/packages/bus/tests/account-migration.test.mjs b/packages/bus/tests/account-migration.test.mjs index 61c912c4a..7434438a7 100644 --- a/packages/bus/tests/account-migration.test.mjs +++ b/packages/bus/tests/account-migration.test.mjs @@ -19,7 +19,7 @@ const here = (p) => new URL(p, import.meta.url); const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens']; // Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it. // Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them. -const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', '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); +const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', '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 })); @@ -96,6 +96,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) { 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')); + copyFileSync(here('../src/migrations/3v-3w.sql'), join(moduleRoot, 'src/migrations/3v-3w.sql')); return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; } @@ -103,7 +104,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), '3v'); + assert.equal(version(path), '3w'); // 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]); @@ -153,7 +154,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), '3v'); + assert.equal(version(path), '3w'); assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows); }); @@ -165,14 +166,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 = '3v';", "export const SCHEMA_VERSION = '3h';") + .replace("export const SCHEMA_VERSION = '3w';", "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), '3v'); + assert.equal(version(path), '3w'); assert.deepEqual(master(path), was); assert.deepEqual(snapshot(path, after.columns).rows, after.rows); }); diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs index c194b6e02..7d5b604e4 100644 --- a/packages/bus/tests/chief-of-staff.test.mjs +++ b/packages/bus/tests/chief-of-staff.test.mjs @@ -615,7 +615,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 = '3v';", "export const SCHEMA_VERSION = '3c';") + .replace("export const SCHEMA_VERSION = '3w';", "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'); @@ -639,7 +639,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, '3v'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w'); 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 }); diff --git a/packages/bus/tests/comment-migration.test.mjs b/packages/bus/tests/comment-migration.test.mjs index 7b2e84a92..5eeebae91 100644 --- a/packages/bus/tests/comment-migration.test.mjs +++ b/packages/bus/tests/comment-migration.test.mjs @@ -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, '3v'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w'); 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 = '3v';", "export const SCHEMA_VERSION = '3w';") - .replace('}];\nconst timedTables', `}, { from: '3v', to: '3w', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`); + .replace("export const SCHEMA_VERSION = '3w';", "export const SCHEMA_VERSION = '3x';") + .replace('}];\nconst timedTables', `}, { from: '3w', to: '3x', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`); writeFileSync(join(moduleRoot, 'src/store.mjs'), source); writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL); - for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql', '3u-3v.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', '3v-3w.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, '3w'); + assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3x'); 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 3v ready/); + assert.match(migrated.stdout, /bus schema 3w ready/); new Store(root).close(); const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' }); assert.equal(usage.status, 2); diff --git a/packages/bus/tests/console-decide.test.mjs b/packages/bus/tests/console-decide.test.mjs index 3496f4e78..81d22a8ec 100644 --- a/packages/bus/tests/console-decide.test.mjs +++ b/packages/bus/tests/console-decide.test.mjs @@ -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], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', + assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.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', // Starter packs SP4 (#1873): the owner's pack list and preview, and the three pack writes. 'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console', diff --git a/packages/bus/tests/gitea-migration.test.mjs b/packages/bus/tests/gitea-migration.test.mjs index 6944e6f85..34f267105 100644 --- a/packages/bus/tests/gitea-migration.test.mjs +++ b/packages/bus/tests/gitea-migration.test.mjs @@ -36,11 +36,11 @@ function source3j(t) { const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens']; // Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it. // Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them. -const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', '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); +const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); function rows(path) { const db = new DatabaseSync(path, { readOnly: true }); try { - return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").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','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(); } @@ -86,6 +86,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) { 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')); + copyFileSync(here('../src/migrations/3v-3w.sql'), join(moduleRoot, 'src/migrations/3v-3w.sql')); return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; } function fresh(t) { @@ -107,7 +108,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), '3v'); + assert.equal(version(path), '3w'); 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))); @@ -133,7 +134,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), '3v'); + assert.equal(version(path), '3w'); }); test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => { @@ -144,14 +145,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 = '3v';", "export const SCHEMA_VERSION = '3j';") + .replace("export const SCHEMA_VERSION = '3w';", "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), '3v'); + assert.equal(version(path), '3w'); assert.deepEqual(master(path), was); assert.deepEqual(rows(path), after); }); diff --git a/packages/bus/tests/grant-migration.test.mjs b/packages/bus/tests/grant-migration.test.mjs index 8958cb9d0..3d7bc65b8 100644 --- a/packages/bus/tests/grant-migration.test.mjs +++ b/packages/bus/tests/grant-migration.test.mjs @@ -22,7 +22,7 @@ const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens']; // Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it. // Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them. -const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', '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); +const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); function source3i(t) { const root = mkdtempSync(join(tmpdir(), 'grant-migrate-')); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -42,7 +42,7 @@ function source3i(t) { function rows(path) { const db = new DatabaseSync(path, { readOnly: true }); try { - return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").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','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(); } @@ -89,6 +89,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) { 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')); + copyFileSync(here('../src/migrations/3v-3w.sql'), join(moduleRoot, 'src/migrations/3v-3w.sql')); return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; } function fresh(t) { @@ -112,7 +113,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), '3v'); + assert.equal(version(path), '3w'); 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))); @@ -137,7 +138,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), '3v'); + assert.equal(version(path), '3w'); }); test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => { @@ -148,14 +149,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 = '3v';", "export const SCHEMA_VERSION = '3i';") + .replace("export const SCHEMA_VERSION = '3w';", "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), '3v'); + assert.equal(version(path), '3w'); assert.deepEqual(master(path), was); assert.deepEqual(rows(path), after); }); diff --git a/packages/bus/tests/name-migration.test.mjs b/packages/bus/tests/name-migration.test.mjs index c66e6f897..08d838396 100644 --- a/packages/bus/tests/name-migration.test.mjs +++ b/packages/bus/tests/name-migration.test.mjs @@ -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, '3v'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w'); 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) }); diff --git a/packages/bus/tests/prd-activation.test.mjs b/packages/bus/tests/prd-activation.test.mjs index a1324961d..624f9a8e7 100644 --- a/packages/bus/tests/prd-activation.test.mjs +++ b/packages/bus/tests/prd-activation.test.mjs @@ -198,8 +198,7 @@ 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. + // 3u-3v (#1873, U4a) adds store_tasks; 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); @@ -216,7 +215,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, '3v'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w'); 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/); diff --git a/packages/bus/tests/project-archive.test.mjs b/packages/bus/tests/project-archive.test.mjs index ac5653aca..764b4460f 100644 --- a/packages/bus/tests/project-archive.test.mjs +++ b/packages/bus/tests/project-archive.test.mjs @@ -143,8 +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'); -// 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'), ''); +// 3u-3v (#1873, U4a) adds store_tasks after it; a 3s store has neither table. +const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8'), ''); function source3s(t) { const root = mkdtempSync(join(tmpdir(), 'project-archive-3s-')); @@ -179,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), '3v'); + assert.equal(version(path), '3w'); 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'), []); @@ -212,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 = '3v';", "export const SCHEMA_VERSION = '3s';") + .replace("export const SCHEMA_VERSION = '3w';", "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); @@ -222,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), '3v'); + assert.equal(version(path), '3w'); 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); diff --git a/packages/bus/tests/request-console.test.mjs b/packages/bus/tests/request-console.test.mjs index a185712d3..20342f00c 100644 --- a/packages/bus/tests/request-console.test.mjs +++ b/packages/bus/tests/request-console.test.mjs @@ -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], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', + assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.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', // Starter packs SP4 (#1873): the owner's pack list and preview, and the three pack writes. 'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console', @@ -306,7 +306,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, '3v'); + assert.equal(SCHEMA_VERSION, '3w'); 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'; @@ -314,11 +314,10 @@ 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'); + // 3u-3v (#1873, U4a) adds store_tasks the same way. 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(step3u, '').replace(step3v, '').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(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'); @@ -335,7 +334,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 3v ready\n'); + assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3w 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); diff --git a/packages/bus/tests/request-continuation.test.mjs b/packages/bus/tests/request-continuation.test.mjs index 347ad903c..338cc991f 100644 --- a/packages/bus/tests/request-continuation.test.mjs +++ b/packages/bus/tests/request-continuation.test.mjs @@ -230,8 +230,7 @@ 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. + // 3u-3v (#1873, U4a) adds store_tasks; 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'); @@ -253,7 +252,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, '3v'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w'); 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/); diff --git a/packages/bus/tests/request-intake.test.mjs b/packages/bus/tests/request-intake.test.mjs index 485979d2b..bc1e2febb 100644 --- a/packages/bus/tests/request-intake.test.mjs +++ b/packages/bus/tests/request-intake.test.mjs @@ -278,8 +278,7 @@ 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. + // 3u-3v (#1873, U4a) adds store_tasks; 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'); @@ -293,7 +292,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 3v ready\n'); assert.equal(SCHEMA_VERSION, '3v'); + assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3w ready\n'); assert.equal(SCHEMA_VERSION, '3w'); 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); @@ -316,8 +315,7 @@ 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. + // 3u-3v (#1873, U4a) adds store_tasks; 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'; @@ -335,7 +333,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 3v ready\n'); + assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3w 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); diff --git a/packages/bus/tests/schema-3w.test.mjs b/packages/bus/tests/schema-3w.test.mjs new file mode 100644 index 000000000..50a846e7c --- /dev/null +++ b/packages/bus/tests/schema-3w.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SCHEMA_VERSION, Store, schemaDigest } from '../src/store.mjs'; + +// #1873 SP9: bus schema step 3v-3w drops project_system_types with its index, its four triggers and +// its rows (Jason's 2026-11-12 ruling removes the system-type kind outright). +const TABLE = 'project_system_types'; +const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'); +const createdBy3u = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'); +const named = (path) => { + const db = new DatabaseSync(path, { readOnly: true }); + try { + return { + master: db.prepare('SELECT type,name FROM sqlite_master WHERE name LIKE ? OR tbl_name=? ORDER BY type,name').all(`${TABLE}%`, TABLE).map((r) => ({ ...r })), + sequence: db.prepare('SELECT name FROM sqlite_sequence WHERE name=?').all(TABLE).map((r) => r.name), + digest: schemaDigest(db), + version: db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value, + }; + } finally { + db.close(); + } +}; + +test('SP9 a store opened at schema 3w has no project_system_types table', (t) => { + assert.equal(SCHEMA_VERSION, '3w'); + assert.equal(ddl.includes(TABLE), false, 'schema.sql no longer creates the table'); + const root = mkdtempSync(join(tmpdir(), 'sp9-fresh-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + new Store(root).close(); + const fresh = named(join(root, 'bus/bus.sqlite')); + assert.equal(fresh.version, '3w'); + assert.deepEqual(fresh.master, []); +}); + +test('SP9 a 3v store holding project_system_types rows migrates to 3w with nothing of the table left', (t) => { + const root = mkdtempSync(join(tmpdir(), 'sp9-3v-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(join(root, 'bus'), { mode: 0o700 }); + const path = join(root, 'bus/bus.sqlite'); + const db = new DatabaseSync(path); + // The 3v schema is today's schema.sql plus what 3t-3u created; the store's step check refuses any + // other shape, so a migration that succeeds proves the fixture is a real 3v store. + db.exec(ddl + createdBy3u); + db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3v'); + db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db)); + db.prepare('INSERT INTO meta VALUES (?,?)').run('comment_cursor_epoch', '2026-11-12T00:00:00.000Z'); + db.prepare('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)').run('p1', '2026-11-12T00:00:00.000Z', 'acme', 'example', null, 'jason'); + const select = db.prepare(`INSERT INTO ${TABLE}(at,business,project,types,by) VALUES(?,?,?,?,?)`); + select.run('2026-11-12T00:00:01.000Z', 'acme', 'p1', '["coding"]', 'jason'); + select.run('2026-11-12T00:00:02.000Z', 'acme', 'p1', '["coding","montage"]', 'jason'); + db.close(); + chmodSync(path, 0o600); + const before = named(path); + assert.deepEqual(before.master.map((r) => r.name).sort(), [TABLE, `${TABLE}_business`, `${TABLE}_no_delete`, `${TABLE}_no_replace`, `${TABLE}_no_update`, `${TABLE}_project`]); + assert.deepEqual(before.sequence, [TABLE]); + assert.throws(() => new Store(root), /schema-mismatch/); + assert.deepEqual(named(path), before, 'the refused open changed nothing'); + const store = new Store(root, { migrateSchema: true }); + t.after(() => store.close()); + assert.equal(store.get('SELECT name FROM projects WHERE id=?', 'p1').name, 'example', 'other rows stay'); + const after = named(path); + assert.equal(after.version, '3w'); + assert.deepEqual(after.master, [], 'no table, index or trigger of that name is left'); + assert.deepEqual(after.sequence, [], 'no sqlite_sequence row of that name is left'); + const freshRoot = mkdtempSync(join(tmpdir(), 'sp9-ref-')); + t.after(() => rmSync(freshRoot, { recursive: true, force: true })); + new Store(freshRoot).close(); + assert.equal(after.digest, named(join(freshRoot, 'bus/bus.sqlite')).digest, 'the migrated schema is the fresh one'); +}); diff --git a/packages/bus/tests/schema.test.mjs b/packages/bus/tests/schema.test.mjs index 2b2d6ad6b..4f99e8a06 100644 --- a/packages/bus/tests/schema.test.mjs +++ b/packages/bus/tests/schema.test.mjs @@ -528,7 +528,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => { // Task 4 (3p to 3q), Task 3 (3q to 3r) and Task 6b (3r to 3s) widen request_events.kind in place. // #797 MVP3 (3s to 3t) adds project_archives and its five guards; project-archive.test.mjs tests them. // 3t to 3u adds project_system_types and four guards; U4a #1873 (3u to 3v) adds store_tasks and its seven guards. - assert.equal(count('table') - 1, 33); - assert.equal(count('trigger'), 130); + // SP9 #1873 (3v to 3w) drops project_system_types and its four guards. + assert.equal(count('table') - 1, 32); + assert.equal(count('trigger'), 126); assert.equal(count('view'), 5); }); diff --git a/packages/bus/tests/session-clear.test.mjs b/packages/bus/tests/session-clear.test.mjs index ddfdcc942..3e2996aa8 100644 --- a/packages/bus/tests/session-clear.test.mjs +++ b/packages/bus/tests/session-clear.test.mjs @@ -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), '3v'); + assert.equal(version(path), '3w'); const after = tables(path); - for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) { + for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', '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 = '3v';", "export const SCHEMA_VERSION = '3m';") + .replace("export const SCHEMA_VERSION = '3w';", "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), '3v'); + assert.equal(version(path), '3w'); 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. diff --git a/packages/bus/tests/sessions.test.mjs b/packages/bus/tests/sessions.test.mjs index 2ebc23011..9e7d485a0 100644 --- a/packages/bus/tests/sessions.test.mjs +++ b/packages/bus/tests/sessions.test.mjs @@ -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), '3v'); + assert.equal(version(path), '3w'); // 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 = '3v';", "export const SCHEMA_VERSION = '3k';") + .replace("export const SCHEMA_VERSION = '3w';", "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), '3v'); + assert.equal(version(path), '3w'); 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. diff --git a/packages/bus/tests/spec-migration.test.mjs b/packages/bus/tests/spec-migration.test.mjs index 4ccb8ff31..c5c4cb300 100644 --- a/packages/bus/tests/spec-migration.test.mjs +++ b/packages/bus/tests/spec-migration.test.mjs @@ -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, '3v'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w'); 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'), []); diff --git a/packages/bus/tests/system-type.test.mjs b/packages/bus/tests/system-type.test.mjs deleted file mode 100644 index 97b980758..000000000 --- a/packages/bus/tests/system-type.test.mjs +++ /dev/null @@ -1,109 +0,0 @@ -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: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, 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']); -}); diff --git a/packages/bus/tests/task-store.test.mjs b/packages/bus/tests/task-store.test.mjs index 0f12b7ba5..88eed6a61 100644 --- a/packages/bus/tests/task-store.test.mjs +++ b/packages/bus/tests/task-store.test.mjs @@ -402,7 +402,8 @@ test('REQ-TASK-1 a 3u store migrates to 3v: store_tasks is new and empty, every 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, '')); + // SP9 (#1873): a 3u store still has what 3t-3u created; 3v-3w drops it on the way to the fresh schema. + db.exec(currentDDL.replace(stepSQL, '') + readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8')); 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'); @@ -414,7 +415,7 @@ test('REQ-TASK-1 a 3u store migrates to 3v: store_tasks is new and empty, every 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(version(path), '3w'); 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-')); diff --git a/packages/bus/tests/thread-migration.test.mjs b/packages/bus/tests/thread-migration.test.mjs index 9641c135a..f0c9cd20d 100644 --- a/packages/bus/tests/thread-migration.test.mjs +++ b/packages/bus/tests/thread-migration.test.mjs @@ -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 = '3v';", "export const SCHEMA_VERSION = '3d';") + .replace("export const SCHEMA_VERSION = '3w';", "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, '3v'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w'); 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 3v ready', ''].join('\n')); + assert.equal(migrated.stdout, [...REPORT, 'bus schema 3w ready', ''].join('\n')); }); diff --git a/packages/bus/tests/thread-shares.test.mjs b/packages/bus/tests/thread-shares.test.mjs index 49180db19..461f26304 100644 --- a/packages/bus/tests/thread-shares.test.mjs +++ b/packages/bus/tests/thread-shares.test.mjs @@ -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), '3v'); + assert.equal(version(path), '3w'); const after = tables(path); assert.deepEqual(after.thread_shares, []); delete after.thread_shares; // Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too. - for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) { + for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', '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 = '3v';", "export const SCHEMA_VERSION = '3l';") + .replace("export const SCHEMA_VERSION = '3w';", "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), '3v'); + assert.equal(version(path), '3w'); 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. diff --git a/packages/bus/tests/usage-migration.test.mjs b/packages/bus/tests/usage-migration.test.mjs index 20eb6b579..037ff5182 100644 --- a/packages/bus/tests/usage-migration.test.mjs +++ b/packages/bus/tests/usage-migration.test.mjs @@ -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, '3v'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w'); 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'); diff --git a/packages/cli/README.md b/packages/cli/README.md index 8ade359a5..5efe21204 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1370,15 +1370,6 @@ field, digest and archive checks. Edit and archive need the task's digest from `mosaic tasks --json` as `--expect`; a stale one is refused `task-conflict` (exit 2) and nothing is written. They refuse inside an agent run. -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. - ## Accounts `mosaic accounts` administers the instance-level accounts store locally, as the diff --git a/packages/cli/live/budget-supervisor.mjs b/packages/cli/live/budget-supervisor.mjs index 2f054bee1..34b14264d 100644 --- a/packages/cli/live/budget-supervisor.mjs +++ b/packages/cli/live/budget-supervisor.mjs @@ -75,7 +75,8 @@ export function readAccounting({ dataRoot,business,start,end }) { try { db.exec('BEGIN'); // 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'); + // SP9 (#1873) 3v-3w drops only the table 3t-3u added, which accounting never reads, so 3w joins too. + if (!['3s','3t','3u','3v','3w'].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); diff --git a/packages/cli/live/budget-supervisor.test.mjs b/packages/cli/live/budget-supervisor.test.mjs index a8e8fad17..9b5b3eedd 100644 --- a/packages/cli/live/budget-supervisor.test.mjs +++ b/packages/cli/live/budget-supervisor.test.mjs @@ -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','3v',null]) test(`accounting ${version==='3s'?'accepts':'refuses'} schema ${version??'missing'}`,t=>{ +for(const version of ['3s','3r','3x',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'); diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 8cfc1989a..b46260f13 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -778,7 +778,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", "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 === "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 === "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); diff --git a/packages/cli/src/org.mjs b/packages/cli/src/org.mjs index 57bbcd531..4b5d22728 100644 --- a/packages/cli/src/org.mjs +++ b/packages/cli/src/org.mjs @@ -4,7 +4,6 @@ import { basename } from 'node:path'; import { listOrgs, orgLock, resolveOrg } from '../../organization/src/org-registry.mjs'; import { organizationWriter } from '../../organization/src/organization.mjs'; import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, projectRepoSet, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs'; -import { installedSystemTypes } from '../../plugins/src/system-types.mjs'; import { installedStarterPacks } from '../../plugins/src/starter-pack.mjs'; import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs'; import { loadSystem, orgPaths, rolesDir } from './config.mjs'; @@ -19,7 +18,7 @@ import { orgOwnerCommand, ORG_OWNER_USAGE, ORG_OWNER_VERBS } from './org-owner.m export const PACKS_USAGE = 'mosaic org pack list | pack preview ID PACK [--project P] [--as KIND.NAME=NEW]... | pack apply ID PACK --digest D [--project P] [--as KIND.NAME=NEW]... | board set ID FILE [--project P] | workflow set ID NAME FILE [--project P] | workflow remove ID NAME [--project P]'; // #1873 SP8: saving the organization, or one project, as a template; the owner's alone. export const TEMPLATE_USAGE = 'mosaic org template save ID NAME [--project P] (--preview | --digest D) | template list 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 [--no-bus] [--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] | project repo set ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | ' + PACKS_USAGE.slice('mosaic org '.length) + ' | ' + TEMPLATE_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]'; +export const ORG_USAGE = 'mosaic org lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--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] | project repo set ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | ' + PACKS_USAGE.slice('mosaic org '.length) + ' | ' + TEMPLATE_USAGE.slice('mosaic org '.length) + ' | 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', '--no-bus'].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 }; } // A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916). const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable'; @@ -92,8 +91,7 @@ async function packsCommand(args, io, deps) { else if (word === 'workflow' && sub === 'remove' && pos.length === 4) { allowed('--project'); verb = 'workflow.set'; revised = true; request = { name, workflow: null, ...project }; } else throw new CliError(PACKS_USAGE, 4); // pack list names no organization, so it reads what the bus's `packs` verb reads (the shipped and - // installed packs, never a saved template) from this instance's data root, as `system-types - // installed` does. + // installed packs, never a saved template) from this instance's data root. if (verb === 'packs') { const system = deps.system ?? loadSystem({ env: io.env, instance: devOwnerInstance(io.env) }); const { packs } = await installedStarterPacks({ dataRoot: system.dataRoot, rolesDir: rolesDir(io.env) }); @@ -179,10 +177,8 @@ export async function orgCommand(args, io, deps = {}) { let lifecycle = null; if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json', ...(words[0] === 'restore' ? ['--no-bus'] : [])], ['--if-revision']); lifecycle = { ifRevision: revision(p.values['--if-revision'], true) }; if (words[0] === 'archive' || !p.flags.has('--no-bus')) refuseAgentRun(io); } else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const ifRevision = revision(p.values['--if-revision'], false); lifecycle = { confirm: need('--confirm'), ifRevision }; refuseAgentRun(io); } - 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] === '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' }); } + 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 (words[0] === 'archive' && words.length === 2) result = await archiveOrg(ctx, words[1], { ...lifecycle, busStop: busStopForDelete, copyRemote: await (deps.copyRemoteFor ?? copyRemoteFor)(ctx) }); diff --git a/packages/cli/src/project.mjs b/packages/cli/src/project.mjs index 98b56d72d..d5293217c 100644 --- a/packages/cli/src/project.mjs +++ b/packages/cli/src/project.mjs @@ -14,8 +14,6 @@ export const PROJECT_USAGE = 'mosaic project init [--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 [--business ] [--json]', - 'mosaic project system-types-set --types [--business ]', 'mosaic project create [--repo ] [--business ] [--tester]', 'mosaic project list [--archived] [--business ] [--json] [--tester]', 'mosaic project archive [--business ]', @@ -30,8 +28,8 @@ export async function projectBusCommand(args, io, callFor) { ? { flags: ['--tester'], values: ['--repo', '--business'] } : sub === 'list' ? { flags: ['--json', '--tester', '--archived'], values: ['--business'] } - : { 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); + : { flags: [], values: ['--business'] }); + const one = ['create', 'archive', 'unarchive'].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); @@ -47,17 +45,6 @@ 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') { diff --git a/packages/cli/tests/org.test.mjs b/packages/cli/tests/org.test.mjs index 620808445..3ca4f1666 100644 --- a/packages/cli/tests/org.test.mjs +++ b/packages/cli/tests/org.test.mjs @@ -12,7 +12,6 @@ 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)); diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index d884ff89d..efa4be42d 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -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,20);assert.deepEqual(pairs.at(-1),['3u','3v']); + assert.equal(pairs.length,21);assert.deepEqual(pairs.at(-1),['3v','3w']); 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','project_system_types']; +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 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','project_system_types'])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','request_project_events'])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,7 +925,7 @@ 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'); }); - for(const x of ['thread_opens','project_system_types'])await t.test(`the in-run recount counts the listed tables too (${x})`,async(t)=>{ + for(const x of ['thread_opens','project_archives'])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',x); diff --git a/packages/cli/tests/system-type.test.mjs b/packages/cli/tests/system-type.test.mjs deleted file mode 100644 index 7ac1c7ca3..000000000 --- a/packages/cli/tests/system-type.test.mjs +++ /dev/null @@ -1,53 +0,0 @@ -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, []); -}); diff --git a/packages/organization/README.md b/packages/organization/README.md index 5873d60e4..e575aa421 100644 --- a/packages/organization/README.md +++ b/packages/organization/README.md @@ -615,11 +615,6 @@ 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. - ## Instance accounts (Phase M2) `src/accounts.mjs` validates `/accounts.json` without a dependency or diff --git a/packages/organization/src/org-rights.mjs b/packages/organization/src/org-rights.mjs index a562db495..6f1e93900 100644 --- a/packages/organization/src/org-rights.mjs +++ b/packages/organization/src/org-rights.mjs @@ -87,7 +87,6 @@ export const VERB_RIGHTS = record({ 'request.draft.console': 'request.write', 'prd.revoke.console': 'request.write', 'plugins.set': 'org.setup', - 'system-types.console': 'org.setup', 'project.remote.get.console': 'org.setup', 'project.remote.set.console': 'org.setup', // #1873 SP4: starter packs, the board and workflows. The list is a board read; every write is diff --git a/packages/organization/src/organization-config.mjs b/packages/organization/src/organization-config.mjs index ba85536f6..7d9fc6fb0 100644 --- a/packages/organization/src/organization-config.mjs +++ b/packages/organization/src/organization-config.mjs @@ -14,8 +14,6 @@ import { loadRole } from "./role.mjs"; import { checkDefinition, checkDefinitions, checkDefinitionName, orgDefinitionName, ORG_PREFIX } from "./definitions.mjs"; import { END_PROTECTED, parseRepoRemote } from "./vocabulary.mjs"; import { checkPlugins, checkUsageSettings, NAME as KIND_NAME } from "../../plugins/src/kinds.mjs"; -import { hasModule } from "../../plugins/src/loader.mjs"; -import { readPluginLock } from "../../plugins/src/pinned.mjs"; import { checkMapping } from "../../library/src/mapping.mjs"; import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs"; import { checkAppearance } from "./appearance.mjs"; @@ -505,14 +503,6 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo // 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) })); - let installedNames = new Set(); - if (dataRoot !== null && plugins["system-type"]?.some(id=>!hasModule("system-type",id))) { - try { installedNames = new Set(Object.keys(readPluginLock(dataRoot).plugins)); } - catch { installedNames = null; } // the loader reports an unreadable lock; core still loads - } - for (const id of plugins["system-type"] ?? []) { - if (!hasModule("system-type", id) && installedNames !== null && !installedNames.has(id)) refuse(`${file} plugins.system-type names unknown module ${id}`); - } const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage); const autonomy = checkAutonomy(document.autonomy); if(autonomy?.authorityResolver) { diff --git a/packages/organization/src/organization.mjs b/packages/organization/src/organization.mjs index 6b3283f88..e2e0f4ca8 100644 --- a/packages/organization/src/organization.mjs +++ b/packages/organization/src/organization.mjs @@ -126,13 +126,6 @@ function autonomyFence(old, document, actor) { if(actor!==old.human || actor==='tester' || agents.includes(actor))fail('human-required'); } -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. @@ -320,7 +313,6 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = ( replyLimitsFence(old, document, actor); modelQuotaFence(old, document, actor); usageFence(old, document, actor); - systemTypesFence(old, document, actor); autonomyFence(old, document, actor); validateBusinessDocument(document, file, { rolesDir, dataRoot }); const bytes = JSON.stringify(document, null, 2) + "\n"; @@ -332,7 +324,6 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = ( replyLimitsFence(old, written, actor); modelQuotaFence(old, written, actor); usageFence(old, written, actor); - systemTypesFence(old, written, actor); autonomyFence(old, written, actor); validateBusinessDocument(written, file, { rolesDir, dataRoot }); if (beforeWrite) { diff --git a/packages/organization/tests/org-rights.test.mjs b/packages/organization/tests/org-rights.test.mjs index 82a750705..4c51e4f3b 100644 --- a/packages/organization/tests/org-rights.test.mjs +++ b/packages/organization/tests/org-rights.test.mjs @@ -79,7 +79,6 @@ const VERBS = { 'request.draft.console': 'request.write', 'prd.revoke.console': 'request.write', 'plugins.set': 'org.setup', - 'system-types.console': 'org.setup', 'project.remote.get.console': 'org.setup', 'project.remote.set.console': 'org.setup', // #1873 SP4: starter packs, the board and workflows. The list is a board read; every write is diff --git a/packages/organization/tests/system-type-removed.test.mjs b/packages/organization/tests/system-type-removed.test.mjs new file mode 100644 index 000000000..b6a1f62eb --- /dev/null +++ b/packages/organization/tests/system-type-removed.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { KINDS, checkPlugins } from '../../plugins/src/kinds.mjs'; +import { validateBusinessDocument } from '../src/organization-config.mjs'; +import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs'; + +// #1873 SP9: the system-type kind is removed outright (Jason, 2026-11-12). An org file that still +// names it refuses as an unknown kind, like any other name not in KINDS, even with an empty list. +test('SP9 an org file naming the removed system-type kind refuses unknown-kind', () => { + assert.equal(Object.hasOwn(KINDS, 'system-type'), false); + for (const list of [['coding'], []]) { + assert.throws(() => checkPlugins({ 'system-type': list }, 'acme.json plugins'), + (e) => e.code === 'unknown-kind' && /unknown kind "system-type"/.test(e.message)); + const root = tmp(), doc = businessDoc(root), file = join(root, 'businesses/acme.json'); + doc.plugins = { 'system-type': list }; + assert.throws(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /unknown kind "system-type"/); + } +}); diff --git a/packages/organization/tests/system-type.test.mjs b/packages/organization/tests/system-type.test.mjs deleted file mode 100644 index efbd36d72..000000000 --- a/packages/organization/tests/system-type.test.mjs +++ /dev/null @@ -1,29 +0,0 @@ -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']); -}); diff --git a/packages/plugins/README.md b/packages/plugins/README.md index acba02089..4d632196f 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -499,15 +499,6 @@ 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. - ### Instance-installed packages (PL1, #1873) The instance owner may install trusted plugin code from the host shell: diff --git a/packages/plugins/channel/cli/index.mjs b/packages/plugins/channel/cli/index.mjs index 6e66a9200..ce3b8a956 100644 --- a/packages/plugins/channel/cli/index.mjs +++ b/packages/plugins/channel/cli/index.mjs @@ -6,8 +6,6 @@ const VERBS = new Set([ "project.create", "project.archive", "project.unarchive", - "project.system-types", - "project.system-types.set", "thread.list", "thread.create", "thread.messages", diff --git a/packages/plugins/src/kinds.mjs b/packages/plugins/src/kinds.mjs index 16cd83020..4989c9327 100644 --- a/packages/plugins/src/kinds.mjs +++ b/packages/plugins/src/kinds.mjs @@ -80,10 +80,6 @@ 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 starter pack (#1873 SP1): starterPack() takes nothing and returns a pack document, which // starter-pack.mjs checks with the origin of where it was read. A pack is copied into an // organization, never switched on, so its switch is "none": `plugins` can never name one. diff --git a/packages/plugins/src/system-types.mjs b/packages/plugins/src/system-types.mjs deleted file mode 100644 index 107474092..000000000 --- a/packages/plugins/src/system-types.mjs +++ /dev/null @@ -1,19 +0,0 @@ -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({dataRoot = null} = {}) { - const { modules, refused } = await loadInstalled('system-type',{dataRoot}); - 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); - }); -} diff --git a/packages/plugins/system-type/business-management/index.mjs b/packages/plugins/system-type/business-management/index.mjs deleted file mode 100644 index 252647720..000000000 --- a/packages/plugins/system-type/business-management/index.mjs +++ /dev/null @@ -1,5 +0,0 @@ -export const interfaceVersion = 1; - -export function systemType() { - return { id: "business-management", label: "Business management", description: "Coordinate and manage business operations.", toolsets: [] }; -} diff --git a/packages/plugins/system-type/coding/index.mjs b/packages/plugins/system-type/coding/index.mjs deleted file mode 100644 index 77e9abb38..000000000 --- a/packages/plugins/system-type/coding/index.mjs +++ /dev/null @@ -1,5 +0,0 @@ -export const interfaceVersion = 1; - -export function systemType() { - return { id: "coding", label: "Coding", description: "Build and maintain software.", toolsets: [] }; -} diff --git a/packages/plugins/system-type/creative-writing/index.mjs b/packages/plugins/system-type/creative-writing/index.mjs deleted file mode 100644 index 768603fcc..000000000 --- a/packages/plugins/system-type/creative-writing/index.mjs +++ /dev/null @@ -1,5 +0,0 @@ -export const interfaceVersion = 1; - -export function systemType() { - return { id: "creative-writing", label: "Creative writing", description: "Develop and write creative works.", toolsets: [] }; -} diff --git a/packages/plugins/system-type/marketing/index.mjs b/packages/plugins/system-type/marketing/index.mjs deleted file mode 100644 index 0a9905c3c..000000000 --- a/packages/plugins/system-type/marketing/index.mjs +++ /dev/null @@ -1,5 +0,0 @@ -export const interfaceVersion = 1; - -export function systemType() { - return { id: "marketing", label: "Marketing", description: "Plan and produce marketing work.", toolsets: [] }; -} diff --git a/packages/plugins/system-type/montage/index.mjs b/packages/plugins/system-type/montage/index.mjs deleted file mode 100644 index 766656902..000000000 --- a/packages/plugins/system-type/montage/index.mjs +++ /dev/null @@ -1,5 +0,0 @@ -export const interfaceVersion = 1; - -export function systemType() { - return { id: "montage", label: "Mosaic Montage", description: "Create and manage media productions.", toolsets: [] }; -} diff --git a/packages/plugins/tests/kinds.test.mjs b/packages/plugins/tests/kinds.test.mjs index 137b4239f..7a607815c 100644 --- a/packages/plugins/tests/kinds.test.mjs +++ b/packages/plugins/tests/kinds.test.mjs @@ -6,7 +6,7 @@ import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs"; const code = expected => e => e instanceof PluginError && e.code === expected; test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1, limit-check at 2 (row 125), each a list", () => { - assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "system-type", "starter-pack", "theme", "task-tracker"]); + assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "starter-pack", "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. diff --git a/packages/plugins/tests/loader.test.mjs b/packages/plugins/tests/loader.test.mjs index 62a0c12ba..7d9413dd0 100644 --- a/packages/plugins/tests/loader.test.mjs +++ b/packages/plugins/tests/loader.test.mjs @@ -12,7 +12,7 @@ import { validateBusinessDocument } from "../../organization/src/organization-co const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] }; // What loaded() reports for GOOD: search-source is a kind too, switched off here. -const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], "starter-pack": [], theme: [], "task-tracker": [] }; +const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "starter-pack": [], theme: [], "task-tracker": [] }; // A tree with one good module of each kind, already loaded. async function loadedTree(t, options) { @@ -56,7 +56,7 @@ test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization const loader = await s.loader(); const result = await loader.load(business.plugins); assert.deepEqual(result.refused, []); - assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], "starter-pack": [], theme: [], "task-tracker": [] }); + assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "starter-pack": [], 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); @@ -171,7 +171,7 @@ test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses a assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/); assert.deepEqual(result.refused[0].kept, ["meter"]); assert.deepEqual(s.loader.logged.slice(before), result.refused); - assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], "starter-pack": [], theme: [], "task-tracker": [] }); + assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "starter-pack": [], theme: [], "task-tracker": [] }); } finally { if (lock) chmodSync(lock, 0o755); } @@ -233,7 +233,7 @@ test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and t const loader = await s.loader(); // meter loads, absent refuses: the whole kind stays off, not half on. const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] }); - assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], "starter-pack": [], theme: [], "task-tracker": [] }); + assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "starter-pack": [], theme: [], "task-tracker": [] }); assert.deepEqual(loader.loaded(), result.loaded); assert.deepEqual(loader.modules("usage-source"), []); assert.equal(loader.logged.length, 1); @@ -261,7 +261,7 @@ test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole se await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/); // An absent key switches the kind off. result = await s.loader.load({ "limit-check": ["cap"] }); - assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], "starter-pack": [], theme: [], "task-tracker": [] }); + assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "starter-pack": [], theme: [], "task-tracker": [] }); }); test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => { diff --git a/packages/plugins/tests/pinned.test.mjs b/packages/plugins/tests/pinned.test.mjs index c1f6298da..c95fb945b 100644 --- a/packages/plugins/tests/pinned.test.mjs +++ b/packages/plugins/tests/pinned.test.mjs @@ -207,22 +207,6 @@ test('PL1 tool exceptions cannot impersonate trusted refusals or echo payload by await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:'hello'}),e=>e.code==='plugin-tool-failed' && e.plugin==='acmeplug' && !e.message.includes(SECRET)); }); -test('PL1 A3 organization copy readers preserve the derived installed-plugin root',async t=>{ - const {withCopies,loadWithCopies}=await import('../../organization/src/agent-copies.mjs'); - const f=fixture(t);f.manifest.kinds=['system-type'];f.manifest.interfaceVersions={'system-type':1}; - mkdirSync(join(f.src,'system-type'));writeFileSync(join(f.src,'system-type/index.mjs'),'export const interfaceVersion=1;export function systemType(){return {}};'); - const hash=f.seal();f.install(f.src,{expectedHash:hash}); - const doc=businessDoc(f.root),rolesDir=rolesCopy(f.root);doc.plugins={...doc.plugins,'system-type':['acmeplug']};writeJson(join(f.root,'businesses/acme.json'),doc); - const options={rolesDir,dataRoot:f.dataRoot,snapshot:null}; - assert.deepEqual(withCopies(doc,join(f.root,'businesses/acme.json'),options).plugins['system-type'],['acmeplug']); - assert.deepEqual(loadWithCopies('acme',{...options,dir:f.root}).plugins['system-type'],['acmeplug']); - const installed=join(f.dataRoot,'plugins/acmeplug/system-type/index.mjs');chmodSync(installed,0o600);writeFileSync(installed,SECRET); - // Config checks names only; the loader owns bad-pin refusal and preserves core. - assert.deepEqual(withCopies(doc,join(f.root,'businesses/acme.json'),options).plugins['system-type'],['acmeplug']); - doc.plugins['system-type']=['defualt']; - assert.throws(()=>withCopies(doc,join(f.root,'businesses/acme.json'),options),/unknown module/); -}); - test('PL1 replacement pins refuse across loader and host tools until a fresh process',async t=>{ for(const first of ['loader','tool']) { const f=fixture(t); diff --git a/packages/plugins/tests/system-type.test.mjs b/packages/plugins/tests/system-type.test.mjs deleted file mode 100644 index 849094e03..000000000 --- a/packages/plugins/tests/system-type.test.mjs +++ /dev/null @@ -1,19 +0,0 @@ -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'], []); -}); diff --git a/packages/tasks/requirements.json b/packages/tasks/requirements.json index dcfc5ea0a..85e6afeae 100644 --- a/packages/tasks/requirements.json +++ b/packages/tasks/requirements.json @@ -70,8 +70,8 @@ { "id": "REQ-LIMIT-4", "title": "Usage sources and limit checks are the first plug-in kinds; an unknown turn is logged and shown, never counted as zero" }, { "id": "REQ-PLUG-1", "title": "Every part the core calls out to is a plug-in kind; modules load from packages/plugins, switched on in the org file" }, { "id": "REQ-PLUG-2", "title": "New call-outs go through a plug-in kind from the start; a hard-coded part converts when a row changes it" }, - { "id": "REQ-SYS-1", "title": "System types are a plug-in kind; organizations enable a list of modules" }, - { "id": "REQ-SYS-2", "title": "Projects use enabled system types and their agents receive the corresponding toolsets" }, + { "id": "REQ-SYS-1", "title": "Starter packs are a plug-in kind; an organization or project copies one in once and owns the copy" }, + { "id": "REQ-SYS-2", "title": "Applying a pack grants nothing; every proposed agent is hired through the hire decision" }, { "id": "REQ-HARN-1", "title": "The meta-harness generates each session's launch bundle from role and variables" }, { "id": "REQ-HARN-2", "title": "Credential scope, verbs, the tool limit and a container enforce the rules" }, { "id": "REQ-EVT-1", "title": "Every slice 1 step emits an event; measures come from events" }, diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index f7ec1cc3e..5a18b98e8 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -6,7 +6,6 @@ import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serv import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs'; import { organizationConsole } from './organizations.mjs'; import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs'; -import { SYSTEM_TYPE_VERBS } from './system-types-http.mjs'; import { packsCaller } from './packs-http.mjs'; import { templatesCaller } from './template-http.mjs'; import { autonomyCaller } from './autonomy-http.mjs'; @@ -192,9 +191,6 @@ try { // The owner reads request status on the selected organization's bus, as on the root Console. Request // writes stay off: the README "Console actions" inventory marks them unavailable for the organizations release. requestRead: session.user === undefined ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null, - // Phase P2 (#1873): the owner's System types writes, the organization's enable switch (plugins.set - // with its revision) and a project's selection, over the owner's human transport under --send only. - systemTypesWrite: session.user === undefined && sendOn ? agentCaller(owner, SYSTEM_TYPE_VERBS) : null, // D1 (#1873): the owner's autonomy writes (save, review, confirm, reverse) on this organization's bus. // These options exist only on a --dev-local-owner Console (`organizations` above). The fence passes // those four verbs and nothing else the console transport carries. diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 804c956e0..3c13249da 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -1928,7 +1928,7 @@ if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')||wsPage(path)) render(false); else if (window.MosaicOrg ? path === '/board' : path === '/' || path === '') paintHeld(heldErr); }; configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {}); - // E1 (P1-5): after Restart bus the organization dashboard draws its bus line and system types now, not at the next poll. + // E1 (P1-5): after Restart bus the organization dashboard draws its bus line now, not at the next poll. document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } }); // The cross-organization pages ask for a repaint after a filter, sort or selection change, // and report the inbox count across organizations for the sidebar. diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index c99bc0533..475e0bc58 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -620,7 +620,7 @@ html += p && !c.refused && redesigned ? window.MosaicProjects.header(p, c.section, c.threads) : heading(p?.name ?? 'Project unavailable'); if (p && !c.refused) { if (p.archived && redesigned && !c.canSend) html += '

This project is archived. Its saved conversations and records stay readable.

'; - if (c.settings) html += `

Project overview

Project information

Repository: ${txt(p.repo || 'Not linked')}

Repository setting unavailable.
System types unavailable.
`; + if (c.settings) html += `

Project overview

Project information

Repository: ${txt(p.repo || 'Not linked')}

Repository setting unavailable.
`; else if (c.section === 'conversations' && redesigned) html += `

Conversations

${c.threads.filter(t=>!t.shared && t.project===p.id).map(t=>`${txt(t.name)}With ${txt(t.instance)}`).join('') || '

No conversations yet. Start one to begin work.

'}
${p.archived ? '' : `
New conversation${creation('thread', c.id)}
`}

Shared with me

${c.threads.some(t=>t.shared && t.project===p.id)?threads(c.threads.filter(t=>t.shared && t.project===p.id)):'

Nothing is shared with you yet.

'}`; else if (c.section === 'conversations') html += '

Conversations

' + threads(c.threads.filter(t => !t.shared)) + (p.archived ? '' : creation('thread', c.id)); else if (redesigned) html += '
Reading…
'; @@ -687,9 +687,6 @@ // which names the project as the organization file does: the chat project's repo key. if (remote && roomOwner() && window.MosaicOrg && typeof window.MosaicProjectRepo?.mount === 'function') window.MosaicProjectRepo.mount({ container: remote, project: project?.repo ?? null, archived: !!c.projectArchived }); else if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); } - // Phase P2 (#1873): the project's System types selection, the owner's, mounted like the remote. - const types = c.container.querySelector('[data-chat-system-types]'); - if (types && roomOwner() && typeof window.MosaicSystemTypes?.mount === 'function') window.MosaicSystemTypes.mount({ container: types, projectId: c.id, archived: !!c.projectArchived }); if (!window.MosaicOrg && (!redesigned || c.kind !== 'project' || c.settings || c.projectArchived)) window.MosaicProjectArchive?.mount(c.container, c); if (redesigned && c.kind === 'project' && c.projectArchived && !c.settings) { const controls = c.container.querySelector('.project-archive-control'); diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index c1b33382a..6d6ce3ee7 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -7,7 +7,7 @@ - + diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css index c443839c9..ef1f90d54 100644 --- a/packages/webui/src/public/shell.css +++ b/packages/webui/src/public/shell.css @@ -487,8 +487,8 @@ 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-docrow{margin-top:8px} +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)} @@ -697,7 +697,7 @@ html[data-organizations] #copy-status:empty{display:none} 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-busctl .bus-buttons{justify-content:flex-start} - html[data-organizations] .ws-typechips .ws-label,html[data-organizations] .ws-docrow .ws-label{min-width:0} + 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} diff --git a/packages/webui/src/public/system-types.css b/packages/webui/src/public/system-types.css deleted file mode 100644 index e3b9d0e89..000000000 --- a/packages/webui/src/public/system-types.css +++ /dev/null @@ -1,33 +0,0 @@ -/* Phase P2 (#1873): System types on the organization dashboard (.ws-types, the chips and the - Settings row's Open) and on a project's information page (.project-types). */ -html[data-organizations] .ws-chip{display:inline-flex;align-items:center;font-size:12px;font-weight:500;padding:1px 7px;border:1px solid var(--line);border-radius:4px;color:var(--text);white-space:nowrap} -html[data-organizations] .ws-cfg button.go{border:0;background:none;padding:0;cursor:pointer;font:inherit;font-size:12.5px;font-weight:600;color:var(--action)} -html[data-organizations] .ws-cfg button.go:hover{text-decoration:underline} -html[data-organizations] .ws-types-list{display:grid;list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)} -html[data-organizations] .ws-types-list li{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:4px 12px;padding:8px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)} -html[data-organizations] .ws-types-list li:last-child{border-bottom:0} -html[data-organizations] .ws-types-list .t{display:flex;flex-direction:column;min-width:0} -html[data-organizations] .ws-types-list .t b{font-size:13px;font-weight:600;color:var(--strong)} -html[data-organizations] .ws-types-list .t .d{font-size:12px;color:var(--muted);overflow-wrap:anywhere} -html[data-organizations] .ws-types-list .ws-dot{font-size:12.5px} -html[data-organizations] .ws-types-list .ws-btn[aria-disabled="true"]{color:var(--muted);cursor:progress} -html[data-organizations] .ws-types>.note{margin:8px 0 0} -html[data-organizations] .ws-types-status:empty{display:none} -html[data-organizations] .ws-types-status code{overflow-wrap:anywhere} -html[data-organizations] .ws-types h2:focus{outline:none} -@media (max-width:759px){ - html[data-organizations] .ws-types-list li{grid-template-columns:minmax(0,1fr) auto} - html[data-organizations] .ws-types-list .t{grid-column:1/-1} -} - -.project-types{border:1px solid var(--border, var(--line, #aaa));border-radius:4px;padding:1rem;margin-top:1rem;overflow-wrap:anywhere} -.project-types fieldset{border:0;margin:0;padding:0;display:grid;gap:.5rem} -.project-types legend{font-weight:600;margin-bottom:.25rem} -.project-types-option{display:flex;align-items:flex-start;gap:.5rem;min-height:44px} -.project-types-option input{margin-top:.2rem;width:20px;height:20px;flex:none} -.project-types-option span{display:flex;flex-direction:column;min-width:0} -.project-types-option small{color:var(--muted, inherit)} -.project-types button{min-height:44px;margin-top:.75rem} -.project-types-tag{font-size:.8em;font-weight:600;padding:0 .4em;border:1px solid currentColor;border-radius:4px;color:var(--warning, inherit)} -.project-types [role="status"]:empty{display:none} -.project-types [role="status"]:focus{outline:none} diff --git a/packages/webui/src/public/system-types.js b/packages/webui/src/public/system-types.js deleted file mode 100644 index f52e9d3b9..000000000 --- a/packages/webui/src/public/system-types.js +++ /dev/null @@ -1,195 +0,0 @@ -// Phase P2 (#1873): System types, the owner's control on the organization dashboard and on a project's -// information page. The modules, their labels and descriptions come from the system-type plug-ins -// through /api/system-types; nothing here lists a module. Writes go to the same route, which uses the -// organization writer (revision, owner fence, receipt) or project.system-types.set; the server and the -// bus recheck every write, so a control shown here grants nothing. -(() => { - 'use strict'; - const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); - const enc = encodeURIComponent; - const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/; - const orgPath = (org, rest) => (org ? `/api/orgs/${enc(org)}/${rest}` : `/api/${rest}`); - const LINES = { - 'sign-in-required': 'Sign in again to see or change system types.', - 'owner-only': "System types are the organization owner's.", - 'read-only': 'This Console only reads. Changing system types needs signed-in send mode.', - 'human-required': "Only the organization's owner can change system types.", - 'organization-system-types-human-only': "Only the organization's owner can change system types.", - 'revision-conflict': 'The organization file changed after this page read it. The list was read again; try again.', - 'organization-busy': 'The organization file is being written. Try again in a moment.', - 'system-type-not-enabled': 'A chosen type is not enabled in the organization any more. The list was read again.', - 'system-type-unknown': 'A chosen type is not installed in this tree.', - 'project-not-found': 'This project was not found.', - 'not-configured': 'System types are not configured on this Console.', - 'system-type-invalid': 'An installed system type module is invalid, so none can be read.', - 'no-bus-host': 'The bus is not running.', - 'invalid-request': 'The request was refused as invalid.', - }; - const line = code => LINES[code] || 'The bus did not confirm the outcome. The list was read again; check it before you retry.'; - - // One request; a refusal keeps its code so the page can say why. - async function call(path, body) { - const init = body === undefined ? { cache: 'no-store', redirect: 'manual' } - : { method: 'POST', cache: 'no-store', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }; - let response; - try { response = await fetch(path, init); } catch { return { ok: false, code: 'outcome-unknown' }; } - if (response.type === 'opaqueredirect') return { ok: false, code: 'sign-in-required' }; - const value = await response.json().catch(() => null); - if (!response.ok) return { ok: false, code: typeof value?.error === 'string' ? value.error : 'outcome-unknown' }; - return value && typeof value === 'object' ? { ok: true, value } : { ok: false, code: 'outcome-unknown' }; - } - const read = (org, project = null) => call(orgPath(org, project === null ? 'system-types' : `system-types/projects/${enc(project)}`)); - const labelOf = (value, id) => value.installed.find(m => m.id === id)?.label || id; - const valid = value => value && Array.isArray(value.installed) && Array.isArray(value.enabled); - - // ---------- organization dashboard ---------- - // Per organization: the last read, the type a write is pending on, and the last outcome line. - const orgs = new Map(); - const slot = org => { if (!orgs.has(org)) orgs.set(org, { value: null, code: null, pending: null, outcome: null }); return orgs.get(org); }; - function remember(org, result) { - const s = slot(org); - if (result.ok && valid(result.value)) { s.value = result.value; s.code = null; } else { s.value = null; s.code = result.ok ? 'outcome-unknown' : result.code; } - return s; - } - // The chip row under the header: the enabled modules' labels. - function chips(org) { - const s = slot(org), v = s.value; - const body = !v ? `${esc(s.code === 'owner-only' ? line('owner-only') : 'System types unavailable')}` - : !v.installed.length ? 'No system type modules are installed.' - : v.enabled.length ? `${v.enabled.map(id => `${esc(labelOf(v, id))}`).join('')}Each project picks from these; none is required.` - : 'None enabled. Enable them under System types below; each project picks from the enabled ones.'; - return `
System types${body}
`; - } - // The Settings row's value. - function summary(org) { - const v = slot(org).value; - return v ? `${v.enabled.filter(id => v.installed.some(m => m.id === id)).length} of ${v.installed.length} installed enabled` : 'Could not be read'; - } - // A side-by-side pane (U5) passes { pane: true, idPrefix }: it shows the types without the switch, - // which stays in the organization's full view, and its heading id carries the pane's prefix. - function section(org, { pane = false, idPrefix = '' } = {}) { - const s = slot(org), v = s.value; - const head = `

System types

installed modules; each project picks from the enabled ones
`; - const status = `

${s.outcome ? s.outcome : ''}

`; - if (!v) return `
${head}

${esc(s.code === 'owner-only' ? line('owner-only') : `System types unavailable. ${line(s.code)}`)}

${status}
`; - if (!v.installed.length) return `
${head}

No system type modules are installed.

${status}
`; - const rows = v.installed.map(m => { - const on = v.enabled.includes(m.id), busy = s.pending !== null; - const state = `${on ? 'enabled' : 'off'}`; - const control = v.writable && !pane - ? `` - : ''; - return `
  • ${esc(m.label)}${esc(m.description)}${state}${control}
  • `; - }).join(''); - const why = pane ? "

    Change system types from this organization's full view.

    " - : v.writable ? '

    Disabling a type keeps it in the projects that pick it; they show it as inactive until it is enabled again.

    ' - : `

    ${esc(line('read-only'))}

    `; - return `
    ${head}
      ${rows}
    ${why}${status}
    `; - } - // The dashboard asks for this before it paints; the result stays for chips(), summary() and section(). - const load = async org => remember(org, await read(org)); - // After a write, the three parts are repainted in place; the 10 s dashboard repaint reads again anyway. - function repaint(org) { - if (window.MosaicOrg?.id !== org) return; - const view = [...document.querySelectorAll('[data-org-dashboard]')].find(v => !v.closest('[data-pane]')); if (!view) return; - const a = document.activeElement, key = view.contains(a) ? a.dataset?.focusKey || null : null; - const swap = (selector, html) => { const el = view.querySelector(selector); if (el) el.outerHTML = html; }; - swap('[data-system-type-chips]', chips(org)); - swap('[data-system-types]', section(org)); - const value = view.querySelector('[data-org-setting="System types"] .v'); if (value) { value.textContent = summary(org); value.title = summary(org); } - if (key) view.querySelector(`[data-focus-key="${CSS.escape(key)}"]`)?.focus({ preventScroll: true }); - } - async function toggle(button) { - const org = window.MosaicOrg?.id, s = org ? slot(org) : null, type = button.dataset.systemTypeToggle; - if (!s?.value?.writable || s.pending !== null || typeof s.value.revision !== 'string') return; - const on = s.value.enabled.includes(type); - const enabled = on ? s.value.enabled.filter(id => id !== type) : [...s.value.enabled, type]; - s.pending = type; s.outcome = null; repaint(org); - const result = await call(orgPath(org, 'system-types'), { revision: s.value.revision, enabled }); - s.pending = null; - const label = labelOf(s.value, type); - if (result.ok && typeof result.value.transaction === 'string') { - s.outcome = `${esc(label)} ${on ? 'disabled' : 'enabled'}. Receipt ${esc(result.value.transaction)}.`; - } else s.outcome = esc(result.ok ? line('outcome-unknown') : line(result.code)); - await load(org); - repaint(org); - } - document.addEventListener('click', e => { - const button = e.target.closest?.('[data-system-type-toggle]'); - // A pending write marks the buttons aria-disabled, not disabled, so focus stays on the one pressed. - if (button) { e.preventDefault(); if (button.getAttribute('aria-disabled') !== 'true' && !button.closest('[data-pane]')) toggle(button); return; } - const open = e.target.closest?.('[data-system-types-open]'); - if (open) { e.preventDefault(); const h = (open.closest('[data-org-dashboard]') || document).querySelector('[data-system-types-heading]'); h?.scrollIntoView({ block: 'start' }); h?.focus({ preventScroll: true }); } - }); - - // ---------- project information page ---------- - let active = null; - document.addEventListener('mosaic-signed-out', () => { active?.dispose(); active = null; }); - // The chat view mounts again on every repaint and keeps the section; leaving the page marks it stale, - // so coming back (say, after the dashboard changed the enabled set) reads again. - window.addEventListener('hashchange', () => { if (active) active.stale = true; }); - function mount({ container, projectId, archived = false }) { - if (!(container instanceof Element)) return; - const org = window.MosaicOrg?.id ?? null; - if (active && active.org === org && active.projectId === projectId) { - container.replaceChildren(active.section); - if (active.archived !== archived) { active.archived = archived; active.paint(); } - if (active.stale) { active.stale = false; active.refresh(); } - return; - } - active?.dispose(); - const section = document.createElement('section'); - section.className = 'project-types'; - section.setAttribute('data-project-system-types', ''); - container.replaceChildren(section); - let value = null, code = null, outcome = '', saving = false, disposed = false, generation = 0; - const state = { org, projectId, section, archived, stale: false, paint, refresh: () => refresh(), dispose: () => { disposed = true; generation++; section.replaceChildren(); } }; - active = state; - function paint() { - if (disposed) return; - const head = '

    System types

    '; - const status = `

    ${outcome}

    `; - if (!ID.test(projectId ?? '')) { section.innerHTML = `${head}

    System types unavailable.

    `; return; } - if (!value) { section.innerHTML = `${head}

    ${esc(code ? `System types unavailable. ${line(code)}` : 'Reading system types…')}

    ${status}`; return; } - const sel = value.project, enabled = value.installed.filter(m => value.enabled.includes(m.id)); - const inactive = sel.inactive.length - ? `

    Inactive, because the organization has disabled them:

      ${sel.inactive.map(id => `
    • ${esc(labelOf(value, id))} inactive
    • `).join('')}
    ${value.writable && !state.archived ? '

    Saving keeps only enabled types, so it removes these from the project.

    ' : ''}
    ` - : ''; - const intro = '

    This project picks from the system types its organization has enabled. None is required.

    '; - const canWrite = value.writable && !state.archived; - if (!enabled.length) { - // With nothing enabled the only change left is clearing what the project still selects. - const clear = canWrite && sel.inactive.length - ? `
    ` : ''; - section.innerHTML = `${head}${intro}

    The organization has no system types enabled. The owner enables them on the organization dashboard.

    ${inactive}${clear}${status}`; - return; - } - const boxes = enabled.map(m => ``).join(''); - const why = state.archived ? '

    This project is archived; its selection is shown read only.

    ' : value.writable ? '' : `

    ${esc(line('read-only'))}

    `; - section.innerHTML = `${head}${intro}
    Selected system types${boxes}
    ${inactive}${canWrite ? `` : ''}
    ${why}${status}`; - } - async function refresh() { - const turn = ++generation; - const result = await read(org, projectId); - if (disposed || turn !== generation) return; - if (result.ok && valid(result.value) && result.value.project) { value = result.value; code = null; } else { value = null; code = result.ok ? 'outcome-unknown' : result.code; } - paint(); - } - section.addEventListener('submit', async e => { - e.preventDefault(); - if (saving || !value?.writable || state.archived) return; - const chosen = [...section.querySelectorAll('input[name="system-type"]:checked')].map(i => i.value); - saving = true; outcome = ''; paint(); - const result = await call(orgPath(org, `system-types/projects/${enc(projectId)}`), { systemTypes: chosen }); - if (disposed) return; - saving = false; - outcome = result.ok ? esc(result.value.selected.length ? `Saved. Selected: ${result.value.selected.map(id => labelOf(value, id)).join(', ')}.` : 'Saved. No system types selected.') : esc(line(result.code)); - await refresh(); - section.querySelector('[data-project-system-types-status]')?.focus({ preventScroll: true }); - }); - paint(); - refresh(); - } - - window.MosaicSystemTypes = Object.freeze({ load, chips, summary, section, mount }); -})(); diff --git a/packages/webui/src/public/workspace.js b/packages/webui/src/public/workspace.js index 24063ec70..a53c4792a 100644 --- a/packages/webui/src/public/workspace.js +++ b/packages/webui/src/public/workspace.js @@ -1,8 +1,7 @@ // The unified workspace screens in organization mode: the sidebar's Organizations and Projects // groups (sample 01), the organization dashboard (sample 03) and the Agents levels (sample 08). // Every read goes through MosaicWorkspaceData (workspace-data.js). Nothing here writes; the bus -// restart button is console-restart.js's own control, with its gating and trail unchanged, and the -// System types chips, row and section are system-types.js's (Phase P2), with its own reads and writes. +// restart button is console-restart.js's own control, with its gating and trail unchanged. (() => { 'use strict'; if (!document.documentElement.hasAttribute('data-organizations')) return; @@ -158,7 +157,7 @@ const secH = (title, note = '', link = '') => `

    ${title}

    ${note ? `${note}` : ''}${link}
    `; function settingsList(org, ctx) { - const { business, settings, instances, orgCount, types } = 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; @@ -170,7 +169,6 @@ const rows = [ ['business', 'Identity', b ? `${label(org)} · ${org.id} · kind not recorded` : unread, 'org file', 'business'], ['flag', 'Appearance', `${titleCase(theme)} theme · ${initials(org)} avatar`, b?.appearance?.theme || org.theme ? 'org file' : 'system', 'appearance'], - ['cube', 'System types', types ? types.summary(org.id) : 'Could not be read', 'org file', types ? '@system-types' : null], ['folder', 'Projects', b ? (Array.isArray(b.projects) && b.projects.length ? plural(b.projects.length, 'project') : 'No projects') : unread, 'org file', '#/projects'], ['people', 'Agents and roles', names ? (names.length ? `${plural(names.length, 'agent')} · ${Object.values(instances).filter(running).length} running` : 'No agents') : unread, 'org file', 'agents'], ['term', 'Models and routing', models ? (models.length ? models.join(' · ') : 'No models set') : unread, 'org file', 'routing'], @@ -189,10 +187,8 @@ ['close', 'Archive or delete', "Archive or delete this organization's file", 'danger', 'organizations'], ]; return `
      ${rows.map(([ic, name, value, tag, page]) => { - const href = page === null || page.startsWith('@') ? null : page.startsWith('#') ? page : `#/settings/${page}`; - // '@system-types' is the System types section further down this page, not a route. - const go = page === '@system-types' ? `` - : href ? `Open` : ''; + const href = page === null ? null : page.startsWith('#') ? page : `#/settings/${page}`; + const go = href ? `Open` : ''; return `${icon(ic)}${esc(name)}${esc(value)}${esc(tag)}${go}`; }).join('')}
    `; } @@ -206,8 +202,7 @@ const list = Array.isArray(listRead) ? listRead : null; const org = list?.find(o => o.id === id && !o.invalid); if (!org) return `

    Organization unavailable

    ${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}

    `; - const types = window.MosaicSystemTypes || null; - const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), types ? types.load(id) : null, window.MosaicAutonomy?.load(id), window.MosaicPacks?.load(id), window.MosaicWorkflows?.load(id)]); + 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), window.MosaicAutonomy?.load(id), window.MosaicPacks?.load(id), window.MosaicWorkflows?.load(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)); @@ -228,7 +223,6 @@ const meta = `

    Kind not recordedid ${esc(id)}Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}Restart is a trailed human action; reload arrives with the cross-org update.

    `; const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid; - const typechips = types ? types.chips(id) : '
    System typesSystem types unavailable
    '; const docChips = !docRead ? 'Docs unavailable' : docRead.failed ? 'Docs unavailable' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `${icon('doc')}${esc(docName(d.path))}${esc(projectName(d.project))}`).join('') : 'No docs yet'; const docrow = `
    Docs${docChips}All docs
    `; @@ -255,7 +249,7 @@ return `
    ${av(org, 'lg')}

    ${name}

    ${bus}${reload}
    ${meta}
    - ${typechips}${docrow} + ${docrow}
    ${tiles('KPIs', 'defined in the organization file', 'No KPIs defined in the organization file')} @@ -266,8 +260,7 @@
    ${secH('Settings', 'every setting, with where its value comes from')} - ${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null, types })} - ${types ? types.section(id, { pane, idPrefix: opts.idPrefix || '' }) : ''} + ${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })} ${window.MosaicAutonomy?.section(id)??''} ${window.MosaicPacks?.section(id) ?? ''} ${window.MosaicWorkflows?.section(id) ?? ''} diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index a2f7ebb7b..a995f822b 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -21,7 +21,6 @@ import { themesHTTP } from './themes-http.mjs'; import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs'; import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs'; import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs'; -import { SYSTEM_TYPES_ROUTE, systemTypesHTTP } from './system-types-http.mjs'; import { PACKS_ROUTE, packsHTTP, packsServes } from './packs-http.mjs'; import { TEMPLATES_ROUTE, templatesHTTP } from './template-http.mjs'; import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs'; @@ -82,14 +81,11 @@ const files = new Map([ ['/editor.css', ['editor.css', 'text/css; charset=utf-8']], ['/icons.svg', ['icons.svg', 'image/svg+xml']], ['/project-remote.css', ['project-remote.css', 'text/css; charset=utf-8']], - // Phase P2 (#1873): the owner's System types control (dashboard section and project selection). ['/autonomy.js', ['autonomy.js', 'text/javascript; charset=utf-8']], ['/autonomy.css', ['autonomy.css', 'text/css; charset=utf-8']], ['/packs.js', ['packs.js', 'text/javascript; charset=utf-8']], ['/workflows.js', ['workflows.js', 'text/javascript; charset=utf-8']], ['/packs.css', ['packs.css', 'text/css; charset=utf-8']], - ['/system-types.js', ['system-types.js', 'text/javascript; charset=utf-8']], - ['/system-types.css', ['system-types.css', 'text/css; charset=utf-8']], ...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]), ...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]), ]); @@ -533,8 +529,6 @@ const AGENT_ROUTES = { // F3: the writes the owner makes on a selected organization's bus from Settings (dev org Console). const ORG_OWNER_WRITE = path => AUTONOMY_ROUTE.test(path) || path === '/api/settings' || path === '/api/instances' || path === '/api/routes' || Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/') - // Phase P2 (#1873): the System types enable switch and a project's selection; the route keeps its own checks. - || SYSTEM_TYPES_ROUTE.test(path) // Starter packs SP4 (#1873): pack apply, board and workflow writes; the route keeps its own checks. || PACKS_ROUTE.test(path) // Starter packs SP8 (#1873): saving a template; the route keeps its own checks. @@ -811,7 +805,7 @@ async function libraryStatusRead(res, read, search) { // `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for // tests; the field table is otherwise the committed packages/tasks/fields.json. // `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request. -export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, proxyAdmin = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined, agentLevels = null } = {}) { +export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, proxyAdmin = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined, agentLevels = null } = {}) { // org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host // names (without a port) a remote browser may use; only the dev owner takes them, and only with // them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback. @@ -837,8 +831,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF // #797 / #1839 Task 3: the request intake reads need --sso, the writes also --send; never the tester. if ((requestRead !== null || requestWrite !== null) && (tester || sso === null || (requestRead !== null && typeof requestRead !== 'function') || (requestWrite !== null && (send === null || typeof requestWrite !== 'function')))) throw new Error('Console requests need --sso (and --send for the writes) on the main listener'); - // Phase P2 (#1873): the owner's System types writes need --sso and --send, never the tester. - if (systemTypesWrite !== null && (tester || sso === null || send === null || typeof systemTypesWrite !== 'function')) throw new Error('System types writes need --sso and --send on the main listener'); const confirmations = createConfirmations(); const registered = routeTable(); for (const registerRoutes of routes) registerRoutes(registered); @@ -861,7 +853,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF // `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session // user's own transport (console-bus.mjs). Only a signed-in listener has user sessions. if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso'); - const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files }; + const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, upstream, files }; const hub = federationView(federation); // An object with none of the three is refused too: asking for the preview never serves without it. if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) { @@ -875,7 +867,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF let boundAddress = null, boundPort = null; const server = createServer(async (req, res) => { // A granted user's request rebinds these below; every other request keeps the owner's. - let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [], autonomyWrite = null, packsWrite = null, workflowsRead = null, templatesWrite = null; + let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, upstream, files } = owner, granted = false, chatGrants = [], autonomyWrite = null, packsWrite = null, workflowsRead = null, templatesWrite = null; res.setHeader('x-content-type-options', 'nosniff'); res.setHeader('referrer-policy', 'no-referrer'); // Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and @@ -945,7 +937,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF // alone stays open on a loopback Host for the status and release gates (Q-C). if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' }); // No CORS. JSON content type and same-origin checks keep browser forms out. - const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), systemTypesPath = SYSTEM_TYPES_ROUTE.test(path), autonomyPath = AUTONOMY_ROUTE.test(path), + const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), autonomyPath = AUTONOMY_ROUTE.test(path), // GET and HEAD /api/board stay the Board read; every other method on it is the pack route's (POST writes, the rest 405). packsPath = packsServes(path, req.method), templatesPath = TEMPLATES_ROUTE.test(path); if (req.headers.origin && req.headers.origin !== (named ? (sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`) : `http://${req.headers.host}`)) @@ -959,7 +951,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (!session && path !== '/healthz') { if (decisionPath) return decisionRefuse(res, 'sign-in-required'); if (requestPath) return requestRefuse(res, 'sign-in-required'); - if (remotePath || systemTypesPath || autonomyPath || packsPath || templatesPath) return json(res, 401, { error: 'sign-in-required' }); + if (remotePath || autonomyPath || packsPath || templatesPath) return json(res, 401, { error: 'sign-in-required' }); if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' }); res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' }); return res.end(); @@ -972,7 +964,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF const instanceAction = originalPath === '/api/console/restart'; if (session && !session.rootOwner && session.user === undefined && orgId === null && !orgList && !hook?.publicRead && !instanceAction && path !== '/api/config' && path !== '/healthz' && !files.has(path)) return json(res, 404, { error: 'not-found' }); if (session && !session.rootOwner && session.user === undefined) { - bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = autonomyWrite = packsWrite = workflowsRead = templatesWrite = upstream = null; + bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = autonomyWrite = packsWrite = workflowsRead = templatesWrite = upstream = null; } if (session?.user !== undefined && !session.rootOwner && !instanceAction) { // #1873: proxy setup answers a grant holder the way the bus answers an agent. @@ -980,7 +972,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' }); if (!organizations && userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' }); if (!organizations) ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session)); - reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null; systemTypesWrite = null; packsWrite = null; workflowsRead = null; templatesWrite = null; + reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null; packsWrite = null; workflowsRead = null; templatesWrite = null; chatGrants = sso.chatGrants?.(session) ?? []; } } @@ -1011,7 +1003,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF ({ bus = null, reads = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, - processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, autonomyWrite = null, packsWrite = null, workflowsRead = null, templatesWrite = null, upstream = null } = selected); + processing = null, decisionWrite = null, requestRead = null, requestWrite = null, autonomyWrite = null, packsWrite = null, workflowsRead = null, templatesWrite = null, upstream = null } = selected); } // F3: the owner's settings writes reach the selected organization's bus too (limits, routing, // agents, assistant, instances, model proxy). The routes below keep their own checks. @@ -1074,7 +1066,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF return projectRemoteHTTP(req, res, { path, read: requestRead, write: send ? requestWrite : null, session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin }); } - // Phase P2 (#1873): the owner's System types read and writes on the selected organization. if(autonomyPath) { if(tester || granted || !named || sso===null)return json(res,403,{error:'owner-only'}); // D1 (#1873): an organization's own autonomy writer (`autonomyWrite`, the four autonomy verbs only); @@ -1082,11 +1073,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF return autonomyHTTP(req,res,{path,read:requestRead,write:send?(autonomyWrite??requestWrite):null,session:ssoSession, live:()=>{const live=sso.session(req);return live?.rootOwner===true?live:null;},origin:localOrigin??sso.publicOrigin}); } - if (systemTypesPath) { - if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' }); - return systemTypesHTTP(req, res, { path, read: requestRead, write: send ? systemTypesWrite : null, - session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin }); - } // Starter packs SP4 (#1873): the owner's pack, board and workflow routes on the selected organization. if (packsPath) { const owner = !(tester || granted || !named || sso === null); @@ -1099,7 +1085,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF return templatesHTTP(req, res, { path, owner, read: requestRead, write: send ? templatesWrite : null, session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso?.publicOrigin }); } - if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/system-types.js', '/system-types.css', '/packs.js', '/packs.css', '/workflows.js'].includes(path)) return json(res, 403, { error: 'owner-only' }); + if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/packs.js', '/packs.css', '/workflows.js'].includes(path)) return json(res, 403, { error: 'owner-only' }); // Row 194 (#1692, D4.1): the tester also launches and terminates. if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path)) return json(res, 403, { error: 'read-only', sent: false }); diff --git a/packages/webui/src/system-types-http.mjs b/packages/webui/src/system-types-http.mjs deleted file mode 100644 index 732bb0d78..000000000 --- a/packages/webui/src/system-types-http.mjs +++ /dev/null @@ -1,140 +0,0 @@ -// Phase P2 (#1873): the owner's System types routes on a selected organization. Owner Console -// transports only; no caller-supplied business, actor or subject reaches the bus. -// -// GET /api/system-types installed modules (label, description) and the enabled set, -// plus the plugin revision when writes are on -// POST /api/system-types {revision, enabled}: plugins.set {'system-type': enabled}, -// the organization writer's owner fence, revision and receipt -// GET /api/system-types/projects/ the same, with the project's selection, active and inactive -// POST /api/system-types/projects/ {systemTypes}: project.system-types.set -// -// `read` is the owner's Console call (consoleCall): it reaches only `system-types.console`, which the -// broker answers to the business human's console cap. `write` is the owner's human transport limited -// to SYSTEM_TYPE_VERBS, present only for the owner's own session under --send. -export const SYSTEM_TYPES_ROUTE = /^\/api\/system-types(?:\/projects\/([^/]+))?$/; -export const SYSTEM_TYPE_VERBS = Object.freeze(['plugins', 'plugins.set', 'project.system-types.set']); -const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/; -// A module name, as plugins/src/kinds.mjs NAME. -const NAME = /^[a-z][a-z0-9-]{0,63}$/; -const REVISION = /^[a-f0-9]{64}$/; -const TRANSACTION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -const MAX_TYPES = 64; -const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null; -const codes = { - 'invalid-request': 400, 'invalid-plugins': 400, 'unknown-plugin': 400, 'system-type-unknown': 400, - 'project-not-found': 404, 'revision-conflict': 409, 'organization-busy': 409, 'system-type-not-enabled': 409, - 'human-required': 403, 'organization-system-types-human-only': 403, 'read-only': 403, - 'not-configured': 503, 'system-type-invalid': 503, 'no-bus-host': 503, -}; -function json(res, status, value) { - res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); - res.end(JSON.stringify(value)); -} -const names = list => Array.isArray(list) && list.length <= MAX_TYPES && list.every(x => typeof x === 'string' && NAME.test(x)) && new Set(list).size === list.length; -const text = value => typeof value === 'string' && value.trim() !== '' && value.length <= 400 && !/[\r\n]/.test(value); -function consoleShape(result, project) { - if (!result || !Array.isArray(result.installed) || !names(result.enabled)) return null; - const installed = []; - for (const m of result.installed) { - if (!m || !NAME.test(m.id ?? '') || !text(m.label) || !text(m.description)) return null; - installed.push({ id: m.id, label: m.label, description: m.description }); - } - if (new Set(installed.map(m => m.id)).size !== installed.length) return null; - let selection = null; - if (project !== null) { - const p = result.project; - if (!p || p.project !== project || !names(p.selected) || !names(p.active) || !names(p.inactive)) return null; - selection = { project, selected: p.selected, active: p.active, inactive: p.inactive }; - } else if (result.project !== null) return null; - return { installed, enabled: result.enabled, project: selection }; -} -const selectionShape = (r, project) => r && r.project === project && names(r.selected) && names(r.active) && names(r.inactive) - ? { project, selected: r.selected, active: r.active, inactive: r.inactive } : null; - -async function body(req, keys) { - if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return null; - const chunks = []; let size = 0; - for await (const chunk of req) { size += chunk.length; if (size > 4096) return 'too-large'; chunks.push(chunk); } - let value; - try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return null; } - if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length || !keys.every(k => Object.hasOwn(value, k))) return null; - return value; -} - -export async function systemTypesHTTP(req, res, { path, read, write, session, live, origin }) { - const refuse = (status, error) => json(res, status, { error }); - const fail = error => { const code = codes[error?.code] ? error.code : 'outcome-unknown'; return refuse(codes[code] ?? 503, code); }; - if (!['GET', 'POST'].includes(req.method)) return refuse(405, 'method-not-allowed'); - if (!session) return refuse(401, 'sign-in-required'); - if (!read) return refuse(503, 'not-configured'); - if (req.method === 'POST' && !write) return refuse(403, 'read-only'); - const site = one(req, 'sec-fetch-site'), from = one(req, 'origin'); - if (req.method === 'POST' ? from !== origin || site !== 'same-origin' - : (req.headersDistinct.origin && from !== origin) || (req.headersDistinct['sec-fetch-site'] && !['same-origin', 'none'].includes(site))) - return refuse(403, 'cross-origin'); - let project = null; - const match = SYSTEM_TYPES_ROUTE.exec(path); - if (match?.[1] !== undefined) { - try { project = decodeURIComponent(match[1]); } catch { return refuse(404, 'project-not-found'); } - if (!ID.test(project)) return refuse(404, 'project-not-found'); - } - let args = null; - if (req.method === 'POST') { - const value = await body(req, project === null ? ['revision', 'enabled'] : ['systemTypes']); - if (value === 'too-large') return refuse(413, 'invalid-request'); - if (!value) return refuse(400, 'invalid-request'); - if (project === null ? typeof value.revision !== 'string' || !REVISION.test(value.revision) || !names(value.enabled) : !names(value.systemTypes)) - return refuse(400, 'invalid-request'); - args = value; - } - if (live() !== session) return refuse(401, 'sign-in-required'); - const subject = { provider: session.provider, iss: session.iss, sub: session.sub }; - // The read comes first on every request: a write names only modules this organization has installed. - let current; - try { current = consoleShape(await read('system-types.console', project === null ? {} : { project }, { subject }), project); } - catch (error) { return fail(error); } - if (!current) return refuse(503, 'outcome-unknown'); - const installed = new Set(current.installed.map(m => m.id)); - - if (req.method === 'GET') { - let revision = null; - if (write && project === null) { - // The enabled set and its revision come from one plugins read. The page sends back a whole - // replacement list, so a list older than its revision would undo a change made in between. - let plugins; - try { plugins = await write('plugins', {}); } catch (error) { return fail(error); } - revision = plugins?.revision ?? null; - const enabled = plugins?.plugins?.['system-type'] ?? []; - if (!REVISION.test(revision ?? '') || !names(enabled)) return refuse(503, 'outcome-unknown'); - current.enabled = enabled; - } - if (live() !== session) return refuse(401, 'sign-in-required'); - return json(res, 200, { ...current, writable: Boolean(write), ...(project === null && write ? { revision } : {}) }); - } - - // The session is checked again after the read: a sign-out while it ran dispatches no write. - if (live() !== session) return refuse(401, 'sign-in-required'); - - if (project === null) { - if (args.enabled.some(id => !installed.has(id))) return refuse(400, 'system-type-unknown'); - let result; - try { result = await write('plugins.set', { revision: args.revision, plugins: { 'system-type': args.enabled } }); } - catch (error) { return fail(error); } - // A write admitted at spawn may already have committed: its receipt is returned even if the - // session ended meanwhile, never reported as not done. - const enabled = result?.plugins?.['system-type'] ?? []; - if (!result || !REVISION.test(result.revision ?? '') || !TRANSACTION.test(result.transaction ?? '') || !names(enabled) - || JSON.stringify(enabled) !== JSON.stringify(args.enabled)) - return refuse(503, 'outcome-unknown'); - return json(res, 200, { enabled, revision: result.revision, transaction: result.transaction }); - } - - // The broker refuses a type the organization has not enabled (system-type-not-enabled); a type that - // is not installed is refused here first. - if (args.systemTypes.some(id => !installed.has(id))) return refuse(400, 'system-type-unknown'); - let result; - try { result = selectionShape(await write('project.system-types.set', { project, systemTypes: args.systemTypes }), project); } - catch (error) { return fail(error); } - if (!result || JSON.stringify(result.selected) !== JSON.stringify(args.systemTypes)) return refuse(503, 'outcome-unknown'); - return json(res, 200, result); -} diff --git a/packages/webui/tests/autonomy-browser.test.mjs b/packages/webui/tests/autonomy-browser.test.mjs index 2126390be..3fe88cc28 100644 --- a/packages/webui/tests/autonomy-browser.test.mjs +++ b/packages/webui/tests/autonomy-browser.test.mjs @@ -16,7 +16,6 @@ for(const [width,height]of [[1440,900],[390,844]])for(const theme of ['light','d if(verb==='autonomy.set.console'){assert.equal(args.revision,revision);writes++;setting=args.autonomy;revision='b'.repeat(64);return {revision,transaction:'10000000-0000-4000-8000-000000000001'};} if(verb==='decision.confirm.console'){if(args.confirm)confirms++;else{declines++;assert.equal(args.reason,'Revise the plan');}return {op:args.confirm?'confirmed':'declined'};} if(verb==='decision.reverse.console'){reverses++;return {reversed:true};} - if(verb==='system-types.console')return {installed:[],enabled:[],project:null}; throw Object.assign(new Error('not-configured'),{code:'not-configured'}); }; const ids=['10000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000002','10000000-0000-4000-8000-000000000003']; diff --git a/packages/webui/tests/browser.test.mjs b/packages/webui/tests/browser.test.mjs index 752c6977a..eb293555c 100644 --- a/packages/webui/tests/browser.test.mjs +++ b/packages/webui/tests/browser.test.mjs @@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4'); await b.evaluate('document.querySelector("#hide-offline").click()'); assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4); - // Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it. P2 (#1873) adds system-types.js after project-remote.js. - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/system-types.js', '/autonomy.js', '/packs.js', '/workflows.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']); + // Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it. + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/autonomy.js', '/packs.js', '/workflows.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1); assert.equal(await b.evaluate('!!window.injected'), false); diff --git a/packages/webui/tests/helpers/system-types-fake.mjs b/packages/webui/tests/helpers/system-types-fake.mjs deleted file mode 100644 index 784b9ec79..000000000 --- a/packages/webui/tests/helpers/system-types-fake.mjs +++ /dev/null @@ -1,42 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { installedSystemTypes } from '../../../plugins/src/system-types.mjs'; - -// Phase P2 (#1873): a per-organization stand-in for the two transports the System types routes use, -// shaped as the broker answers them. `requestRead` answers system-types.console with the installed -// modules (the plug-in data itself, read from packages/plugins), the enabled set and a project's -// selection. `systemTypesWrite` answers plugins, plugins.set (revision checked, a transaction -// returned) and project.system-types.set (enabled types only). `owner: false` makes plugins.set -// refuse as the organization writer's owner fence does for anyone but the human. -export async function systemTypesFake({ enabled = [], projects = {}, owner = true } = {}) { - const installed = await installedSystemTypes(); - const state = { enabled: [...enabled], projects: structuredClone(projects), n: 0, writes: [] }; - const revision = () => createHash('sha256').update(`system-types-${state.n}`).digest('hex'); - const fail = code => { throw Object.assign(new Error(code), { code }); }; - const selection = project => { - const selected = state.projects[project]; - return { project, selected: [...selected], active: selected.filter(x => state.enabled.includes(x)), inactive: selected.filter(x => !state.enabled.includes(x)) }; - }; - const requestRead = async (verb, args) => { - if (verb !== 'system-types.console') fail('invalid-request'); - if (args.project !== undefined && !Object.hasOwn(state.projects, args.project)) fail('project-not-found'); - return { installed: structuredClone(installed), enabled: [...state.enabled], project: args.project === undefined ? null : selection(args.project) }; - }; - const systemTypesWrite = async (verb, args) => { - state.writes.push(verb); - if (verb === 'plugins') return { business: 'fake', revision: revision(), plugins: { 'system-type': [...state.enabled] } }; - if (verb === 'plugins.set') { - if (args.revision !== revision()) fail('revision-conflict'); - if (!owner) fail('organization-system-types-human-only'); - state.enabled = [...args.plugins['system-type']]; state.n++; - return { business: 'fake', revision: revision(), plugins: { 'system-type': [...state.enabled] }, transaction: randomUUID() }; - } - if (verb === 'project.system-types.set') { - if (!Object.hasOwn(state.projects, args.project)) fail('project-not-found'); - if (args.systemTypes.some(x => !state.enabled.includes(x))) fail('system-type-not-enabled'); - state.projects[args.project] = [...args.systemTypes]; - return selection(args.project); - } - fail('invalid-request'); - }; - return { requestRead, systemTypesWrite, state, installed }; -} diff --git a/packages/webui/tests/org-archive-browser.test.mjs b/packages/webui/tests/org-archive-browser.test.mjs index 08f1da0ed..a4fbe6576 100644 --- a/packages/webui/tests/org-archive-browser.test.mjs +++ b/packages/webui/tests/org-archive-browser.test.mjs @@ -14,7 +14,6 @@ import { busStopForDelete } from '../../cli/src/org-bus.mjs'; import { CliError } from '../../cli/src/errors.mjs'; import { actionTrail } from '../src/action-trail.mjs'; import { browser } from './browser.mjs'; -import { systemTypesFake } from './helpers/system-types-fake.mjs'; import { close } from './fixture.mjs'; // E1: `bus` adds verbs to the admin's bus (busStart for a restore); `trail` gives the Console its action trail; @@ -146,10 +145,9 @@ test('E1 (P1-4): restoring the only organization reloads into it and still shows assert.equal(await f.b.evaluate(`Object.keys(sessionStorage).filter(k=>k.startsWith('mosaic-org-restored:')).length`), 0); }); -test('E1 (P1-5): after restoring another organization, its dashboard shows the bus running and its System types', { timeout: 45000 }, async t => { - const health = { up: false }, bus = startingBus(health), st = await systemTypesFake({ enabled: ['coding'] }); +test('E1 (P1-5): after restoring another organization, its dashboard shows the bus running and its organization file', { timeout: 45000 }, async t => { + const health = { up: false }, bus = startingBus(health); const extra = id => ({ - requestRead: st.requestRead, reads: { business: async () => ({ business: { id, human: 'local-dev-owner', arbiters: [], projects: [], vars: {}, instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, bus: { instances: async () => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }), messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] }, }); @@ -158,8 +156,8 @@ test('E1 (P1-5): after restoring another organization, its dashboard shows the b await f.wait(`/Bus started\. It answers\./.test(document.querySelector('#org-archive-success')?.innerText || '')`); await f.wait(`!!document.querySelector('#workspace-nav [data-workspace-org=beta]')`); await f.b.evaluate(`location.hash = '#/orgs/beta/organization'`); - await f.wait(`/bus running, read/.test(document.querySelector('[data-org-dashboard]')?.innerText || '') && !!document.querySelector('[data-org-dashboard] [data-system-type-chips] .ws-chip')`); - assert.doesNotMatch(await f.b.evaluate(`document.querySelector('[data-org-dashboard] [data-system-type-chips]').textContent`), /unavailable/); + await f.wait(`/bus running, read/.test(document.querySelector('[data-org-dashboard]')?.innerText || '')`); + assert.match(await f.b.evaluate(`document.querySelector('[data-org-dashboard] [data-org-setting="Identity"] .v').textContent`), / · beta · /); assert.deepEqual(bus.starts.map(([id]) => id), ['beta']); }); diff --git a/packages/webui/tests/org-refresh-browser.test.mjs b/packages/webui/tests/org-refresh-browser.test.mjs index a94f2cc83..367a22c25 100644 --- a/packages/webui/tests/org-refresh-browser.test.mjs +++ b/packages/webui/tests/org-refresh-browser.test.mjs @@ -1,5 +1,5 @@ // E1 (P1-5): pages follow a state change without a reload. Restart bus redraws the organization -// dashboard's bus line and System types at once, and Quick Setup puts the new organization in the +// dashboard's bus line and its settings at once, and Quick Setup puts the new organization in the // sidebar. Neither waits for the 10 s repaint: each check runs well inside that window. import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -13,16 +13,14 @@ import { actionTrail } from '../src/action-trail.mjs'; import * as service from '../../organization/src/org-service.mjs'; import { browser } from './browser.mjs'; import { close } from './fixture.mjs'; -import { systemTypesFake } from './helpers/system-types-fake.mjs'; const waitFor = (b, tries = 150) => expression => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); // One organization whose bus state the fake bus below owns. A stopped bus has no reads, as -// organizations.mjs resolves it; a running one answers the dashboard's reads and System types. +// organizations.mjs resolves it; a running one answers the dashboard's reads. async function westOrgs(state) { - const st = await systemTypesFake({ enabled: ['coding'] }); const running = () => ({ - busHealthy: true, requestRead: st.requestRead, systemTypesWrite: null, + busHealthy: true, reads: { business: async () => ({ business: { id: 'west', human: 'jason', arbiters: [], projects: [], vars: {}, instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, bus: { instances: async () => ({ business: 'west', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }), messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] }, chat: { projects: async () => [], threads: async () => [] }, @@ -33,7 +31,7 @@ async function westOrgs(state) { }; } -test('E1 (P1-5b): Restart bus redraws the dashboard bus line and System types without a reload or the 10 s repaint', { timeout: 45000 }, async t => { +test('E1 (P1-5b): Restart bus redraws the dashboard bus line and settings without a reload or the 10 s repaint', { timeout: 45000 }, async t => { const state = { running: false }; const starts = []; const bus = { busStatus: async (ctx, id) => ({ org: id, healthy: state.running, pid: state.running ? 4242 : null }), @@ -47,16 +45,16 @@ test('E1 (P1-5b): Restart bus redraws the dashboard bus line and System types wi const wait = waitFor(b); await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/west/organization`); await wait('document.querySelector("[data-org-dashboard] [data-bus-restart]") && /bus unavailable, read/.test(document.querySelector("[data-org-dashboard]").innerText)'); - assert.match(await b.evaluate('document.querySelector("[data-org-dashboard] [data-system-type-chips]").textContent'), /System types unavailable/); + assert.equal(await b.evaluate('document.querySelector("[data-org-dashboard] [data-org-setting=\\"Identity\\"] .v").textContent'), 'Could not be read'); // The page's own repaint is 10 s after its last draw; mark the draw this test starts from. await b.evaluate('window.__drawn = Date.now(); window.__first = document.querySelector("[data-org-dashboard]")'); await b.evaluate('document.querySelector("[data-org-dashboard] [data-bus-restart]").click()'); await wait('/It answers/.test(document.querySelector("[data-org-dashboard] [data-bus-restart-receipt]")?.textContent || "")'); - // No reload: the page keeps its globals, and the dashboard shows the running bus and the enabled type. - await wait('/bus running, read/.test(document.querySelector("[data-org-dashboard]")?.innerText || "") && !!document.querySelector("[data-org-dashboard] [data-system-type-chips] .ws-chip")'); + // No reload: the page keeps its globals, and the dashboard shows the running bus and its organization file. + await wait('/bus running, read/.test(document.querySelector("[data-org-dashboard]")?.innerText || "")'); assert.ok(await b.evaluate('Date.now() - window.__drawn') < 8000, 'the dashboard redrew before the 10 s repaint'); assert.equal(await b.evaluate('typeof window.__first'), 'object', 'the page was not reloaded'); - assert.doesNotMatch(await b.evaluate('document.querySelector("[data-org-dashboard] [data-system-type-chips]").textContent'), /unavailable/); + assert.match(await b.evaluate('document.querySelector("[data-org-dashboard] [data-org-setting=\\"Identity\\"] .v").textContent'), /^West · west · /); assert.match(await b.evaluate('document.querySelector("[data-org-dashboard] [data-bus-restart-receipt]").textContent'), /^Bus started at .*It answers\./); assert.deepEqual(starts.map(([id]) => id), ['west']); }); diff --git a/packages/webui/tests/packs-workflows-browser.test.mjs b/packages/webui/tests/packs-workflows-browser.test.mjs index e0ffe67ef..95fe36aae 100644 --- a/packages/webui/tests/packs-workflows-browser.test.mjs +++ b/packages/webui/tests/packs-workflows-browser.test.mjs @@ -52,7 +52,6 @@ async function fixture(t, { send = true } = {}) { if (verb === 'packs.console') return { packs: PACKS }; if (verb === 'pack.preview.console') return { ...PREVIEW, project: args.project ?? null }; if (verb === 'autonomy.get.console') return { revision: REV('e'), autonomy: {}, levels: {} }; - if (verb === 'system-types.console') return { installed: [], enabled: [], project: null }; throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); }; const write = async (verb, args) => { diff --git a/packages/webui/tests/side-by-side-browser.test.mjs b/packages/webui/tests/side-by-side-browser.test.mjs index 84bc9496c..6f7e3ac90 100644 --- a/packages/webui/tests/side-by-side-browser.test.mjs +++ b/packages/webui/tests/side-by-side-browser.test.mjs @@ -407,7 +407,7 @@ test('the same project, dashboard and Agents page in both panes draw no duplicat await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`); const both = route => `location.hash = '#/orgs/ddigital/side-by-side?a=ddigital${route}&b=ddigital${route}'`; const pages = [ - ['/organization', `${title('a')}.startsWith('Dashboard') && ${title('b')}.startsWith('Dashboard') && document.querySelectorAll('[data-pane-body] [data-system-types-heading]').length === 2`], + ['/organization', `${title('a')}.startsWith('Dashboard') && ${title('b')}.startsWith('Dashboard') && document.querySelectorAll('[data-pane-body] [data-org-settings]').length === 2`], ['/agents', `${title('a')}.startsWith('Agents') && ${title('b')}.startsWith('Agents') && document.querySelectorAll('[data-pane-body] [data-agents-available] tr[data-agent]').length >= 2`], [`/projects/${P}`, `${title('a')}.startsWith('Pomodoro Timer') && ${title('b')}.startsWith('Pomodoro Timer') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`], [`/projects/${P}/conversations`, `${title('a')}.includes('Conversations') && ${title('b')}.includes('Conversations')`], @@ -425,8 +425,8 @@ test('the same project, dashboard and Agents page in both panes draw no duplicat drawn[page] = await b.evaluate(`[...document.querySelectorAll('#pane-a [data-pane-body] [id]')].map(e => e.id)`); } assert.deepEqual(found, Object.fromEntries(pages.map(([route]) => [route.replace(P, 'P'), { duplicates: [], stray: [] }]))); - // The audit is not vacuous: the dashboard draws its system types heading and the requirements page - // its request select, north star and PRD headings, each with the pane's prefix. - assert.deepEqual(drawn['/organization'], ['pane-a-ws-reload-why', 'pane-a-ws-types-h']); + // The audit is not vacuous: the dashboard draws its reload note and the requirements page its + // request select, north star and PRD headings, each with the pane's prefix. + assert.deepEqual(drawn['/organization'], ['pane-a-ws-reload-why']); assert.deepEqual(drawn['/projects/P/requirements'], ['pane-a-project-request-select', 'pane-a-project-north-star', 'pane-a-project-prd']); }); diff --git a/packages/webui/tests/system-types-browser.test.mjs b/packages/webui/tests/system-types-browser.test.mjs deleted file mode 100644 index a9966d9cf..000000000 --- a/packages/webui/tests/system-types-browser.test.mjs +++ /dev/null @@ -1,244 +0,0 @@ -// Phase P2 (#1873): System types on the organization dashboard (sample 03) and on a project's information -// page. The Console, its per-organization routes and the pages are real; only the two bus transports are -// the fake in helpers/system-types-fake.mjs, which reads the installed modules from packages/plugins. The -// server-side admission checks and the real organization writer are in system-types-http.test.mjs. -// MOSAIC_UX_SHOTS= also writes light and dark shots at 1440x900 and 390x844. -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { startServer } from '../src/serve.mjs'; -import { createDevOwner } from '../src/dev-owner.mjs'; -import { browser } from './browser.mjs'; -import { close } from './fixture.mjs'; -import { systemTypesFake } from './helpers/system-types-fake.mjs'; - -const P = '10000000-0000-4000-8000-0000000000b1'; -const RECEIPT = /Receipt ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\./; -const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)}+' | '+(document.querySelector('main')?.innerText||'').slice(0,1500))})()`); - -// One running organization with one project. `send` is the owner's signed-in send mode; without it the -// Console only reads and the route gets no writer. -function organizations(st, { send = true } = {}) { - const business = { business: { id: 'acme', human: 'jason', arbiters: [], projects: [P], vars: {}, instances: [], appearance: { theme: null } } }; - return { - list: async () => [{ id: 'acme', label: 'Acme', theme: null, busHealthy: true }], - resolve: async id => id !== 'acme' ? null : ({ - busHealthy: true, requestRead: st.requestRead, systemTypesWrite: send ? st.systemTypesWrite : null, ...(send ? { send: async () => ({}) } : {}), - reads: { business: async () => business, settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, - bus: { instances: async () => ({ business: 'acme', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }), - messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] }, - chat: { projects: async () => [{ id: P, name: 'Work', repo: null }], threads: async () => [] }, - }), - }; -} - -async function page(t, st, { send = true, width = 1440, height = 900 } = {}) { - const server = await startServer({ port: 0, sso: createDevOwner(), ...(send ? { send: async () => ({}) } : {}), organizations: organizations(st, { send }), log: () => {} }); - t.after(() => close(server)); - const b = await browser({ machine: false }); t.after(() => b.close()); - const errors = []; - b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'exception')); - await b.viewport(width, height); - const base = `http://127.0.0.1:${server.address().port}/`; - return { b, base, wait: waitFor(b), errors }; -} -const dashboard = async ({ b, base, wait }) => { - await b.navigate(`${base}#/orgs/acme/organization`); - await wait('document.querySelector("[data-org-dashboard] [data-system-types]") && !/Reading/.test(document.querySelector("[data-system-types]").textContent)'); -}; -const settings = async ({ b, base, wait }) => { - await b.navigate(`${base}#/orgs/acme/projects/${P}/settings`); - await wait('document.querySelector("[data-project-system-types]") && !/Reading system types/.test(document.querySelector("[data-project-system-types]").textContent)'); -}; -const text = (b, selector) => b.evaluate(`(document.querySelector(${JSON.stringify(selector)})?.innerText ?? '').replace(/\\s+/g, ' ').trim()`); -const chips = b => b.evaluate(`[...document.querySelectorAll('[data-system-type-chips] .ws-chip')].map(c => c.textContent)`); -const click = (b, selector) => b.evaluate(`(document.querySelector(${JSON.stringify(selector)}).click(), true)`); - -test('the dashboard lists the installed modules from the plug-in data and the owner switches one with a receipt', { timeout: 60000 }, async t => { - const st = await systemTypesFake({ enabled: ['coding'], projects: { [P]: [] } }); - const p = await page(t, st); const { b, wait } = p; - await dashboard(p); - // Every row is a module the plug-ins declare, in their order, with their label and description. - const rows = await b.evaluate(`[...document.querySelectorAll('[data-system-types] li')].map(li => [li.dataset.systemType, li.querySelector('.t b').textContent, li.querySelector('.t .d').textContent, li.querySelector('.ws-dot').textContent.replace(/[●–]/, '')])`); - assert.deepEqual(rows, st.installed.map(m => [m.id, m.label, m.description, m.id === 'coding' ? 'enabled' : 'off'])); - assert.ok(st.installed.length >= 2, 'the tree installs more than one system type module'); - assert.deepEqual(await chips(b), ['Coding']); - assert.match(await text(b, '[data-system-type-chips]'), /Each project picks from these; none is required\.$/); - assert.match(await text(b, '[data-org-setting="System types"]'), new RegExp(`1 of ${st.installed.length} installed enabled`)); - // The Settings row's Open moves focus to the section heading. - await click(b, '[data-org-setting="System types"] [data-system-types-open]'); - assert.equal(await b.evaluate('document.activeElement?.dataset.systemTypesHeading'), ''); - - // Enable Marketing from the keyboard; the receipt is the organization writer's transaction. - await b.evaluate(`document.querySelector('[data-system-type-toggle="marketing"]').focus(), true`); - assert.equal(await b.evaluate(`document.activeElement.getAttribute('aria-label')`), 'Enable Marketing'); - await b.key('Enter'); - await wait(`/Receipt/.test(document.querySelector('[data-system-types-status]')?.textContent)`); - assert.match(await text(b, '[data-system-types-status]'), new RegExp(`^Marketing enabled\\. ${RECEIPT.source}$`)); - assert.deepEqual(st.state.enabled, ['coding', 'marketing']); - assert.deepEqual(await chips(b), ['Coding', 'Marketing']); - assert.match(await text(b, '[data-org-setting="System types"]'), new RegExp(`2 of ${st.installed.length} installed enabled`)); - // Focus stays on the switch it pressed, which now says Disable. - assert.deepEqual(await b.evaluate(`[document.activeElement.dataset.focusKey, document.activeElement.getAttribute('aria-label')]`), ['st:marketing', 'Disable Marketing']); - - // Disable Coding; another write changed the organization file meanwhile, so the first try is refused. - st.state.n++; - await click(b, '[data-system-type-toggle="coding"]'); - await wait(`/changed after this page read it/.test(document.querySelector('[data-system-types-status]')?.textContent)`); - assert.deepEqual(st.state.enabled, ['coding', 'marketing']); - await click(b, '[data-system-type-toggle="coding"]'); - await wait(`/Coding disabled/.test(document.querySelector('[data-system-types-status]')?.textContent)`); - assert.match(await text(b, '[data-system-types-status]'), RECEIPT); - assert.deepEqual(st.state.enabled, ['marketing']); - assert.equal(await b.evaluate(`document.querySelector('[data-system-type="coding"] .ws-dot').textContent.replace(/[●–]/, '')`), 'off'); - assert.deepEqual(p.errors, []); -}); - -test('without the owner proof the switch is refused, and a read-only Console offers none', { timeout: 60000 }, async t => { - // The writer's owner fence refuses: the page says so and nothing changes. - const fenced = await systemTypesFake({ enabled: ['coding'], projects: { [P]: [] }, owner: false }); - const p = await page(t, fenced); const { b, wait } = p; - await dashboard(p); - await click(b, '[data-system-type-toggle="marketing"]'); - await wait(`/owner can change/.test(document.querySelector('[data-system-types-status]')?.textContent)`); - assert.equal(await text(b, '[data-system-types-status]'), "Only the organization's owner can change system types."); - assert.deepEqual(fenced.state.enabled, ['coding']); - assert.doesNotMatch(await text(b, '[data-system-types]'), /Receipt/); - - // A Console without send mode reads the list and shows no switch; no write reaches the transport. - const readOnly = await systemTypesFake({ enabled: ['coding'], projects: { [P]: ['coding'] } }); - const q = await page(t, readOnly, { send: false }); - await dashboard(q); - assert.equal(await q.b.evaluate(`document.querySelectorAll('[data-system-types] li').length`), readOnly.installed.length); - assert.equal(await q.b.evaluate(`document.querySelectorAll('[data-system-type-toggle]').length`), 0); - assert.match(await text(q.b, '[data-system-types]'), /This Console only reads\. Changing system types needs signed-in send mode\./); - // The project page shows the selection read only too. - await settings(q); - assert.equal(await q.b.evaluate(`[...document.querySelectorAll('[data-project-system-types] input')].every(i => i.disabled)`), true); - assert.equal(await q.b.evaluate(`document.querySelector('[data-project-system-types] button[type=submit]')`), null); - assert.deepEqual(readOnly.state.writes, []); -}); - -test('a project selects only enabled modules and a disabled selection shows as inactive', { timeout: 60000 }, async t => { - // Montage was selected, then disabled in the organization. - const st = await systemTypesFake({ enabled: ['coding', 'marketing'], projects: { [P]: ['montage'] } }); - const p = await page(t, st); const { b, wait } = p; - await settings(p); - const label = id => st.installed.find(m => m.id === id).label; - assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-project-system-types] input[name=system-type]')].map(i => [i.value, i.checked, i.closest('label').querySelector('strong').textContent])`), - [['coding', false, label('coding')], ['marketing', false, label('marketing')]]); - assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-inactive-type]')].map(li => [li.dataset.inactiveType, li.innerText.replace(/\\s+/g, ' ').trim()])`), [['montage', `${label('montage')} inactive`]]); - assert.match(await text(b, '[data-project-system-types-inactive]'), /Saving keeps only enabled types/); - - // Pick Marketing and save: the inactive one goes, the saved selection is the bus's answer. - await b.evaluate(`document.querySelector('[data-project-system-types] input[value=marketing]').click(), true`); - await click(b, '[data-project-system-types] button[type=submit]'); - await wait(`/Saved/.test(document.querySelector('[data-project-system-types-status]')?.textContent)`); - assert.equal(await text(b, '[data-project-system-types-status]'), `Saved. Selected: ${label('marketing')}.`); - assert.equal(await b.evaluate(`document.activeElement?.hasAttribute('data-project-system-types-status')`), true); - assert.deepEqual(st.state.projects[P], ['marketing']); - assert.equal(await b.evaluate(`document.querySelector('[data-inactive-type]')`), null); - - // The organization disables Coding after the page read it; saving it is refused and the list is read again. - await b.evaluate(`document.querySelector('[data-project-system-types] input[value=coding]').click(), true`); - st.state.enabled = ['marketing']; - await click(b, '[data-project-system-types] button[type=submit]'); - await wait(`/not enabled in the organization/.test(document.querySelector('[data-project-system-types-status]')?.textContent)`); - assert.deepEqual(st.state.projects[P], ['marketing']); - assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-project-system-types] input[name=system-type]')].map(i => i.value)`), ['marketing']); - - // The owner disables Marketing on the dashboard; back on the project it stays selected, shown inactive. - await dashboard(p); - await click(b, '[data-system-type-toggle="marketing"]'); - await wait(`/Marketing disabled/.test(document.querySelector('[data-system-types-status]')?.textContent)`); - assert.deepEqual(st.state.enabled, []); - await settings(p); - assert.match(await text(b, '[data-project-system-types]'), /The organization has no system types enabled\./); - assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-inactive-type]')].map(li => li.dataset.inactiveType)`), ['marketing']); - assert.equal(await b.evaluate(`document.querySelectorAll('[data-project-system-types] input').length`), 0); - // With nothing enabled the owner can still clear the inactive selection, without enabling a type first. - assert.equal(await text(b, '[data-project-system-types] button[type=submit]'), 'Clear the inactive types'); - await click(b, '[data-project-system-types] button[type=submit]'); - await wait(`/Saved/.test(document.querySelector('[data-project-system-types-status]')?.textContent)`); - assert.equal(await text(b, '[data-project-system-types-status]'), 'Saved. No system types selected.'); - assert.deepEqual(st.state.projects[P], []); - assert.deepEqual(st.state.enabled, []); - assert.equal(await b.evaluate(`document.querySelector('[data-inactive-type]')`), null); - assert.equal(await b.evaluate(`document.querySelector('[data-project-system-types] button[type=submit]')`), null); - assert.deepEqual(p.errors, []); - - // A read-only Console shows the same inactive selection and offers no clear button. - const readOnly = await systemTypesFake({ enabled: [], projects: { [P]: ['coding'] } }); - const q = await page(t, readOnly, { send: false }); - await settings(q); - assert.deepEqual(await q.b.evaluate(`[...document.querySelectorAll('[data-inactive-type]')].map(li => li.dataset.inactiveType)`), ['coding']); - assert.equal(await q.b.evaluate(`document.querySelector('[data-project-system-types] button[type=submit]')`), null); - assert.deepEqual(readOnly.state.writes, []); -}); - -test('the dashboard section and the project page fit a phone screen in light and dark', { timeout: 90000 }, async t => { - const st = await systemTypesFake({ enabled: ['coding', 'marketing'], projects: { [P]: ['coding', 'montage'] } }); - const shots = process.env.MOSAIC_UX_SHOTS; - if (shots) mkdirSync(shots, { recursive: true }); - for (const [width, height] of [[1440, 900], [390, 844]]) { - const p = await page(t, st, { width, height }); const { b } = p; - for (const mode of ['light', 'dark']) { - await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: mode }] }); - await dashboard(p); - // The default appearance is Harbor in the dark whatever the OS says; the header switch picks the mode. - if (await b.evaluate(`document.getElementById('theme-switch').getAttribute('aria-checked')`) !== String(mode === 'dark')) { - await b.evaluate(`document.getElementById('theme-switch').click(), true`); - await p.wait(`document.getElementById('theme-switch').getAttribute('aria-checked') === '${mode === 'dark'}'`); - } - await b.evaluate(`document.querySelector('[data-system-types-heading]').scrollIntoView({ block: 'start' }), true`); - assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, `dashboard ${width} ${mode}`); - assert.equal(await b.evaluate(`[...document.querySelectorAll('[data-system-types] li')].every(li => li.scrollWidth <= li.clientWidth)`), true, `dashboard rows ${width} ${mode}`); - if (shots) await b.screenshot(join(shots, `p2-dashboard-types-${width}-${mode}.png`)); - await settings(p); - await b.evaluate(`document.querySelector('[data-project-system-types]').scrollIntoView({ block: 'start' }), true`); - // At 1440 a project page taller than the window already overflows by its header's primary action - // (projects.css, before P2), so the wide check is the section's own box; the phone check is the page. - if (width === 390) assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, `project ${width} ${mode}`); - assert.equal(await b.evaluate(`(s => s.scrollWidth <= s.clientWidth && s.getBoundingClientRect().right <= document.documentElement.clientWidth)(document.querySelector('[data-project-system-types]'))`), true, `project section ${width} ${mode}`); - assert.equal(await b.evaluate(`[...document.querySelectorAll('[data-project-system-types] .project-types-option')].every(o => o.getBoundingClientRect().height >= 44)`), true, `targets ${width} ${mode}`); - if (shots) await b.screenshot(join(shots, `p2-project-types-${width}-${mode}.png`)); - } - assert.deepEqual(p.errors, []); - } -}); - -// U5's Side by side draws an organization dashboard into a pane. Each pane reads its own organization's -// system types; a pane only shows them (the switch stays in the organization's full view, as the bus -// restart does), and its Settings row opens the section in the same pane. -test('a side-by-side pane shows its own organization\'s system types and offers no switch', { timeout: 60000 }, async t => { - const acme = await systemTypesFake({ enabled: ['coding'], projects: { [P]: [] } }); - const beta = await systemTypesFake({ enabled: ['marketing'], projects: {} }); - const one = organizations(acme); - const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: { - list: async () => [{ id: 'acme', label: 'Acme', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: true }], - resolve: async id => id === 'acme' ? one.resolve('acme') : id === 'beta' ? { ...(await one.resolve('acme')), requestRead: beta.requestRead, systemTypesWrite: beta.systemTypesWrite, - chat: { projects: async () => [], threads: async () => [] } } : null, - } }); - t.after(() => close(server)); - const b = await browser({ machine: false }); t.after(() => b.close()); - const errors = []; - b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'exception')); - await b.viewport(1440, 900); - const wait = waitFor(b); - await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/side-by-side?a=acme/organization&b=beta/organization`); - const ready = key => `document.querySelector('#pane-${key} [data-system-types]') && !/Reading/.test(document.querySelector('#pane-${key} [data-system-types]').textContent)`; - await wait(`${ready('a')} && ${ready('b')}`); - const paneChips = key => b.evaluate(`[...document.querySelectorAll('#pane-${key} [data-system-type-chips] .ws-chip')].map(c => c.textContent)`); - assert.deepEqual(await paneChips('a'), ['Coding']); - assert.deepEqual(await paneChips('b'), ['Marketing']); - assert.equal(await b.evaluate(`document.querySelectorAll('.panes [data-system-type-toggle]').length`), 0); - for (const key of ['a', 'b']) - assert.match(await text(b, `#pane-${key} [data-system-types]`), /Change system types from this organization's full view\./); - const ids = await b.evaluate(`[...document.querySelectorAll('[data-system-types-heading]')].map(h => h.id)`); - assert.equal(ids.length, 2); assert.equal(new Set(ids).size, 2); - await click(b, '#pane-b [data-org-setting="System types"] [data-system-types-open]'); - assert.equal(await b.evaluate(`document.activeElement === document.querySelector('#pane-b [data-system-types-heading]')`), true); - assert.deepEqual([acme.state.writes.includes('plugins.set'), beta.state.writes.includes('plugins.set')], [false, false]); - assert.deepEqual(errors, []); -}); diff --git a/packages/webui/tests/system-types-http.test.mjs b/packages/webui/tests/system-types-http.test.mjs deleted file mode 100644 index 9a8ba3fc2..000000000 --- a/packages/webui/tests/system-types-http.test.mjs +++ /dev/null @@ -1,250 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; -import { request } from 'node:http'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs'; -import { loadBusiness } from '../../organization/src/organization-config.mjs'; -import { resolveInstance } from '../../organization/src/resolve.mjs'; -import { busOrganization } from '../../bus/src/organization-context.mjs'; -import { startBroker } from '../../bus/src/runtime.mjs'; -import { serve } from '../../bus/src/server.mjs'; -import { Client } from '../../bus/src/client.mjs'; -import { installedSystemTypes } from '../../plugins/src/system-types.mjs'; -import { startServer } from '../src/serve.mjs'; -import { close } from './fixture.mjs'; - -// Phase P2 (#1873): the owner's System types routes on a selected organization, end to end. An -// ephemeral organization with the real runtime writer (plugins.set, the system types owner fence, -// changes.jsonl) and the broker's console socket for the read; the SSO session is a fixture -// authority. `writer` picks the cap the write transport binds: the owner's human cap, or the tester. -async function fixture(t, { writer = 'owner', send = true, afterRead = null, beforeWrite = null } = {}) { - // Cleanups run last-made first, registered as soon as each resource exists. - const undo = []; t.after(async () => { for (const step of undo.reverse()) await step(); }); - const root = mkdtempSync(join(tmpdir(), 'st-')); - const rolesDir = rolesCopy(root), system = systemFor(root); - const doc = businessDoc(root, 'demo'); - doc.plugins = { ...(doc.plugins ?? {}), 'system-type': ['coding'], harness: ['pi'] }; - doc.vars = { ...doc.vars, 'bus.tester': true }; - const file = writeJson(join(root, 'businesses/demo.json'), doc); - mkdirSync(join(root, 'data'), { mode: 0o700 }); - const business = loadBusiness('demo', { dir: root, rolesDir }); - const resolved = Object.fromEntries(Object.keys(business.roles).map(instance => [instance, resolveInstance({ system, business, instance })])); - undo.push(() => rmSync(root, { recursive: true, force: true })); - const runtime = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busOrganization(business, resolved) }, organization: { dir: root, rolesDir, system } }); - undo.push(() => runtime.close()); - const path = join(root, 'b.sock'); - const socketServer = await serve({ broker: runtime.broker, path, peerReader: { read: async () => ({}), close: () => {} }, - authenticateConsole: proof => runtime.broker.bindConsole({ business: 'demo', human: 'jason', subject: proof.subject }) }); - undo.push(() => socketServer.close()); - const owner = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); - const role = Object.keys(business.roles)[0]; - const cap = writer === 'tester' ? runtime.broker.bindTester({ business: 'demo' }) - : writer === 'agent' ? runtime.broker.bindLaunch({ business: 'demo', role, run: 'agent-run', harness: 'pi' }) : owner; - const calls = []; - // afterRead and beforeWrite let a test act between the route's read and its write. - const requestRead = async (verb, args, ctx) => { calls.push(['read', verb]); const r = await new Client({ path, console: { subject: ctx.subject } }).call(verb, args); await afterRead?.(); return r; }; - const systemTypesWrite = async (verb, args) => { await beforeWrite?.(verb); calls.push(['write', verb]); return runtime.broker.request(cap, { verb, args }); }; - const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason', rootOwner: true }; - let active = true; - const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) }; - const organizations = { - list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }], - resolve: async id => id !== 'demo' ? null : { busHealthy: true, send: send ? async () => ({}) : null, requestRead, systemTypesWrite: send ? systemTypesWrite : null, - reads: { business: async () => ({ business: { id: 'demo' } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } }, - }; - const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), organizations, log: () => {} }); - const base = `http://127.0.0.1:${web.address().port}`; - sso.publicOrigin = base; sso.publicHost = new URL(base).host; - undo.push(() => close(web)); - const project = runtime.broker.request(owner, { verb: 'project.create', args: { name: 'example' } }).id; - const onDisk = () => JSON.parse(readFileSync(file)).plugins['system-type']; - const ownerRequest = (verb, args) => runtime.broker.request(owner, { verb, args }); - return { base, file, project, calls, onDisk, ownerRequest, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } }; -} - -function call(f, path, method = 'GET', body, headers = {}) { - return new Promise((resolve, reject) => { - const data = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body); - const req = request(f.base + path, { method, headers: { ...(data !== undefined ? { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...headers } }, res => { - let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)])); - }); - req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(data); - }); -} -const ORG = '/api/orgs/demo/system-types'; - -test('the owner reads the installed modules and enables one with the revision, getting a receipt', async t => { - const f = await fixture(t); - const [status, before] = await call(f, ORG); - assert.equal(status, 200, JSON.stringify(before)); - // Labels and descriptions are the modules' own, never a list in the WebUI. - const modules = (await installedSystemTypes()).map(({ id, label, description }) => ({ id, label, description })); - assert.deepEqual(before.installed, modules); - assert.deepEqual(before.enabled, ['coding']); - assert.equal(before.project, null); assert.equal(before.writable, true); assert.match(before.revision, /^[a-f0-9]{64}$/); - const [saved, receipt] = await call(f, ORG, 'POST', { revision: before.revision, enabled: ['coding', 'montage'] }); - assert.equal(saved, 200, JSON.stringify(receipt)); - assert.deepEqual(receipt.enabled, ['coding', 'montage']); - assert.match(receipt.transaction, /^[0-9a-f-]{36}$/); assert.notEqual(receipt.revision, before.revision); - assert.deepEqual(f.onDisk(), ['coding', 'montage']); - const [, after] = await call(f, ORG); - assert.deepEqual(after.enabled, ['coding', 'montage']); assert.equal(after.revision, receipt.revision); - // The revision is the proof: the old one is refused and nothing is written. - const [stale, conflict] = await call(f, ORG, 'POST', { revision: before.revision, enabled: [] }); - assert.deepEqual([stale, conflict.error], [409, 'revision-conflict']); - assert.deepEqual(f.onDisk(), ['coding', 'montage']); -}); - -test('the admission checks refuse before any write', async t => { - const f = await fixture(t); - const [, { revision }] = await call(f, ORG); - const good = { revision, enabled: [] }; - for (const [label, body, headers, expected] of [ - ['unknown module', { revision, enabled: ['nope'] }, {}, [400, 'system-type-unknown']], - ['duplicate', { revision, enabled: ['coding', 'coding'] }, {}, [400, 'invalid-request']], - ['extra field', { ...good, actor: 'jason' }, {}, [400, 'invalid-request']], - ['business field', { ...good, business: 'other' }, {}, [400, 'invalid-request']], - ['no revision', { enabled: [] }, {}, [400, 'invalid-request']], - ['bad revision', { revision: 'x', enabled: [] }, {}, [400, 'invalid-request']], - ['not JSON', '{', {}, [400, 'invalid-request']], - ['too large', JSON.stringify({ revision, enabled: [], pad: 'x'.repeat(5000) }), {}, [413, 'invalid-request']], - ['form post', good, { 'content-type': 'text/plain' }, [400, 'invalid-request']], - // The Console's own origin check answers a foreign Origin first; the route's own check answers Sec-Fetch-Site. - ['cross origin', good, { origin: 'https://other.example' }, [403, 'cross-origin request refused']], - ['cross site', good, { 'sec-fetch-site': 'cross-site' }, [403, 'cross-origin']], - ]) { - const writes = f.calls.filter(c => c[0] === 'write' && c[1] === 'plugins.set').length; - const [status, result] = await call(f, ORG, 'POST', body, headers); - assert.deepEqual([status, result.error], expected, label); - assert.equal(f.calls.filter(c => c[0] === 'write' && c[1] === 'plugins.set').length, writes, label); - assert.deepEqual(f.onDisk(), ['coding'], label); - } - const [method] = await call(f, ORG, 'PUT'); - assert.equal(method, 405); - const [cross, crossBody] = await call(f, ORG, 'GET', undefined, { origin: 'https://other.example' }); - assert.equal(cross, 403); assert.match(crossBody.error, /^cross-origin/); - const [badProject, badProjectBody] = await call(f, `${ORG}/projects/${encodeURIComponent('../x')}`); - assert.deepEqual([badProject, badProjectBody.error], [404, 'project-not-found']); - f.signOut(); - const [signedOut, signedOutBody] = await call(f, ORG, 'POST', good); - assert.deepEqual([signedOut, signedOutBody.error], [401, 'sign-in-required']); - assert.deepEqual(f.onDisk(), ['coding']); -}); - -test('a sign-out while the read runs dispatches no write on either route', async t => { - let signOutOnRead = false; - const f = await fixture(t, { afterRead: () => { if (signOutOnRead) f.signOut(); } }); - const [, { revision }] = await call(f, ORG); - signOutOnRead = true; - const writes = () => f.calls.filter(c => c[0] === 'write' && c[1] !== 'plugins').length; - for (const [path, body] of [[ORG, { revision, enabled: [] }], [`${ORG}/projects/${f.project}`, { systemTypes: ['coding'] }]]) { - const [status, result] = await call(f, path, 'POST', body); - assert.deepEqual([status, result.error], [401, 'sign-in-required'], path); - } - assert.equal(writes(), 0); - assert.deepEqual(f.onDisk(), ['coding']); -}); - -test('root authority revoked during a read cannot write through a surviving user session', async t => { - for (const projectRoute of [false, true]) { - let revokeOnRead = false; - const f = await fixture(t, { afterRead: () => { if (revokeOnRead) f.revokeOwner(); } }); - const [, { revision }] = await call(f, ORG); - revokeOnRead = true; - const path = projectRoute ? `${ORG}/projects/${f.project}` : ORG; - const body = projectRoute ? { systemTypes: ['coding'] } : { revision, enabled: [] }; - const [status, result] = await call(f, path, 'POST', body); - assert.deepEqual([status, result.error], [401, 'sign-in-required'], path); - assert.equal(f.calls.filter(c => c[0] === 'write' && c[1] !== 'plugins').length, 0); - assert.deepEqual(f.onDisk(), ['coding']); - } -}); - -test('the enabled set and its revision come from one read, so a change in between is not undone', async t => { - let raced = false; - const f = await fixture(t, { beforeWrite: async verb => { - // Another owner action enables Marketing after the route's Console read and before its plugins read. - if (verb !== 'plugins' || raced) return; - raced = true; - const { revision } = await f.ownerRequest('plugins', {}); - await f.ownerRequest('plugins.set', { revision, plugins: { 'system-type': ['coding', 'marketing'] } }); - } }); - const [, view] = await call(f, ORG); - assert.equal(raced, true); - assert.deepEqual(view.enabled, ['coding', 'marketing']); - const [saved, receipt] = await call(f, ORG, 'POST', { revision: view.revision, enabled: view.enabled.filter(x => x !== 'coding') }); - assert.equal(saved, 200, JSON.stringify(receipt)); - assert.deepEqual(f.onDisk(), ['marketing']); -}); - -test('without the owner human proof the enable switch is refused', async t => { - // A Console without --send has no write transport: the read works, the write is read-only. - const readOnly = await fixture(t, { send: false }); - const [status, view] = await call(readOnly, ORG); - assert.equal(status, 200); assert.equal(view.writable, false); assert.equal(view.revision, undefined); - const [refused, body] = await call(readOnly, ORG, 'POST', { revision: 'a'.repeat(64), enabled: [] }); - assert.deepEqual([refused, body.error], [403, 'read-only']); - assert.deepEqual(readOnly.onDisk(), ['coding']); - // A write transport bound as the tester or as an agent, not the owner's human cap, is refused by the - // broker, and the receipt-bearing path never runs. - for (const writer of ['tester', 'agent']) { - const f = await fixture(t, { writer }); - const [fenced, fence] = await call(f, ORG, 'POST', { revision: 'b'.repeat(64), enabled: [] }); - assert.deepEqual([fenced, fence.error], [403, 'human-required'], writer); - assert.deepEqual(f.onDisk(), ['coding'], writer); - } -}); - -test('a project selects only enabled modules and a disabled selection reads as inactive', async t => { - const f = await fixture(t); - const path = `${ORG}/projects/${f.project}`; - const [status, empty] = await call(f, path); - assert.equal(status, 200, JSON.stringify(empty)); - assert.deepEqual(empty.project, { project: f.project, selected: [], active: [], inactive: [] }); - assert.equal(empty.revision, undefined); - const [notEnabled, notEnabledBody] = await call(f, path, 'POST', { systemTypes: ['montage'] }); - assert.deepEqual([notEnabled, notEnabledBody.error], [409, 'system-type-not-enabled']); - const [unknown, unknownBody] = await call(f, path, 'POST', { systemTypes: ['nope'] }); - assert.deepEqual([unknown, unknownBody.error], [400, 'system-type-unknown']); - const [saved, selection] = await call(f, path, 'POST', { systemTypes: ['coding'] }); - assert.equal(saved, 200, JSON.stringify(selection)); - assert.deepEqual(selection, { project: f.project, selected: ['coding'], active: ['coding'], inactive: [] }); - // The owner disables coding for the organization; the project keeps its selection, now inactive. - const [, { revision }] = await call(f, ORG); - assert.equal((await call(f, ORG, 'POST', { revision, enabled: [] }))[0], 200); - const [, after] = await call(f, path); - assert.deepEqual(after.enabled, []); - assert.deepEqual(after.project, { project: f.project, selected: ['coding'], active: [], inactive: ['coding'] }); - const [missing, missingBody] = await call(f, `${ORG}/projects/no-such-project`); - assert.deepEqual([missing, missingBody.error], [404, 'project-not-found']); - const [missingWrite, missingWriteBody] = await call(f, `${ORG}/projects/no-such-project`, 'POST', { systemTypes: [] }); - assert.deepEqual([missingWrite, missingWriteBody.error], [404, 'project-not-found']); -}); - -test('the tester listener and a Console without SSO never reach the routes or the files', async t => { - const { createDevOwner } = await import('../src/dev-owner.mjs'); - let reached = 0; - const hit = async () => { reached++; return {}; }; - const organizations = { list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }], - resolve: async () => ({ busHealthy: true, send: hit, requestRead: hit, systemTypesWrite: hit, reads: { business: async () => ({ business: { id: 'demo' } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } }) }; - const root = mkdtempSync(join(tmpdir(), 'stt-')), socket = join(root, 't.sock'); - const tester = await startServer({ tester: true, testerSocket: socket, board: 'http://127.0.0.1:9', organizations, log: () => {} }); - t.after(async () => { await close(tester); rmSync(root, { recursive: true, force: true }); }); - const viaTester = (path, method = 'GET') => new Promise((resolve, reject) => { - const req = request({ socketPath: socket, path, method, headers: { host: 'mosaic-tester.invalid' } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); }); - req.on('error', reject); req.end(); - }); - for (const path of ['/system-types.js', '/system-types.css']) assert.equal(await viaTester(path), 403, path); - // The tester has no organization session, so the per-organization routes answer org-unavailable. - for (const [path, method] of [[ORG, 'GET'], [ORG, 'POST'], [`${ORG}/projects/p1`, 'GET']]) assert.ok([403, 404].includes(await viaTester(path, method)), path); - const plain = await startServer({ port: 0, organizations, log: () => {} }); t.after(() => close(plain)); - const plainBase = `http://127.0.0.1:${plain.address().port}`; - assert.ok((await fetch(`${plainBase}${ORG}`)).status >= 400); - // The owner's dev session on a Console with the routes: the file is served. - const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, log: () => {} }); t.after(() => close(owner)); - assert.equal((await fetch(`http://127.0.0.1:${owner.address().port}/system-types.js`)).status, 200); - assert.equal(reached, 0); - await assert.rejects(startServer({ port: 0, sso: createDevOwner(), systemTypesWrite: hit, log: () => {} }), /System types writes need/); -}); diff --git a/packages/webui/tests/unified-workspace-browser.test.mjs b/packages/webui/tests/unified-workspace-browser.test.mjs index 24ad97050..409a945ab 100644 --- a/packages/webui/tests/unified-workspace-browser.test.mjs +++ b/packages/webui/tests/unified-workspace-browser.test.mjs @@ -10,13 +10,12 @@ import { join } from 'node:path'; import { browser } from './browser.mjs'; import { close } from './fixture.mjs'; import { fileURLToPath } from 'node:url'; -import { systemTypesFake } from './helpers/system-types-fake.mjs'; const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); -// Three organizations as sample 01 and 03 show them: two running buses and one stopped. P2: each running -// organization's System types read comes from the installed modules; `send` adds the owner's writes. -function workspaceOrgs({ failThreads = false, seen = null, send = false, types = {}, copies = {}, buses = {} } = {}) { +// Three organizations as sample 01 and 03 show them: two running buses and one stopped. `send` adds the +// owner's writes. +function workspaceOrgs({ failThreads = false, seen = null, send = false, copies = {}, buses = {} } = {}) { const ORGS = [ { id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true }, { id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true }, @@ -30,8 +29,6 @@ function workspaceOrgs({ failThreads = false, seen = null, send = false, types = const AGENTS = { ddigital: [{ role: 'chief', holder_run: 'r1', state: 'waiting' }, { role: 'copywriter', holder_run: 'r2', state: 'working' }], forge: [] }; const claim = (thread, holder_run) => ({ thread, holder_run, harness: 'claude', at: '2026-11-09T12:00:00.000Z' }); const CLAIMS = { ddigital: [claim('t3', 'r1'), claim('t1', 'r2'), claim('t2', 'r3')], forge: [] }; - const TYPES = { ddigital: { enabled: ['coding', 'marketing'], projects: { 'spring-launch': ['coding'], 'brand-refresh': ['marketing'] } }, forge: { enabled: [], projects: { 'api-gateway': [] } }, ...types }; - const fakes = {}; const FILES = { 'spring-launch': ['docs/north-star.md', 'docs/launch-plan.md', 'src/index.js'], 'brand-refresh': ['docs/brand-voice-guide.md'], 'api-gateway': [] }; const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])), @@ -45,10 +42,8 @@ function workspaceOrgs({ failThreads = false, seen = null, send = false, types = seen?.push(id); const org = ORGS.find(o => o.id === id); if (!org) return null; if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } }; - fakes[id] ??= systemTypesFake(TYPES[id]); - const st = await fakes[id]; return { - busHealthy: true, requestRead: st.requestRead, systemTypesWrite: send ? st.systemTypesWrite : null, ...(send ? { send: async () => ({}) } : {}), + busHealthy: true, ...(send ? { send: async () => ({}) } : {}), reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', notifier: { binding: 'discord' }, launch: { limits: { total: 4 } } }), queue: async () => ({ rows: id === 'ddigital' ? [{ state: 'done' }, { state: 'in-progress' }, { state: 'queued' }] : [] }), busStatus: async () => ({}) }, // `buses` hands an organization's instance read to a real booted bus instead. @@ -61,7 +56,7 @@ function workspaceOrgs({ failThreads = false, seen = null, send = false, types = } // 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 SETTINGS = ['Identity', 'Appearance', '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']; @@ -138,8 +133,6 @@ test('organization dashboard renders every section from the per-organization rea assert.equal(head.avatar, 'DD'); assert.equal(head.title, 'Distinctly Digital'); assert.match(head.meta, /Kind not recorded/); assert.match(head.meta, /id ddigital/); assert.match(head.meta, /Tracker: Vikunja/); assert.match(head.meta, /bus running, read \d/); assert.match(head.colour, /^#[0-9a-f]{6}$/i); - // System types: the enabled modules by their own labels (P2). - assert.match(await b.evaluate('document.querySelector(".ws-typechips").innerText'), /System types\s+Coding\s*Marketing\s+Each project picks from these/i); // Docs come from the project files reads: only documents, named by project. assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-doc")].map(a => a.innerText.replace(/\\s+/g, " ").trim())'), ['North star Spring launch', 'Launch plan Spring launch', 'Brand voice guide Brand refresh']); // KPIs and metrics: one honest empty tile each, with no invented number. @@ -164,8 +157,6 @@ test('organization dashboard renders every section from the per-organization rea for (const s of settings) { assert.ok(s.icon, s.name); assert.ok(s.value, s.name); assert.ok(TAGS.includes(s.tag), `${s.name}: ${s.tag}`); } const by = Object.fromEntries(settings.map(s => [s.name, s])); assert.deepEqual([by['Members and invites'].tag, by['Members and invites'].href], ['not built', null]); - assert.deepEqual([by['System types'].tag, by['System types'].value], ['org file', '2 of 5 installed enabled']); - assert.equal(await b.evaluate('document.querySelector(\'[data-org-setting="System types"] button.go\').textContent'), 'Open'); assert.deepEqual([by['Archive or delete'].tag, by['Archive or delete'].danger, by['Archive or delete'].href], ['danger', true, '#/settings/organizations']); assert.equal(by.Identity.value, 'Distinctly Digital · ddigital · kind not recorded'); assert.equal(by['Task tracker'].value, 'Vikunja'); @@ -185,8 +176,7 @@ test('organization dashboard empty states say so in one short line', async t => await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")'); const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'); // Section labels are uppercased by CSS, so innerText carries the uppercase form. - // The empty organization has no System types read, so it says unavailable, never that none exist. - for (const line of [/System types\s+System types unavailable/i, /Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/]) + for (const line of [/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); }); diff --git a/packages/webui/tests/w15-integration-browser.test.mjs b/packages/webui/tests/w15-integration-browser.test.mjs index 975866ec1..c53444f4c 100644 --- a/packages/webui/tests/w15-integration-browser.test.mjs +++ b/packages/webui/tests/w15-integration-browser.test.mjs @@ -8,7 +8,6 @@ test('integrated project settings and conversation mounts use authorized context [`GET /api/chat/rooms/${ids.project}/messages?limit=100`]: {status:503,body:{error:'fixture-unavailable'}}, 'GET /api/project-files?project=fixture-repository': {status:503,body:{error:'fixture-unavailable'}}, 'GET /api/project-file?project=fixture-repository&path=spec%2Fnorth-star.md': {status:503,body:{error:'fixture-unavailable'}}, - [`GET /api/system-types/projects/${ids.project}`]: {status:503,body:{error:'fixture-unavailable'}}, '/api/project-remotes/fixture-repository': { project: ids.repository, remote: null, revision: 'a'.repeat(64), firstSaveAllowed: true }, }; const { f, b, wait } = await w15Fixture(t, { scenario }); diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh index 5aa930a93..f627adc87 100755 --- a/scripts/stack-release.sh +++ b/scripts/stack-release.sh @@ -440,7 +440,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 project_system_types" +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" STORE_JS=' const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");