diff --git a/packages/bus/schema.sql b/packages/bus/schema.sql index 0c8fda64c..2ab62c272 100644 --- a/packages/bus/schema.sql +++ b/packages/bus/schema.sql @@ -639,3 +639,19 @@ 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; diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 070462198..444045767 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -165,7 +165,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa 'thread.share', 'thread.unshare', 'thread.shares']); // Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb // refuses it human-required before its own checks, and these refuse every other cap. -export const CONSOLE_VERBS = Object.freeze(['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', +export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', // Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's // unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only // request.question and the PM's own reads keep their unsuffixed names and refuse a console cap. @@ -5560,6 +5560,28 @@ export class Broker { return result; }).catch(e => { throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request); }); } + if (request.verb === 'system-types.console') { + if (!s.console || s.human !== this.#business(s.business).human) fail('human-required'); + keys(args, ['project']); + if (!this.#organization) fail('not-configured'); + const enabled = this.#organization(s.business, 'system-types.current', {}, s.human); + if (!Array.isArray(enabled)) fail('not-configured'); + let project = null; + if (args.project !== undefined) { + const id = identifier(args.project); + if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', id, s.business)) fail('project-not-found'); + const row = this.#store.get('SELECT types FROM project_system_types WHERE business=? AND project=? ORDER BY seq DESC LIMIT 1', s.business, id); + const selected = row ? JSON.parse(row.types) : []; + project = { project: id, selected, active: selected.filter(x => enabled.includes(x)), inactive: selected.filter(x => !enabled.includes(x)) }; + } + return Promise.resolve(this.#organization(s.business, 'system-types.installed', {}, s.human)) + .then(installed => { + if (!Array.isArray(installed)) fail('not-configured'); + const result = { installed, enabled, project }; + this.#secretCheck(result); + return result; + }); + } if (request.verb === 'project.remote.get.console' || request.verb === 'project.remote.set.console') { if (!s.console || s.human !== this.#business(s.business).human) fail('human-required'); if (!this.#organization) fail('not-configured'); @@ -5666,7 +5688,7 @@ export class Broker { if (["limits", "plugins", "instances", "routes", "organization.revision"].includes(request.verb)) keys(args, []); else if (request.verb === "plugins.set") { keys(args, ["revision", "plugins", "usage"], ["revision", "plugins"]); - if (s.tester && object(args.plugins) && Object.hasOwn(args.plugins, 'model-provider')) fail('human-required'); + if (s.tester && object(args.plugins) && (Object.hasOwn(args.plugins, 'model-provider') || Object.hasOwn(args.plugins, 'system-type'))) fail('human-required'); } // Row 165 (#1662): instances.set is human-only and outside ORGANIZATION_KEYS, so // organization.set and the chief of staff's tool never reach it (sage-0918). @@ -7566,6 +7588,25 @@ 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/3t-3u.sql b/packages/bus/src/migrations/3t-3u.sql new file mode 100644 index 000000000..1b5d6f5da --- /dev/null +++ b/packages/bus/src/migrations/3t-3u.sql @@ -0,0 +1,16 @@ +-- Per-project selections are a human-authored append-only settings trail. +CREATE TABLE project_system_types ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + business TEXT NOT NULL, + project TEXT NOT NULL REFERENCES projects(id), + types TEXT NOT NULL, + by TEXT NOT NULL +) STRICT; +CREATE INDEX project_system_types_project ON project_system_types (business, project, seq); +CREATE TRIGGER project_system_types_no_update BEFORE UPDATE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END; +CREATE TRIGGER project_system_types_no_delete BEFORE DELETE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END; +CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_types WHEN EXISTS (SELECT 1 FROM project_system_types WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END; +CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types + WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business + BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END; diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 01f13c139..ac68876fd 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -22,6 +22,7 @@ import { readSessions, sessionsFile, stopSession } from '../../seat/src/session. import { compactionCount, storeDir } from '../../seat/src/compactions.mjs'; import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs'; import { createLoader, hasModule } from '../../plugins/src/loader.mjs'; +import { installedSystemTypes } from '../../plugins/src/system-types.mjs'; import { pluginSettings } from '../../organization/src/plugin-settings.mjs'; import { loadManagementClient } from '../../model-proxy/src/client.mjs'; import { ProxyPage } from './proxy-page.mjs'; @@ -751,6 +752,8 @@ export async function startBroker({ known.set(id, canonicalJson(next.projects)); return getProjectRemote(writer, id, args.project); } + if (verb === 'system-types.installed') return installedSystemTypes(); + if (verb === 'system-types.current') return settings.current(id).plugins?.['system-type'] ?? []; if(verb==='limits')return withTokens(id,settings.read(id)); if(verb==='plugins')return {...plugins.read(id), available:{harness:levels.names ?? []}}; // Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader. diff --git a/packages/bus/src/store.mjs b/packages/bus/src/store.mjs index 79fbf999d..a92a08df3 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 = '1cec88b0b697c648bcab18b45f86d85526aa1701a7619046367e16c38476aee8'; -export const SCHEMA_VERSION = '3t'; +export const SCHEMA_SHA256 = '6f6f0c0fd471b7895648a71aa9f320a769b145da792c4152ee8282006eff30c7'; +export const SCHEMA_VERSION = '3u'; // Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned. const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8'); const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65'; @@ -112,6 +112,9 @@ const migrations = [{ // and its guards; no existing row changes and nothing is deleted. from: '3s', to: '3t', sql: readFileSync(new URL('./migrations/3s-3t.sql', import.meta.url), 'utf8'), +}, { + from: '3t', to: '3u', + sql: readFileSync(new URL('./migrations/3t-3u.sql', import.meta.url), 'utf8'), }]; const timedTables = [ 'events', @@ -138,6 +141,7 @@ const timedTables = [ 'request_snapshot_events', 'request_project_events', 'project_archives', + 'project_system_types', ]; const hex = (value) => createHash('sha256').update(value).digest('hex'); if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch'); diff --git a/packages/bus/tests/account-migration.test.mjs b/packages/bus/tests/account-migration.test.mjs index 36e06c7e0..1581b966c 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', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); +const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); function source3h(t) { const root = mkdtempSync(join(tmpdir(), 'account-migrate-')); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -94,6 +94,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) { copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql')); copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql')); copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql')); + copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql')); return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; } @@ -101,7 +102,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence const { root, path } = source3h(t), before = snapshot(path), was = master(path); assert.throws(() => new Store(root), /schema-mismatch/); new Store(root, { migrateSchema: true }).close(); - assert.equal(version(path), '3t'); + assert.equal(version(path), '3u'); // Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50. assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows); assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]); @@ -151,7 +152,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with assert.deepEqual(snapshot(path, before.columns).rows, before.rows); // Still a whole 3h store: the real step migrates it. new Store(root, { migrateSchema: true }).close(); - assert.equal(version(path), '3t'); + assert.equal(version(path), '3u'); assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows); }); @@ -163,14 +164,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and schema: ddl3h, store: (source) => source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3h';") + .replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3h';") .replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'), }); const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8'); assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step'); assert.throws(() => new Store3h(root), /schema-mismatch/); assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/); - assert.equal(version(path), '3t'); + assert.equal(version(path), '3u'); 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 39c61cc5e..d577e922c 100644 --- a/packages/bus/tests/chief-of-staff.test.mjs +++ b/packages/bus/tests/chief-of-staff.test.mjs @@ -616,7 +616,7 @@ async function database3c(t) { let source = readFileSync(here('../src/store.mjs'), 'utf8'); const pinned = source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3c';") + .replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3c';") .replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); assert.notEqual(pinned, source); assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step'); @@ -640,7 +640,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a assert.throws(() => new Store(root), /schema-mismatch/); const store = new Store(root, { migrateSchema: true }); t.after(() => store.close()); - assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u'); 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 50db455ef..f48e1b3a7 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, '3t'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u'); assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}'); assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0); const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value; @@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver const sha = createHash('sha256').update(futureDDL).digest('hex'); let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8'); source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`) - .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3u';") - .replace('}];\nconst timedTables', `}, { from: '3t', to: '3u', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`); + .replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3v';") + .replace('}];\nconst timedTables', `}, { from: '3u', to: '3v', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`); writeFileSync(join(moduleRoot, 'src/store.mjs'), source); writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL); - for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql']) + for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql']) copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name)); const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs'))); const future = new FutureStore(root, { migrateSchema: true }); - assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u'); + assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v'); assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture'); assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0); assert.equal(future.get("SELECT id FROM events").id, 'preserved'); @@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname; const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' }); assert.equal(migrated.status, 0, migrated.stderr); - assert.match(migrated.stdout, /bus schema 3t ready/); + assert.match(migrated.stdout, /bus schema 3u 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 099623479..343d9fabe 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], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', + assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', 'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']); assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console'))); assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false); diff --git a/packages/bus/tests/gitea-migration.test.mjs b/packages/bus/tests/gitea-migration.test.mjs index bcb2f2f2a..21f90f62e 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', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); +const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); function rows(path) { const db = new DatabaseSync(path, { readOnly: true }); try { - return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives')").all() + return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types')").all() .map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all() .filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))])); } finally { db.close(); } @@ -84,6 +84,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) { copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql')); copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql')); copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql')); + copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql')); return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; } function fresh(t) { @@ -105,7 +106,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t const { root, path } = source3j(t), before = rows(path), was = master(path); assert.throws(() => new Store(root), /schema-mismatch/); new Store(root, { migrateSchema: true }).close(); - assert.equal(version(path), '3t'); + assert.equal(version(path), '3u'); assert.deepEqual(migrated3l(rows(path)), before); const now = master(path); assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r))); @@ -131,7 +132,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an assert.deepEqual(master(path), was); assert.deepEqual(rows(path), before); new Store(root, { migrateSchema: true }).close(); - assert.equal(version(path), '3t'); + assert.equal(version(path), '3u'); }); test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => { @@ -142,14 +143,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as schema: ddl3j, store: (source) => source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3j';") + .replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3j';") .replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'), }); const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8'); assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step'); assert.throws(() => new Store3j(root), /schema-mismatch/); assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/); - assert.equal(version(path), '3t'); + assert.equal(version(path), '3u'); 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 fd75e9bf2..d82f73517 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', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); +const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); function source3i(t) { const root = mkdtempSync(join(tmpdir(), 'grant-migrate-')); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -42,7 +42,7 @@ function source3i(t) { function rows(path) { const db = new DatabaseSync(path, { readOnly: true }); try { - return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives')").all() + return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types')").all() .map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all() .filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))])); } finally { db.close(); } @@ -87,6 +87,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) { copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql')); copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql')); copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql')); + copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql')); return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; } function fresh(t) { @@ -110,7 +111,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t const { root, path } = source3i(t), before = rows(path), was = master(path); assert.throws(() => new Store(root), /schema-mismatch/); new Store(root, { migrateSchema: true }).close(); - assert.equal(version(path), '3t'); + assert.equal(version(path), '3u'); assert.deepEqual(migrated3l(rows(path)), before); const now = master(path).filter(r => !LATER.includes(r.name)); assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r))); @@ -135,7 +136,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an assert.deepEqual(master(path), was); assert.deepEqual(rows(path), before); new Store(root, { migrateSchema: true }).close(); - assert.equal(version(path), '3t'); + assert.equal(version(path), '3u'); }); test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => { @@ -146,14 +147,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as schema: ddl3i, store: (source) => source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3i';") + .replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3i';") .replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'), }); const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8'); assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step'); assert.throws(() => new Store3i(root), /schema-mismatch/); assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/); - assert.equal(version(path), '3t'); + assert.equal(version(path), '3u'); 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 7fb2f449f..c2feb07df 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, '3t'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u'); 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 1d532dd3c..3937d638d 100644 --- a/packages/bus/tests/prd-activation.test.mjs +++ b/packages/bus/tests/prd-activation.test.mjs @@ -198,6 +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'), '') .replace(",'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced'", ''); const db = new DatabaseSync(path); db.exec(ddl); const at = '2026-11-03T00:00:00.000Z'; @@ -213,7 +214,7 @@ test('3p through 3s migration preserves existing request rows and the append-onl assert.throws(() => new Store(root), /schema-mismatch/); const store = new Store(root, { migrateSchema: true }); t.after(() => store.close()); assert.deepEqual(store.all('SELECT * FROM request_events'), before); - assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u'); 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 6170dd177..e29726819 100644 --- a/packages/bus/tests/project-archive.test.mjs +++ b/packages/bus/tests/project-archive.test.mjs @@ -143,7 +143,7 @@ const here = (p) => new URL(p, import.meta.url); const migrationSQL = readFileSync(here('../src/migrations/3s-3t.sql'), 'utf8'); const currentDDL = readFileSync(here('../schema.sql'), 'utf8'); assert.equal(currentDDL.split(migrationSQL).length, 2, 'schema.sql holds the 3s→3t step exactly once'); -const ddl3s = currentDDL.replace(migrationSQL, ''); +const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), ''); function source3s(t) { const root = mkdtempSync(join(tmpdir(), 'project-archive-3s-')); @@ -178,7 +178,7 @@ test('a 3s store migrates to 3t: project_archives is new and empty, every row st assert.deepEqual(master(path), was, 'the refused open changed nothing'); const store = new Store(root, { migrateSchema: true }); t.after(() => store.close()); - assert.equal(version(path), '3t'); + assert.equal(version(path), '3u'); assert.equal(store.get("SELECT id FROM events").id, 'preserved'); assert.equal(store.get("SELECT name FROM projects").name, 'kept'); assert.deepEqual(store.all('SELECT seq,at,business,project,op,by FROM project_archives'), []); @@ -211,7 +211,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa const source = readFileSync(here('../src/store.mjs'), 'utf8'); const edited = source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3s).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3s';") + .replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3s';") .replace(/\}, \{\n {2}\/\/ #797 MVP3[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); assert.ok(edited.includes("export const SCHEMA_VERSION = '3s';") && edited.includes('3r-3s') && !edited.includes('3s-3t'), 'the 3s engine has no 3s→3t step'); writeFileSync(join(moduleRoot, 'src/store.mjs'), edited); @@ -221,7 +221,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa const Store3s = (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; assert.throws(() => new Store3s(root), /schema-mismatch/); assert.throws(() => new Store3s(root, { migrateSchema: true }), /schema-mismatch/); - assert.equal(version(path), '3t'); + assert.equal(version(path), '3u'); 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 6a13b56b4..985cb46db 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], ['project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', + assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', 'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']); }); @@ -302,7 +302,7 @@ test('a cancel is refused when the owner-to-PM edge closed, with no withdrawn de }); test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => { - assert.equal(SCHEMA_VERSION, '3t'); + assert.equal(SCHEMA_VERSION, '3u'); 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'; @@ -311,7 +311,7 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, // 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'); // 3p-3q shipped the PRD kinds and mode.changed; 3q-3r adds the three Task 3 kinds after them. - const ddl3q = current.replace(step3t, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'"); + const ddl3q = current.replace(step3t, '').replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'"); assert.notEqual(ddl3q, current, 'the fixture is the narrower schema'); const migration = readFileSync(new URL('../src/migrations/3q-3r.sql', import.meta.url), 'utf8'); assert.ok(migration.includes("'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'"), 'the step carries every 3q kind plus its own'); @@ -328,7 +328,7 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, db.close(); chmodSync(path, 0o600); assert.throws(() => new Store(root), /schema-mismatch/); const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' }); - assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n'); + assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u 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 d41a72975..b83a85135 100644 --- a/packages/bus/tests/request-continuation.test.mjs +++ b/packages/bus/tests/request-continuation.test.mjs @@ -230,6 +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'), '') .replace(",'advanced'", '')); db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3r'); db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db)); @@ -250,7 +251,7 @@ test('3r to 3s migration preserves journal and sequence and admits only the new assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before); assert.deepEqual(store.all("SELECT name,sql FROM sqlite_master WHERE type='trigger' AND tbl_name IN ('request_events','request_effects') ORDER BY name"), guards); assert.equal(store.get("SELECT name FROM sqlite_master WHERE type='index' AND name='request_events_request'").name, 'request_events_request'); - assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u'); 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 9f739678d..8d7f4ac52 100644 --- a/packages/bus/tests/request-intake.test.mjs +++ b/packages/bus/tests/request-intake.test.mjs @@ -278,6 +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'), '') .replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'"); const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8'); assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length); @@ -290,7 +291,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch db.close(); chmodSync(path, 0o600); const before = readFileSync(path); assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before); const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' }); - assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n'); assert.equal(SCHEMA_VERSION, '3t'); + assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u ready\n'); assert.equal(SCHEMA_VERSION, '3u'); const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved'); for (const table of ['request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0); store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0); @@ -313,6 +314,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'), '') .replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'"); const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z'; const db = new DatabaseSync(path); db.exec(ddl3o); @@ -329,7 +331,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t => db.close(); chmodSync(path, 0o600); assert.throws(() => new Store(root), /schema-mismatch/); const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' }); - assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n'); + assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3u 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.test.mjs b/packages/bus/tests/schema.test.mjs index cf20c95cc..7b685f71e 100644 --- a/packages/bus/tests/schema.test.mjs +++ b/packages/bus/tests/schema.test.mjs @@ -527,7 +527,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => { // #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards. // Task 4 (3p to 3q), Task 3 (3q to 3r) and Task 6b (3r to 3s) widen request_events.kind in place. // #797 MVP3 (3s to 3t) adds project_archives and its five guards; project-archive.test.mjs tests them. - assert.equal(count('table') - 1, 31); - assert.equal(count('trigger'), 119); + // 3t to 3u adds project_system_types and four guards. + assert.equal(count('table') - 1, 32); + assert.equal(count('trigger'), 123); assert.equal(count('view'), 5); }); diff --git a/packages/bus/tests/session-clear.test.mjs b/packages/bus/tests/session-clear.test.mjs index ceb68862c..c0ac2dfca 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), '3t'); + assert.equal(version(path), '3u'); const after = tables(path); - for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives']) { + for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types']) { assert.deepEqual(after[name], [], name); delete after[name]; } @@ -571,7 +571,7 @@ async function engine3m(root) { const source = readFileSync(here('../src/store.mjs'), 'utf8'); const edited = source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3m';") + .replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3m';") .replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step'); writeFileSync(join(moduleRoot, 'src/store.mjs'), edited); @@ -588,7 +588,7 @@ test('T4 schema: the 3m engine refuses a 3n store, migrating or not, and leaves const Store3m = await engine3m(root); assert.throws(() => new Store3m(root), /schema-mismatch/); assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/); - assert.equal(version(path), '3t'); + assert.equal(version(path), '3u'); 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 2f652b65f..4fb4ad8c2 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), '3t'); + assert.equal(version(path), '3u'); // The migrated schema is the fresh one, object for object. const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-')); t.after(() => rmSync(freshRoot, { recursive: true, force: true })); @@ -430,7 +430,7 @@ async function engine3k(root) { const source = readFileSync(here('../src/store.mjs'), 'utf8'); const edited = source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3k';") + .replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3k';") .replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step'); writeFileSync(join(moduleRoot, 'src/store.mjs'), edited); @@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a const Store3k = await engine3k(root); assert.throws(() => new Store3k(root), /schema-mismatch/); assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/); - assert.equal(version(path), '3t'); + assert.equal(version(path), '3u'); 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 1711ce3d5..447f9fbe8 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, '3t'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u'); 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 new file mode 100644 index 000000000..d517b86bd --- /dev/null +++ b/packages/bus/tests/system-type.test.mjs @@ -0,0 +1,107 @@ +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); + db.exec(ddl.replace(step, '')); + 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, '3u'); +}); + +test('runtime reads the current organization file for project selections and console discovery', async t => { + const { mkdirSync } = await import('node:fs'); + const { startBroker } = await import('../src/runtime.mjs'); + const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs'); + const root = mkdtempSync(join(tmpdir(), 'sys-runtime-')); + const rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo'); + doc.plugins = { 'system-type': ['coding'] }; + writeJson(join(root, 'businesses/demo.json'), doc); + mkdirSync(join(root, 'data'), { mode: 0o700 }); + const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } }; + const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: business }, organization: { dir: root, rolesDir, system: systemFor(root) } }); + t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); }); + const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const consoleOwner = bus.broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } }); + const call = (verb, args = {}, cap = human) => bus.broker.request(cap, { verb, args }); + const project = call('project.create', { name: 'example' }).id; + call('project.system-types.set', { project, systemTypes: ['coding'] }); + const view = await call('system-types.console', { project }, consoleOwner); + assert.deepEqual(view.enabled, ['coding']); + assert.deepEqual(view.project.active, ['coding']); + assert.equal(view.installed.length, 5); + const plugins = call('plugins'); + call('plugins.set', { revision: plugins.revision, plugins: { 'system-type': [] } }); + assert.deepEqual(call('project.system-types', { project }).inactive, ['coding']); +}); diff --git a/packages/bus/tests/thread-migration.test.mjs b/packages/bus/tests/thread-migration.test.mjs index 7890c4aa2..657c8978b 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 = '3t';", "export const SCHEMA_VERSION = '3d';") + .replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3d';") .replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step'); writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned); @@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l assert.throws(() => new Store(root), /schema-mismatch/); const store = new Store(root, { migrateSchema: true }); t.after(() => store.close()); - assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u'); for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table); // Parked: the undelivered ones are counted per business and role, and none is changed or given a thread. @@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname; const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' }); assert.equal(migrated.status, 0, migrated.stderr); - assert.equal(migrated.stdout, [...REPORT, 'bus schema 3t ready', ''].join('\n')); + assert.equal(migrated.stdout, [...REPORT, 'bus schema 3u ready', ''].join('\n')); }); diff --git a/packages/bus/tests/thread-shares.test.mjs b/packages/bus/tests/thread-shares.test.mjs index f72fa49fa..9842ee776 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), '3t'); + assert.equal(version(path), '3u'); const after = tables(path); assert.deepEqual(after.thread_shares, []); delete after.thread_shares; // Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too. - for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives']) { + for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types']) { assert.deepEqual(after[name], [], name); delete after[name]; } @@ -439,7 +439,7 @@ async function engine3l(root) { const source = readFileSync(here('../src/store.mjs'), 'utf8'); const edited = source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3l';") + .replace("export const SCHEMA_VERSION = '3u';", "export const SCHEMA_VERSION = '3l';") .replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step'); writeFileSync(join(moduleRoot, 'src/store.mjs'), edited); @@ -456,7 +456,7 @@ test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it wa const Store3l = await engine3l(root); assert.throws(() => new Store3l(root), /schema-mismatch/); assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/); - assert.equal(version(path), '3t'); + assert.equal(version(path), '3u'); 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 17c289b97..e7ddf4b03 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, '3t'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u'); 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 a42dd6937..ada0dceb3 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1289,3 +1289,12 @@ first attempt. Completed moves are not rolled back. A held lifecycle lock can mean a bus start is still in progress; if a process crashed and left its lock, inspect it before removing it and resuming. Moves use rename and therefore require the source and deleted folder to be on the same filesystem. + +System types are installed plug-ins. `mosaic org system-types installed` prints +module metadata; `mosaic org system-types enabled ID` prints an organization's enabled +ids. The owner changes the enabled list through the existing organization file +update or `mosaic plugins set`. `mosaic project system-types PROJECT --org ID` +shows selected, active and inactive ids. The owner replaces the selection with +`mosaic project system-types-set PROJECT --types coding,montage --org ID`; an +empty `--types ''` clears it. Disabled selections stay recorded but become +inactive. Project settings changes are append-only and require human proof. diff --git a/packages/cli/live/budget-supervisor.mjs b/packages/cli/live/budget-supervisor.mjs index e09bdac9f..c50a73398 100644 --- a/packages/cli/live/budget-supervisor.mjs +++ b/packages/cli/live/budget-supervisor.mjs @@ -74,7 +74,7 @@ export function readAccounting({ dataRoot,business,start,end }) { const db = new DatabaseSync(join(dataRoot,'bus/bus.sqlite'),{readOnly:true}); try { db.exec('BEGIN'); - if (!['3s','3t'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused'); + if (!['3s','3t','3u'].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 85e128023..a8e8fad17 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','3u',null]) test(`accounting ${version==='3s'?'accepts':'refuses'} schema ${version??'missing'}`,t=>{ +for(const version of ['3s','3r','3v',null]) test(`accounting ${version==='3s'?'accepts':'refuses'} schema ${version??'missing'}`,t=>{ const f=setup(t,100); // Only this disposable fixture is altered; the production meta table is append-only. f.store.run('DROP TRIGGER meta_no_update');f.store.run('DROP TRIGGER meta_no_delete'); diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index ee64d0aee..e260af2e1 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -727,7 +727,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin, if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps); if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps)); if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io); - if (verb === "project") return ["create", "list", "archive", "unarchive"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps)); + if (verb === "project") return ["create", "list", "archive", "unarchive", "system-types", "system-types-set"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps)); if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps)); if (verb === "session") return sessionCommand(rest, io, (p) => channelCallFor(p, io, deps)); if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage); diff --git a/packages/cli/src/org.mjs b/packages/cli/src/org.mjs index bec8db029..07b914d8a 100644 --- a/packages/cli/src/org.mjs +++ b/packages/cli/src/org.mjs @@ -2,13 +2,14 @@ import { readFile, writeFile } from 'node:fs/promises'; import { configDir } from '../../organization/src/organization-config.mjs'; import { listOrgs, orgLock } from '../../organization/src/org-registry.mjs'; import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs'; +import { installedSystemTypes } from '../../plugins/src/system-types.mjs'; import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs'; import { loadSystem } from './config.mjs'; import { CliError } from './errors.mjs'; import { busStopForDelete } from './org-bus.mjs'; import { consoleBusRestart } from './org-console.mjs'; import { refuseInsideAgent } from './transport.mjs'; -export const ORG_USAGE = 'mosaic org lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]'; +export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]'; function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; } async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e.message}`, 2); } } export async function orgCommand(args, io, deps = {}) { @@ -20,8 +21,10 @@ export async function orgCommand(args, io, deps = {}) { if (words[0] === 'validate' && words.length === 2) { allowed([], []); result = await validateOrg(await document(words[1])); if (!result.ok) throw new CliError(result.errors.map(e => `${e.path}: ${e.message}`).join('\n'), 2); io.stdout.write('valid\n'); return; } // U1 (#1873): `org reload` is a human action; an agent run is refused before any config is read. if (words[0] === 'reload') refuseInsideAgent(io.env); + if (words[0] === 'system-types' && words[1] === 'installed' && words.length === 2) { allowed(['--json'], []); io.stdout.write(JSON.stringify(await installedSystemTypes(), null, 2) + '\n'); return; } const instance = devOwnerInstance(io.env); const system = deps.system ?? loadSystem({ env: io.env, instance }); const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER }; - if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); } + if (words[0] === 'system-types' && words[1] === 'enabled' && words.length === 3) { allowed(['--json'], []); const org = await showOrg(ctx, words[2]); result = { organization: words[2], enabled: org.doc.plugins?.['system-type'] ?? [] }; } + else if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); } else if (words[0] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); } else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); } else if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json'], ['--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); const opts = { ifRevision: Number(r) }; result = words[0] === 'archive' ? await archiveOrg(ctx, words[1], { ...opts, busStop: busStopForDelete }) : await restoreOrg(ctx, words[1], opts); } diff --git a/packages/cli/src/project.mjs b/packages/cli/src/project.mjs index c8c4a604e..98b56d72d 100644 --- a/packages/cli/src/project.mjs +++ b/packages/cli/src/project.mjs @@ -14,6 +14,8 @@ 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 ]', @@ -28,8 +30,8 @@ export async function projectBusCommand(args, io, callFor) { ? { flags: ['--tester'], values: ['--repo', '--business'] } : sub === 'list' ? { flags: ['--json', '--tester', '--archived'], values: ['--business'] } - : { flags: [], values: ['--business'] }); - const one = sub === 'create' || sub === 'archive' || sub === 'unarchive'; + : { flags: sub === 'system-types' ? ['--json'] : [], values: sub === 'system-types-set' ? ['--business', '--types'] : ['--business'] }); + const one = ['create', 'archive', 'unarchive', 'system-types', 'system-types-set'].includes(sub); if (parsed.positional.length !== (one ? 1 : 0)) throw new CliError(`project ${sub} takes ${one ? (sub === 'create' ? 'one name' : 'one name or id') : 'no arguments'}\n${PROJECT_BUS_USAGE}`, 4); const call = callFor(parsed); @@ -45,6 +47,17 @@ export async function projectBusCommand(args, io, callFor) { } return io.stdout.write(`created project ${name} id ${made.id}\n`); } + if (sub === 'system-types' || sub === 'system-types-set') { + const projects = await call('project.list', { includeArchived: true }); + const ref = parsed.positional[0]; + const hit = projects.find(p => p.id === ref) ?? projects.find(p => p.name === ref); + if (!hit) throw new CliError(`no project is named or numbered ${JSON.stringify(ref)}`, 2); + const write = sub === 'system-types-set'; + if (write && parsed.values['--types'] === undefined) throw new CliError('--types is required (an empty string clears the list)', 4); + const systemTypes = parsed.values['--types'] ? parsed.values['--types'].split(',') : []; + const result = await call(write ? 'project.system-types.set' : 'project.system-types', { project: hit.id, ...(write ? { systemTypes } : {}) }); + return io.stdout.write(JSON.stringify(result, null, 2) + '\n'); + } // #797 MVP3: the owner's reversible archive. Resolution reads the full list, archived included, // so an archived project can be named for unarchive. if (sub === 'archive' || sub === 'unarchive') { diff --git a/packages/cli/tests/org.test.mjs b/packages/cli/tests/org.test.mjs index 87ac4eead..3d132e6f9 100644 --- a/packages/cli/tests/org.test.mjs +++ b/packages/cli/tests/org.test.mjs @@ -12,6 +12,7 @@ test('organization CLI renders, validates, creates, updates and initializes git' const file = join(home, 'alpha.json'); const invoke = (...args) => { const r = run(env, ...args); assert.equal(r.status, 0, r.stderr); return r.stdout; }; invoke('org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file); invoke('org', 'validate', file); invoke('org', 'create', 'alpha', '--from', file); + assert.deepEqual(JSON.parse(invoke('org', 'system-types', 'enabled', 'alpha')), { organization: 'alpha', enabled: [] }); const bootCheck = spawnSync(process.execPath, ['--input-type=module', '-e', `import {loadSystem, bootConfig} from './packages/cli/src/config.mjs'; const system=loadSystem({env:process.env, instance:'orgtest'}); bootConfig({system,businessId:'alpha',env:process.env});`], { cwd: repo, env, encoding: 'utf8' }); assert.equal(bootCheck.status, 0, bootCheck.stderr); let info = JSON.parse(invoke('org', 'show', 'alpha', '--json')); assert.equal(info.id, 'alpha'); assert.equal(JSON.parse(invoke('org', 'list', '--json')).length, 1); invoke('org', 'update', 'alpha', '--from', file, '--if-revision', String(info.revision)); diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index 412865d45..264fe4fdd 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,18);assert.deepEqual(pairs.at(-1),['3s','3t']); + assert.equal(pairs.length,19);assert.deepEqual(pairs.at(-1),['3t','3u']); const release=readFileSync(join(ROOT,'scripts/stack-release.sh'),'utf8'); const fn=(name)=>{const m=release.match(new RegExp(`^${name}\\(\\) \{[\\s\\S]*?^\}`, 'm'));assert.ok(m,name);return m[0]}; const functions=['copy_schema','target_schema','plan_steps'].map(fn).join('\n'); @@ -885,7 +885,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' '); const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1]; // sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as // well as T46. -const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','project_archives','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']; +const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','project_archives','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_system_types']; const LATE='2999-01-01T00:00:00.000Z'; const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()}; @@ -903,7 +903,7 @@ test('T45 row 181 items 2 and 3: the restore guard counts events and every COUNT both(store('3f.sqlite',['events'],['events','instance_names']),'1\t1 event(s)\n'); both(store('none.sqlite',[],['instance_names']),'0\t0 event(s)\n'); }); - for(const x of ['instance_names','usage_records'])await t.test(`one ${x} row and no event refuses before any stop; --events 1 restores`,async(t)=>{ + for(const x of ['instance_names','usage_records','project_system_types'])await t.test(`one ${x} row and no event refuses before any stop; --events 1 restores`,async(t)=>{ const e=await mk(t);await seed(e); assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation; addRow(e.store,x); @@ -925,10 +925,10 @@ test('T45 row 181 items 2 and 3: the restore guard counts events and every COUNT assert.equal(r.code,3,r.out+r.err);assert.ok(r.err.endsWith(`stack-release: refusing restore-mismatch: cannot count the rows after ${stamp}\n`),r.err); assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).at(-1).code,'restore-mismatch'); }); - await t.test('the in-run recount counts the listed tables too',async(t)=>{ + for(const x of ['thread_opens','project_system_types'])await t.test(`the in-run recount counts the listed tables too (${x})`,async(t)=>{ const e=await mk(t);await seed(e); assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation; - fault(e,'stop-writes-row','thread_opens'); + fault(e,'stop-writes-row',x); const r=await run(e,'rollback','acme','--restore',act);assert.equal(r.code,1,r.out+r.err); assert.equal(r.err,rolledBack('post-backup-events',C)); assert.ok(r.out.includes('; 0 event(s) were written after the backup\n'),r.out); diff --git a/packages/cli/tests/system-type.test.mjs b/packages/cli/tests/system-type.test.mjs new file mode 100644 index 000000000..7ac1c7ca3 --- /dev/null +++ b/packages/cli/tests/system-type.test.mjs @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { orgCommand } from '../src/org.mjs'; +import { projectBusCommand } from '../src/project.mjs'; + +test('org installed system types and project selection reads print JSON', async () => { + let output = ''; + const io = { stdout: { write: s => { output += s; } }, env: {} }; + await orgCommand(['system-types', 'installed'], io); + assert.deepEqual(JSON.parse(output).map(x => x.id), ['business-management', 'coding', 'creative-writing', 'marketing', 'montage']); + output = ''; + const calls = []; + await projectBusCommand(['system-types', 'work', '--business', 'demo', '--json'], io, () => async (verb, args) => { + calls.push([verb, args]); + return verb === 'project.list' ? [{ id: 'project-id', name: 'work' }] : { project: 'project-id', selected: ['coding'], active: ['coding'], inactive: [] }; + }); + assert.deepEqual(calls.map(x => x[0]), ['project.list', 'project.system-types']); + assert.deepEqual(JSON.parse(output).active, ['coding']); +}); + +// sys1-review P2: both commands go through run() and the real CLI channel module, so a verb the +// channel does not admit fails here. A disabled channel and an agent run still refuse. +test('project system-types and system-types-set pass the CLI channel', async t => { + const { run } = await import('../src/cli.mjs'); + const { fixture, humanEnv, io: mkio, tmp } = await import('./helpers.mjs'); + const root = tmp(t), f = fixture(root); + const P = '10000000-0000-4000-8000-000000000001'; + const transportFor = channels => { + const calls = []; + const transport = () => async (verb, args) => { + if (verb === 'plugins') return { business: 'acme', revision: 'a'.repeat(64), plugins: { channel: channels } }; + calls.push([verb, args]); + if (verb === 'project.list') return [{ id: P, at: '2026-11-09T00:00:00.000Z', name: 'work', repo: null }]; + if (verb === 'project.system-types') return { project: P, selected: ['coding'], active: ['coding'], inactive: [] }; + if (verb === 'project.system-types.set') return { project: P, selected: args.systemTypes, active: args.systemTypes, inactive: [] }; + throw Object.assign(new Error(verb), { code: 'bus-refused' }); + }; + return { calls, transport }; + }; + const read = transportFor(['cli']), rx = mkio(humanEnv(f.env)); + assert.equal(await run(['project', 'system-types', 'work', '--business', 'acme'], rx, { transport: read.transport }), 0, rx.err?.text); + assert.deepEqual(read.calls, [['project.list', { includeArchived: true }], ['project.system-types', { project: P }]]); + assert.deepEqual(JSON.parse(rx.out.text).active, ['coding']); + const write = transportFor(['cli']), wx = mkio(humanEnv(f.env)); + assert.equal(await run(['project', 'system-types-set', 'work', '--types', 'coding,marketing', '--business', 'acme'], wx, { transport: write.transport }), 0, wx.err?.text); + assert.deepEqual(write.calls.at(-1), ['project.system-types.set', { project: P, systemTypes: ['coding', 'marketing'] }]); + const off = transportFor(['webui']), ox = mkio(humanEnv(f.env)); + assert.equal(await run(['project', 'system-types', 'work', '--business', 'acme'], ox, { transport: off.transport }), 3); + assert.deepEqual(off.calls, []); + const agent = transportFor(['cli']); + assert.notEqual(await run(['project', 'system-types-set', 'work', '--types', 'coding', '--business', 'acme'], mkio({ ...humanEnv(f.env), CODEX_THREAD_ID: 'fixture-agent' }), { transport: agent.transport }), 0); + assert.deepEqual(agent.calls, []); +}); diff --git a/packages/organization/README.md b/packages/organization/README.md index 3ae14f673..8750422bf 100644 --- a/packages/organization/README.md +++ b/packages/organization/README.md @@ -578,3 +578,8 @@ The local development owner is accepted only through the existing named-instance selection on this release's throwaway development instances. Receipt subjects name that principal, not Jason. The dev-purpose bootstrap fence is a separate follow-up before person-used deployment. + +The optional `plugins.system-type` list enables installed system-type modules for +an organization. Missing means none. Unknown ids refuse validation. Changes +are owner-only; project selections are separate bus settings and a selection +whose type is disabled remains recorded as inactive. diff --git a/packages/organization/src/organization-config.mjs b/packages/organization/src/organization-config.mjs index 34e0a647a..7631729c7 100644 --- a/packages/organization/src/organization-config.mjs +++ b/packages/organization/src/organization-config.mjs @@ -12,6 +12,7 @@ import { parseCredentialRef } from "./credentials.mjs"; import { loadRole } from "./role.mjs"; import { END_PROTECTED } from "./vocabulary.mjs"; import { checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs"; +import { hasModule } from "../../plugins/src/loader.mjs"; import { checkMapping } from "../../library/src/mapping.mjs"; import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs"; import { checkAppearance } from "./appearance.mjs"; @@ -322,6 +323,9 @@ export function validateOrganizationDocument(document, file, { rolesDir }) { // here; whether each module loads is the loader's check, at load time. const plugins = document.plugins === undefined ? {} : structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) })); + for (const id of plugins["system-type"] ?? []) { + if (!hasModule("system-type", id)) refuse(`${file} plugins.system-type names unknown module ${id}`); + } const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage); const appearance = checkAppearance(document.appearance, `${file} appearance`); const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file); diff --git a/packages/organization/src/organization.mjs b/packages/organization/src/organization.mjs index 681c10621..cf888913f 100644 --- a/packages/organization/src/organization.mjs +++ b/packages/organization/src/organization.mjs @@ -105,6 +105,13 @@ function usageFence(old, document, actor) { fail("organization-usage-human-only"); } +function systemTypesFence(old, document, actor) { + if (canonicalJson(old.plugins?.['system-type'] ?? []) === canonicalJson(document?.plugins?.['system-type'] ?? [])) return; + const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]); + if (actor === old.human && actor !== 'tester' && !agents.includes(actor)) return; + fail('organization-system-types-human-only'); +} + // C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is // the business's human as the file stood before the write, and no agent or the tester under that // name. Compared as M9 compares projects, on the document and again on what lands on disk. @@ -286,6 +293,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) { replyLimitsFence(old, document, actor); modelQuotaFence(old, document, actor); usageFence(old, document, actor); + systemTypesFence(old, document, actor); validateBusinessDocument(document, file, { rolesDir }); const bytes = JSON.stringify(document, null, 2) + "\n"; // Check and log exactly what lands on disk, for every setter: a value JSON @@ -295,6 +303,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) { replyLimitsFence(old, written, actor); modelQuotaFence(old, written, actor); usageFence(old, written, actor); + systemTypesFence(old, written, actor); validateBusinessDocument(written, file, { rolesDir }); if (beforeWrite) { beforeWrite(); diff --git a/packages/organization/tests/system-type.test.mjs b/packages/organization/tests/system-type.test.mjs new file mode 100644 index 000000000..efbd36d72 --- /dev/null +++ b/packages/organization/tests/system-type.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { validateBusinessDocument } from '../src/organization-config.mjs'; +import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs'; + +test('organization enables only installed system types; absent means none', () => { + const root = tmp(), doc = businessDoc(root), file = join(root, 'businesses/acme.json'); + const validate = () => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }); + assert.deepEqual(validate().plugins['system-type'] ?? [], []); + doc.plugins = { 'system-type': ['coding', 'montage'] }; + assert.deepEqual(validate().plugins['system-type'], ['coding', 'montage']); + doc.plugins['system-type'] = ['not-installed']; + assert.throws(validate, /system-type.*not-installed/); +}); + +test('system type changes through the organization writer are owner-only', async t => { + const { rolesCopy, writeJson } = await import('./helpers.mjs'); + const { pluginSettings } = await import('../src/plugin-settings.mjs'); + const { rmSync } = await import('node:fs'); + const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true })); + const rolesDir = rolesCopy(root), doc = businessDoc(root), file = join(root, 'businesses/acme.json'); + writeJson(file, doc); + const settings = pluginSettings({ dir: root, rolesDir }); + const revision = settings.read('acme').revision; + assert.throws(() => settings.set('acme', { revision, plugins: { 'system-type': ['coding'] } }, 'tester'), /human-only/); + assert.deepEqual(settings.read('acme').plugins, {}); + assert.deepEqual(settings.set('acme', { revision, plugins: { 'system-type': ['coding'] } }, doc.human).plugins['system-type'], ['coding']); +}); diff --git a/packages/plugins/README.md b/packages/plugins/README.md index cd958df1b..30932e95b 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -496,3 +496,12 @@ counts; it has no quota authority. See record separation, bounded reads and per-key quota settings. A harness declares `outputPolicy({ proxy })` as `adapter`, `non-newline-stdout`, or `stdout`. The launcher validates and serializes that declaration; old sessions retain their recorded activity behavior. + +### System types + +`system-type` modules export `interfaceVersion = 1` and `systemType()`, returning +`{ id, label, description, toolsets }`. The id is the module directory name; +`toolsets` is a distinct list of toolset ids. The installed list is discovered +from module directories, and malformed modules refuse discovery. An organization +selects modules with `plugins.system-type`; an omitted list enables none. The +initial five modules declare no toolsets yet. diff --git a/packages/plugins/channel/cli/index.mjs b/packages/plugins/channel/cli/index.mjs index ce3b8a956..6e66a9200 100644 --- a/packages/plugins/channel/cli/index.mjs +++ b/packages/plugins/channel/cli/index.mjs @@ -6,6 +6,8 @@ const VERBS = new Set([ "project.create", "project.archive", "project.unarchive", + "project.system-types", + "project.system-types.set", "thread.list", "thread.create", "thread.messages", diff --git a/packages/plugins/src/kinds.mjs b/packages/plugins/src/kinds.mjs index d28fb4445..5f482b42f 100644 --- a/packages/plugins/src/kinds.mjs +++ b/packages/plugins/src/kinds.mjs @@ -80,6 +80,10 @@ export const KINDS = Object.freeze({ "model-provider": Object.freeze({ kind: "model-provider", interfaceVersion: 1, exports: Object.freeze(["catalogue", "preflight", "route"]), switch: "list", }), + // Project system types are installed modules, enabled per organization. + "system-type": Object.freeze({ + kind: "system-type", interfaceVersion: 1, exports: Object.freeze(["systemType"]), switch: "list", + }), // A Console colour theme (stream S4, PLAN C5). A module is data only: theme() takes nothing and returns // { displayName, tokens: { light, dim, dark } } // where each mode is a flat map of the Console's colour custom properties to #rrggbb strings. theme.mjs diff --git a/packages/plugins/src/system-types.mjs b/packages/plugins/src/system-types.mjs new file mode 100644 index 000000000..62ba27ff9 --- /dev/null +++ b/packages/plugins/src/system-types.mjs @@ -0,0 +1,19 @@ +import { loadInstalled } from './loader.mjs'; +import { NAME } from './kinds.mjs'; + +// Discovery is fail-closed: malformed or unloadable installed modules cannot be advertised. +export async function installedSystemTypes() { + const { modules, refused } = await loadInstalled('system-type'); + if (refused.length) throw Object.assign(new Error('system type module refused'), { code: 'system-type-invalid' }); + return modules.map(({ name, module }) => { + const value = module.systemType(); + if (!value || Object.keys(value).sort().join(',') !== 'description,id,label,toolsets' || value.id !== name || + typeof value.label !== 'string' || !value.label.trim() || /[\r\n]/.test(value.label) || + typeof value.description !== 'string' || !value.description.trim() || /[\r\n]/.test(value.description) || + !Array.isArray(value.toolsets) || value.toolsets.some(t => typeof t !== 'string' || !NAME.test(t)) || + new Set(value.toolsets).size !== value.toolsets.length) { + throw Object.assign(new Error(`invalid system type ${name}`), { code: 'system-type-invalid' }); + } + return structuredClone(value); + }); +} diff --git a/packages/plugins/system-type/business-management/index.mjs b/packages/plugins/system-type/business-management/index.mjs new file mode 100644 index 000000000..252647720 --- /dev/null +++ b/packages/plugins/system-type/business-management/index.mjs @@ -0,0 +1,5 @@ +export const interfaceVersion = 1; + +export function systemType() { + return { id: "business-management", label: "Business management", description: "Coordinate and manage business operations.", toolsets: [] }; +} diff --git a/packages/plugins/system-type/coding/index.mjs b/packages/plugins/system-type/coding/index.mjs new file mode 100644 index 000000000..77e9abb38 --- /dev/null +++ b/packages/plugins/system-type/coding/index.mjs @@ -0,0 +1,5 @@ +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 new file mode 100644 index 000000000..768603fcc --- /dev/null +++ b/packages/plugins/system-type/creative-writing/index.mjs @@ -0,0 +1,5 @@ +export const interfaceVersion = 1; + +export function systemType() { + return { id: "creative-writing", label: "Creative writing", description: "Develop and write creative works.", toolsets: [] }; +} diff --git a/packages/plugins/system-type/marketing/index.mjs b/packages/plugins/system-type/marketing/index.mjs new file mode 100644 index 000000000..0a9905c3c --- /dev/null +++ b/packages/plugins/system-type/marketing/index.mjs @@ -0,0 +1,5 @@ +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 new file mode 100644 index 000000000..766656902 --- /dev/null +++ b/packages/plugins/system-type/montage/index.mjs @@ -0,0 +1,5 @@ +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 cddc56872..6a6a5fa00 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", "theme"]); + assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "system-type", "theme"]); // Stream S4 (PLAN C5): a theme is data. interfaceVersion 1, one export, a list switch. assert.deepEqual({ ...KINDS.theme, exports: [...KINDS.theme.exports] }, { kind: "theme", interfaceVersion: 1, exports: ["theme"], switch: "list" }); assert.ok(Object.isFrozen(KINDS.theme) && Object.isFrozen(KINDS.theme.exports)); diff --git a/packages/plugins/tests/loader.test.mjs b/packages/plugins/tests/loader.test.mjs index 1fd971489..725416fae 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": [], theme: [] }; +const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] }; // A tree with one good module of each kind, already loaded. async function loadedTree(t, options) { @@ -56,7 +56,7 @@ test("REQ-PLUG-1 fixture modules of each kind switch on from a test organization const loader = await s.loader(); const result = await loader.load(business.plugins); assert.deepEqual(result.refused, []); - assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] }); + assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] }); assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage"); assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit"); assert.equal(loader.modules("limit-check")[0].mustStop(), false); @@ -170,7 +170,7 @@ test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses a assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/); assert.deepEqual(result.refused[0].kept, ["meter"]); assert.deepEqual(s.loader.logged.slice(before), result.refused); - assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] }); + assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] }); } finally { if (lock) chmodSync(lock, 0o755); } @@ -232,7 +232,7 @@ test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and t const loader = await s.loader(); // meter loads, absent refuses: the whole kind stays off, not half on. const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] }); - assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] }); + assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] }); assert.deepEqual(loader.loaded(), result.loaded); assert.deepEqual(loader.modules("usage-source"), []); assert.equal(loader.logged.length, 1); @@ -260,7 +260,7 @@ test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole se await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/); // An absent key switches the kind off. result = await s.loader.load({ "limit-check": ["cap"] }); - assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], theme: [] }); + assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], theme: [] }); }); test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => { diff --git a/packages/plugins/tests/system-type.test.mjs b/packages/plugins/tests/system-type.test.mjs new file mode 100644 index 000000000..849094e03 --- /dev/null +++ b/packages/plugins/tests/system-type.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createLoader, loadInstalled } from '../src/loader.mjs'; + +test('installed system types describe the five modules and unknown names refuse', async () => { + const { modules, refused } = await loadInstalled('system-type'); + assert.deepEqual(refused, []); + assert.deepEqual(modules.map(m => m.name), ['business-management', 'coding', 'creative-writing', 'marketing', 'montage']); + for (const { name, module } of modules) { + const info = module.systemType(); + assert.equal(info.id, name); + assert.ok(info.label && info.description && Array.isArray(info.toolsets)); + if (name === 'montage') assert.equal(info.label, 'Mosaic Montage'); + } + const loader = createLoader({ log() {} }); + const result = await loader.load({ 'system-type': ['unknown'] }); + assert.equal(result.refused.length, 1); + assert.deepEqual(result.loaded['system-type'], []); +}); diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh index beb959c85..babc193f6 100755 --- a/scripts/stack-release.sh +++ b/scripts/stack-release.sh @@ -421,7 +421,7 @@ plugin_checks() { # Row 181: the tables a bus writer fills without an event in the same transaction, in schema # order. A restore drops their rows after STAMP as it drops events, so the guard counts them too. # BUILD.md for row 181 names each writer; the T46 guard classifies every table in the schema. -COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects project_archives threads sessions session_defaults thread_opens thread_shares instance_names usage_records session_ops message_cancels request_events request_effects request_snapshots request_snapshot_events request_project_events" +COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects project_archives threads sessions session_defaults thread_opens thread_shares instance_names usage_records session_ops message_cancels request_events request_effects request_snapshots request_snapshot_events request_project_events project_system_types" STORE_JS=' const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");