feat(bus,plugins,organization,cli,webui): SP9 remove system types outright, bus schema 3v-3w drops project_system_types (#1873)

Jason's 2026-11-12 ruling: the only deployed system is the dev environment on
dragon-lin, so the system-type plug-in kind goes with no retired kind, fence,
conversion or new refusal code.

- plugins: system-type leaves KINDS; system-types.mjs and the five modules are
  deleted. An org file naming the kind refuses unknown-kind.
- organization: the plugins.system-type check, systemTypesFence and the
  system-types.console right are removed.
- bus: system-types.console, project.system-types[.set], the system-type half
  of the tester check, the runtime reads and system-type-not-enabled are
  removed. Step 3v-3w drops project_system_types, its index, its four guards
  and its rows; SCHEMA_VERSION is 3w (32 tables, 126 triggers, 5 views).
- cli: mosaic org system-types and mosaic project system-types[-set] are
  removed; stack-release COUNTED and the budget supervisor's closed list move
  to 3w.
- channel: the project.system-types verbs are removed.
- webui: the System types page, route, dashboard row, chips and project
  selection are removed.
- docs: REQ-SYS-1/2 now describe starter packs; README system-type text and
  the requirements titles follow.
This commit was merged in pull request #1960.
This commit is contained in:
jason.woltje committed 2026-11-12 21:02:46 -06:00
1 parent 0251acdad9
commit e6ab8bc1fd
81 files changed
+264 -1497

No files matched your search

+13 -7
View File
@@ -1240,16 +1240,22 @@ Added in version 1.3 (Q155).
changes it, not in one rewrite (Q155). Until it converts, Technical changes it, not in one rewrite (Q155). Until it converts, Technical
considerations lists where it is hard-coded. considerations lists where it is hard-coded.
### System types ### Starter packs
Added in version 1.20. Added in version 1.20.
- **REQ-SYS-1.** System types are a plug-in kind. Its modules include - **REQ-SYS-1.** Starter packs are a plug-in kind. Mosaic Stack ships five:
coding, creative writing, marketing, Mosaic Montage (the OpenMontage coding, creative writing, marketing, Mosaic Montage and business
fork) and business management. An organization enables a list of them. management. A pack is a starting configuration of role definitions,
- **REQ-SYS-2.** A project may use any system type its organization proposed hires, a board style and workflow templates. An organization or
enables, and none is required. An agent working in a project gets the project copies a pack in once and then owns and changes what it copied. A
toolsets of the system types that project uses. user can save an organization or project as their own template and apply it
the same way.
- **REQ-SYS-2.** Applying a pack grants nothing by itself. Every agent it
proposes is hired through the hire decision (REQ-AGENT-3), bounded by the
chief's own role, with any line above it approved by the owner. A pack never
sets credentials, rights, the autonomy setting or plug-in choices. Removing
a pack removes nothing it copied.
### Meta-harness ### Meta-harness
-16
View File
@@ -639,22 +639,6 @@ CREATE TRIGGER request_snapshot_events_no_replace BEFORE INSERT ON request_snaps
CREATE TRIGGER request_project_events_no_update BEFORE UPDATE ON request_project_events BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END; CREATE TRIGGER request_project_events_no_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_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; CREATE TRIGGER request_project_events_no_replace BEFORE INSERT ON request_project_events WHEN EXISTS (SELECT 1 FROM request_project_events WHERE seq=NEW.seq OR (business=NEW.business AND project=NEW.project AND revision=NEW.revision)) BEGIN SELECT RAISE(ABORT, 'request_project_events is append-only'); END;
-- Per-project selections are a human-authored append-only settings trail.
CREATE TABLE project_system_types (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
types TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX project_system_types_project ON project_system_types (business, project, seq);
CREATE TRIGGER project_system_types_no_update BEFORE UPDATE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_delete BEFORE DELETE ON project_system_types BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_no_replace BEFORE INSERT ON project_system_types WHEN EXISTS (SELECT 1 FROM project_system_types WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_system_types is append-only'); END;
CREATE TRIGGER project_system_types_business BEFORE INSERT ON project_system_types
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a system types row belongs to its project business'); END;
-- U4a (#1873): the task store built into each organization, the default task-tracker module -- U4a (#1873): the task store built into each organization, the default task-tracker module
-- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row -- (REQ-TASK-1). Each row is the whole task after one write, so a task's state is its latest row
-- and the rows are its history: who wrote it (a role and run, or the person), when, under which -- and the rows are its history: who wrote it (a role and run, or the person), when, under which
+2 -43
View File
@@ -178,7 +178,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
'thread.share', 'thread.unshare', 'thread.shares']); 'thread.share', 'thread.unshare', 'thread.shares']);
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb // 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. // refuses it human-required before its own checks, and these refuse every other cap.
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', export const CONSOLE_VERBS = Object.freeze(['project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's // 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 // 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. // request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
@@ -6177,28 +6177,6 @@ export class Broker {
return result; return result;
}).catch(e => { throw this.#refused(s, e instanceof BusError ? e : new BusError(e.code ?? 'request-failed'), request); }); }).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 (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 (!s.console || s.human !== this.#business(s.business).human) fail('human-required');
if (!this.#organization) fail('not-configured'); if (!this.#organization) fail('not-configured');
@@ -6394,7 +6372,7 @@ export class Broker {
if (["limits", "plugins", "instances", "routes", "organization.revision"].includes(request.verb)) keys(args, []); if (["limits", "plugins", "instances", "routes", "organization.revision"].includes(request.verb)) keys(args, []);
else if (request.verb === "plugins.set") { else if (request.verb === "plugins.set") {
keys(args, ["revision", "plugins", "usage"], ["revision", "plugins"]); keys(args, ["revision", "plugins", "usage"], ["revision", "plugins"]);
if (s.tester && object(args.plugins) && (Object.hasOwn(args.plugins, 'model-provider') || Object.hasOwn(args.plugins, 'system-type'))) fail('human-required'); if (s.tester && object(args.plugins) && Object.hasOwn(args.plugins, 'model-provider')) fail('human-required');
} }
// Row 165 (#1662): instances.set is human-only and outside ORGANIZATION_KEYS, so // 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). // organization.set and the chief of staff's tool never reach it (sage-0918).
@@ -8715,25 +8693,6 @@ export class Broker {
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null)) .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 })); .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 // #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 // 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 // are refused here. Each verb appends one project_archives row (the audit trail) and deletes
+11
View File
@@ -0,0 +1,11 @@
-- #1873 SP9: the system-type kind is removed outright (Jason, 2026-11-12: "The only deployed
-- system is the dev environment on dragon-lin. It can be wiped and reconfigured. No reason to hold
-- on to legacy for this change."). This step drops what 3t-3u created: the four guards, the index
-- and the table, with its rows. It is the one deletion of append-only rows in that go, and the
-- ruling is its authority. SQLite removes the table's sqlite_sequence row with the table.
DROP TRIGGER project_system_types_no_update;
DROP TRIGGER project_system_types_no_delete;
DROP TRIGGER project_system_types_no_replace;
DROP TRIGGER project_system_types_business;
DROP INDEX project_system_types_project;
DROP TABLE project_system_types;
+1 -1
View File
@@ -183,7 +183,7 @@ group(
"Read my_rails and use the applicable decision path; ask the owner to inspect grants if no supported path exists." "Read my_rails and use the applicable decision path; ask the owner to inspect grants if no supported path exists."
); );
group( group(
"not-configured project-already-archived project-archived project-exists project-not-archived project-not-configured project-not-found project-record-invalid project-remote-configured project-remote-invalid project-remote-library project-remote-protected project-remote-unknown project-spec-human-only system-type-not-enabled tracker-not-store usage-not-configured", "not-configured project-already-archived project-archived project-exists project-not-archived project-not-configured project-not-found project-record-invalid project-remote-configured project-remote-invalid project-remote-library project-remote-protected project-remote-unknown project-spec-human-only tracker-not-store usage-not-configured",
"R3/role scope: configured project and service state", "R3/role scope: configured project and service state",
"Read the supported project/service status and ask its owner to repair missing configuration or state before retrying." "Read the supported project/service status and ask its owner to repair missing configuration or state before retrying."
); );
-3
View File
@@ -27,7 +27,6 @@ import { readSessions, sessionsFile, stopSession } from '../../seat/src/session.
import { compactionCount, storeDir } from '../../seat/src/compactions.mjs'; import { compactionCount, storeDir } from '../../seat/src/compactions.mjs';
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs'; import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
import { createLoader, hasModule } from '../../plugins/src/loader.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 { pluginSettings } from '../../organization/src/plugin-settings.mjs';
import { loadManagementClient } from '../../model-proxy/src/client.mjs'; import { loadManagementClient } from '../../model-proxy/src/client.mjs';
import { ProxyPage } from './proxy-page.mjs'; import { ProxyPage } from './proxy-page.mjs';
@@ -1200,8 +1199,6 @@ export async function startBroker({
if (preview === undefined && typeof digest === 'string') return templateSave({ ...base, document, name, project, digest, assertClean }); if (preview === undefined && typeof digest === 'string') return templateSave({ ...base, document, name, project, digest, assertClean });
throw new TemplateError('invalid-request', 'template.save takes preview: true or the preview digest'); throw new TemplateError('invalid-request', 'template.save takes preview: true or the preview digest');
} }
if (verb === 'system-types.installed') return installedSystemTypes({dataRoot:instanceRoot ?? dataRoot});
if (verb === 'system-types.current') return settings.current(id).plugins?.['system-type'] ?? [];
if(verb==='limits')return withTokens(id,settings.read(id)); if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='plugins')return {...plugins.read(id), available:{harness:levels.names ?? []}}; 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. // Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
+7 -3
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'); const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = 'e5868996851ee60a3c6d2798a17b9da446ba666c859d84d1b575ca5104ae61f8'; export const SCHEMA_SHA256 = '1c305d842619360c14c8388cd43cb9de81afbe46ff0662fd5de9851be9ff3ea0';
export const SCHEMA_VERSION = '3v'; export const SCHEMA_VERSION = '3w';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned. // 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 legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65'; const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -120,6 +120,11 @@ const migrations = [{
// Only a new append-only table, its index and its guards; no existing row changes. // Only a new append-only table, its index and its guards; no existing row changes.
from: '3u', to: '3v', from: '3u', to: '3v',
sql: readFileSync(new URL('./migrations/3u-3v.sql', import.meta.url), 'utf8'), sql: readFileSync(new URL('./migrations/3u-3v.sql', import.meta.url), 'utf8'),
}, {
// SP9 (#1873, Jason's ruling 2026-11-12): drops the table 3t-3u created, its index and its four
// guards, with its rows.
from: '3v', to: '3w',
sql: readFileSync(new URL('./migrations/3v-3w.sql', import.meta.url), 'utf8'),
}]; }];
const timedTables = [ const timedTables = [
'events', 'events',
@@ -146,7 +151,6 @@ const timedTables = [
'request_snapshot_events', 'request_snapshot_events',
'request_project_events', 'request_project_events',
'project_archives', 'project_archives',
'project_system_types',
'store_tasks', 'store_tasks',
]; ];
const hex = (value) => createHash('sha256').update(value).digest('hex'); const hex = (value) => createHash('sha256').update(value).digest('hex');
@@ -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']; 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 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. // Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3h(t) { function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-migrate-')); const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true })); t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -96,6 +96,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql')); copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql')); copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql')); copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
copyFileSync(here('../src/migrations/3v-3w.sql'), join(moduleRoot, 'src/migrations/3v-3w.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
} }
@@ -103,7 +104,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path); const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50. // 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(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]); assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -153,7 +154,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows); assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it. // Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows); assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
}); });
@@ -165,14 +166,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h, schema: ddl3h,
store: (source) => source 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_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3h';") .replace("export const SCHEMA_VERSION = '3w';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'), .replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
}); });
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8'); 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.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), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/); assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows); assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
}); });
+2 -2
View File
@@ -615,7 +615,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8'); let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source 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_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3c';") .replace("export const SCHEMA_VERSION = '3w';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); .replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source); assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step'); assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -639,7 +639,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
t.after(() => store.close()); t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v'); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}'); assert.equal(store.get("SELECT 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'); 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 }); const b = new Broker({ store, businesses: demo(), organization: null });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source); const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v'); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}'); assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0); 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; 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'); const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8'); 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}';`) source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3w';") .replace("export const SCHEMA_VERSION = '3w';", "export const SCHEMA_VERSION = '3x';")
.replace('}];\nconst timedTables', `}, { from: '3v', to: '3w', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`); .replace('}];\nconst timedTables', `}, { from: '3w', to: '3x', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source); writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL); writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql', '3u-3v.sql']) for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql', '3s-3t.sql', '3t-3u.sql', '3u-3v.sql', '3v-3w.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name)); 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 { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true }); const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w'); assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3x');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture'); assert.equal(future.get("SELECT 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 count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved'); 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 command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' }); const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr); assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3v ready/); assert.match(migrated.stdout, /bus schema 3w ready/);
new Store(root).close(); new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' }); const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2); assert.equal(usage.status, 2);
+1 -1
View File
@@ -89,7 +89,7 @@ function setup(t) {
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls; // #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
// no unsuffixed verb is ever a console verb. // no unsuffixed verb is ever a console verb.
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => { test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console', 'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console',
// Starter packs SP4 (#1873): the owner's pack list and preview, and the three pack writes. // Starter packs SP4 (#1873): the owner's pack list and preview, and the three pack writes.
'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console', 'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console',
+7 -6
View File
@@ -36,11 +36,11 @@ function source3j(t) {
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens']; 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 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. // Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function rows(path) { function rows(path) {
const db = new DatabaseSync(path, { readOnly: true }); const db = new DatabaseSync(path, { readOnly: true });
try { try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").all() return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','store_tasks')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all() .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 }))])); .filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); } } finally { db.close(); }
@@ -86,6 +86,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql')); copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql')); copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql')); copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
copyFileSync(here('../src/migrations/3v-3w.sql'), join(moduleRoot, 'src/migrations/3v-3w.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
} }
function fresh(t) { function fresh(t) {
@@ -107,7 +108,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
const { root, path } = source3j(t), before = rows(path), was = master(path); const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
assert.deepEqual(migrated3l(rows(path)), before); assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path); const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r))); assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -133,7 +134,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before); assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
}); });
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => { test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -144,14 +145,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3j, schema: ddl3j,
store: (source) => source 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_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3j';") .replace("export const SCHEMA_VERSION = '3w';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'), .replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
}); });
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8'); 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.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), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/); assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after); assert.deepEqual(rows(path), after);
}); });
+7 -6
View File
@@ -22,7 +22,7 @@ const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1',
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens']; 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 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. // Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'project_system_types', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'store_tasks', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3i(t) { function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-')); const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true })); t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -42,7 +42,7 @@ function source3i(t) {
function rows(path) { function rows(path) {
const db = new DatabaseSync(path, { readOnly: true }); const db = new DatabaseSync(path, { readOnly: true });
try { try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','project_system_types','store_tasks')").all() return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives','store_tasks')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all() .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 }))])); .filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); } } finally { db.close(); }
@@ -89,6 +89,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql')); copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql'));
copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql')); copyFileSync(here('../src/migrations/3t-3u.sql'), join(moduleRoot, 'src/migrations/3t-3u.sql'));
copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql')); copyFileSync(here('../src/migrations/3u-3v.sql'), join(moduleRoot, 'src/migrations/3u-3v.sql'));
copyFileSync(here('../src/migrations/3v-3w.sql'), join(moduleRoot, 'src/migrations/3v-3w.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
} }
function fresh(t) { function fresh(t) {
@@ -112,7 +113,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
const { root, path } = source3i(t), before = rows(path), was = master(path); const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
assert.deepEqual(migrated3l(rows(path)), before); assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path).filter(r => !LATER.includes(r.name)); 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))); assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -137,7 +138,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before); assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
}); });
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => { test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -148,14 +149,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3i, schema: ddl3i,
store: (source) => source 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_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3i';") .replace("export const SCHEMA_VERSION = '3w';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'), .replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
}); });
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8'); 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.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), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/); assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after); assert.deepEqual(rows(path), after);
}); });
+1 -1
View File
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
try { try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v'); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null }); assert.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.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) }); assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
+2 -3
View File
@@ -198,8 +198,7 @@ test('3p through 3s migration preserves existing request rows and the append-onl
const path = join(root, 'bus/bus.sqlite'), ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8') 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. // 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/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '') // 3u-3v (#1873, U4a) adds store_tasks; the same holds for it.
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '') .replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace(",'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced'", ''); .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 db = new DatabaseSync(path); db.exec(ddl);
@@ -216,7 +215,7 @@ test('3p through 3s migration preserves existing request rows and the append-onl
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); t.after(() => store.close()); const store = new Store(root, { migrateSchema: true }); t.after(() => store.close());
assert.deepEqual(store.all('SELECT * FROM request_events'), before); assert.deepEqual(store.all('SELECT * FROM request_events'), before);
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v'); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w');
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')", at); 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('DELETE FROM request_events'), /append-only/);
assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/); assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/);
+5 -5
View File
@@ -143,8 +143,8 @@ const here = (p) => new URL(p, import.meta.url);
const migrationSQL = readFileSync(here('../src/migrations/3s-3t.sql'), 'utf8'); const migrationSQL = readFileSync(here('../src/migrations/3s-3t.sql'), 'utf8');
const currentDDL = readFileSync(here('../schema.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'); assert.equal(currentDDL.split(migrationSQL).length, 2, 'schema.sql holds the 3s→3t step exactly once');
// 3u-3v (#1873, U4a) adds store_tasks after them; a 3s store has neither. // 3u-3v (#1873, U4a) adds store_tasks after it; a 3s store has neither table.
const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), '').replace(readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8'), ''); const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8'), '');
function source3s(t) { function source3s(t) {
const root = mkdtempSync(join(tmpdir(), 'project-archive-3s-')); const root = mkdtempSync(join(tmpdir(), 'project-archive-3s-'));
@@ -179,7 +179,7 @@ test('a 3s store migrates to 3t: project_archives is new and empty, every row st
assert.deepEqual(master(path), was, 'the refused open changed nothing'); assert.deepEqual(master(path), was, 'the refused open changed nothing');
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
t.after(() => store.close()); t.after(() => store.close());
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
assert.equal(store.get("SELECT id FROM events").id, 'preserved'); assert.equal(store.get("SELECT id FROM events").id, 'preserved');
assert.equal(store.get("SELECT name FROM projects").name, 'kept'); assert.equal(store.get("SELECT name FROM projects").name, 'kept');
assert.deepEqual(store.all('SELECT seq,at,business,project,op,by FROM project_archives'), []); assert.deepEqual(store.all('SELECT seq,at,business,project,op,by FROM project_archives'), []);
@@ -212,7 +212,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
const source = readFileSync(here('../src/store.mjs'), 'utf8'); const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source 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_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3s).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3s';") .replace("export const SCHEMA_VERSION = '3w';", "export const SCHEMA_VERSION = '3s';")
.replace(/\}, \{\n {2}\/\/ #797 MVP3[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); .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'); 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); writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -222,7 +222,7 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa
const Store3s = (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; const Store3s = (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
assert.throws(() => new Store3s(root), /schema-mismatch/); assert.throws(() => new Store3s(root), /schema-mismatch/);
assert.throws(() => new Store3s(root, { migrateSchema: true }), /schema-mismatch/); assert.throws(() => new Store3s(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
assert.deepEqual(master(path), was); 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. // 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); const { root: old } = source3s(t);
+5 -6
View File
@@ -90,7 +90,7 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra }); options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
test('the console verb list includes the artifact read and existing owner controls', () => { test('the console verb list includes the artifact read and existing owner controls', () => {
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console', assert.deepEqual([...CONSOLE_VERBS], ['project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console', 'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console',
// Starter packs SP4 (#1873): the owner's pack list and preview, and the three pack writes. // Starter packs SP4 (#1873): the owner's pack list and preview, and the three pack writes.
'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console', 'packs.console', 'pack.preview.console', 'pack.apply.console', 'board.set.console', 'workflow.set.console',
@@ -306,7 +306,7 @@ test('a cancel is refused when the owner-to-PM edge closed, with no withdrawn de
}); });
test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => { test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => {
assert.equal(SCHEMA_VERSION, '3v'); assert.equal(SCHEMA_VERSION, '3w');
const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-')); const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-'));
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 }); 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'; const path = join(root, 'bus/bus.sqlite'), AT = '2026-11-03T00:00:00.000Z';
@@ -314,11 +314,10 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
// 3s-3t (#797) adds project_archives, outside request_events; the 3q fixture predates it and // 3s-3t (#797) adds project_archives, outside request_events; the 3q fixture predates it and
// the replayed chain recreates it, so the step's text is removed here like the kinds are. // 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'); const step3t = readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8');
// 3t-3u (#1873, SYS1) adds project_system_types the same way, and 3u-3v (U4a) adds store_tasks. // 3u-3v (#1873, U4a) adds store_tasks the same way.
const step3u = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
const step3v = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'); const step3v = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8');
// 3p-3q shipped the PRD kinds and mode.changed; 3q-3r adds the three Task 3 kinds after them. // 3p-3q shipped the PRD kinds and mode.changed; 3q-3r adds the three Task 3 kinds after them.
const ddl3q = current.replace(step3t, '').replace(step3u, '').replace(step3v, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'"); const ddl3q = current.replace(step3t, '').replace(step3v, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'");
assert.notEqual(ddl3q, current, 'the fixture is the narrower schema'); assert.notEqual(ddl3q, current, 'the fixture is the narrower schema');
const migration = readFileSync(new URL('../src/migrations/3q-3r.sql', import.meta.url), 'utf8'); 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'); assert.ok(migration.includes("'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'"), 'the step carries every 3q kind plus its own');
@@ -335,7 +334,7 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row,
db.close(); chmodSync(path, 0o600); db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/); 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' }); const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n'); assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3w ready\n');
const store = new Store(root); 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'); 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); const fresh = new DatabaseSync(':memory:'); fresh.exec(current);
@@ -230,8 +230,7 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
// 3s-3t (#797) adds project_archives; the 3r fixture predates it and the replayed chain recreates it. // 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') 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/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '') // 3u-3v (#1873, U4a) adds store_tasks; the same holds for it.
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '') .replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace(",'advanced'", '')); .replace(",'advanced'", ''));
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3r'); db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3r');
@@ -253,7 +252,7 @@ test('3r to 3s migration preserves journal and sequence and admits only the new
assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before); assert.deepEqual(store.all('SELECT * 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.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 name FROM sqlite_master WHERE type='index' AND name='request_events_request'").name, 'request_events_request');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v'); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w');
store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',16,'advanced',?,'{}')", at); 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.equal(store.get("SELECT max(seq) n FROM request_events").n, 42);
assert.throws(() => store.run("UPDATE request_events SET kind='opened'"), /append-only/); assert.throws(() => store.run("UPDATE request_events SET kind='opened'"), /append-only/);
+4 -6
View File
@@ -278,8 +278,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
// 3s-3t (#797) adds project_archives after the request tables; the pre-3t fixtures below // 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. // 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/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '') // 3u-3v (#1873, U4a) adds store_tasks; the same holds for it.
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '') .replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'"); .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'); const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8');
@@ -293,7 +292,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch
db.close(); chmodSync(path, 0o600); const before = readFileSync(path); db.close(); chmodSync(path, 0o600); const before = readFileSync(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before); 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' }); const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n'); assert.equal(SCHEMA_VERSION, '3v'); assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3w ready\n'); assert.equal(SCHEMA_VERSION, '3w');
const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved'); 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); 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); store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0);
@@ -316,8 +315,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
// The 3o schema is the current one with the narrower kind list. // The 3o schema is the current one with the narrower kind list.
const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8') 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/3s-3t.sql', import.meta.url), 'utf8'), '')
.replace(readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8'), '') // 3u-3v (#1873, U4a) adds store_tasks; the same holds for it.
// 3u-3v (#1873, U4a) adds store_tasks after project_system_types; the same holds for it.
.replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '') .replace(readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8'), '')
.replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'"); .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 { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z';
@@ -335,7 +333,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t =>
db.close(); chmodSync(path, 0o600); db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/); 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' }); const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3v ready\n'); assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3w ready\n');
const store = new Store(root); 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_events ORDER BY seq'), before, 'every row keeps its seq and bytes');
assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects); assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects);
+73
View File
@@ -0,0 +1,73 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SCHEMA_VERSION, Store, schemaDigest } from '../src/store.mjs';
// #1873 SP9: bus schema step 3v-3w drops project_system_types with its index, its four triggers and
// its rows (Jason's 2026-11-12 ruling removes the system-type kind outright).
const TABLE = 'project_system_types';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
const createdBy3u = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
const named = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return {
master: db.prepare('SELECT type,name FROM sqlite_master WHERE name LIKE ? OR tbl_name=? ORDER BY type,name').all(`${TABLE}%`, TABLE).map((r) => ({ ...r })),
sequence: db.prepare('SELECT name FROM sqlite_sequence WHERE name=?').all(TABLE).map((r) => r.name),
digest: schemaDigest(db),
version: db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value,
};
} finally {
db.close();
}
};
test('SP9 a store opened at schema 3w has no project_system_types table', (t) => {
assert.equal(SCHEMA_VERSION, '3w');
assert.equal(ddl.includes(TABLE), false, 'schema.sql no longer creates the table');
const root = mkdtempSync(join(tmpdir(), 'sp9-fresh-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
new Store(root).close();
const fresh = named(join(root, 'bus/bus.sqlite'));
assert.equal(fresh.version, '3w');
assert.deepEqual(fresh.master, []);
});
test('SP9 a 3v store holding project_system_types rows migrates to 3w with nothing of the table left', (t) => {
const root = mkdtempSync(join(tmpdir(), 'sp9-3v-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite');
const db = new DatabaseSync(path);
// The 3v schema is today's schema.sql plus what 3t-3u created; the store's step check refuses any
// other shape, so a migration that succeeds proves the fixture is a real 3v store.
db.exec(ddl + createdBy3u);
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3v');
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES (?,?)').run('comment_cursor_epoch', '2026-11-12T00:00:00.000Z');
db.prepare('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)').run('p1', '2026-11-12T00:00:00.000Z', 'acme', 'example', null, 'jason');
const select = db.prepare(`INSERT INTO ${TABLE}(at,business,project,types,by) VALUES(?,?,?,?,?)`);
select.run('2026-11-12T00:00:01.000Z', 'acme', 'p1', '["coding"]', 'jason');
select.run('2026-11-12T00:00:02.000Z', 'acme', 'p1', '["coding","montage"]', 'jason');
db.close();
chmodSync(path, 0o600);
const before = named(path);
assert.deepEqual(before.master.map((r) => r.name).sort(), [TABLE, `${TABLE}_business`, `${TABLE}_no_delete`, `${TABLE}_no_replace`, `${TABLE}_no_update`, `${TABLE}_project`]);
assert.deepEqual(before.sequence, [TABLE]);
assert.throws(() => new Store(root), /schema-mismatch/);
assert.deepEqual(named(path), before, 'the refused open changed nothing');
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get('SELECT name FROM projects WHERE id=?', 'p1').name, 'example', 'other rows stay');
const after = named(path);
assert.equal(after.version, '3w');
assert.deepEqual(after.master, [], 'no table, index or trigger of that name is left');
assert.deepEqual(after.sequence, [], 'no sqlite_sequence row of that name is left');
const freshRoot = mkdtempSync(join(tmpdir(), 'sp9-ref-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
new Store(freshRoot).close();
assert.equal(after.digest, named(join(freshRoot, 'bus/bus.sqlite')).digest, 'the migrated schema is the fresh one');
});
+3 -2
View File
@@ -528,7 +528,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// Task 4 (3p to 3q), Task 3 (3q to 3r) and Task 6b (3r to 3s) widen request_events.kind in place. // 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. // #797 MVP3 (3s to 3t) adds project_archives and its five guards; project-archive.test.mjs tests them.
// 3t to 3u adds project_system_types and four guards; U4a #1873 (3u to 3v) adds store_tasks and its seven guards. // 3t to 3u adds project_system_types and four guards; U4a #1873 (3u to 3v) adds store_tasks and its seven guards.
assert.equal(count('table') - 1, 33); // SP9 #1873 (3v to 3w) drops project_system_types and its four guards.
assert.equal(count('trigger'), 130); assert.equal(count('table') - 1, 32);
assert.equal(count('trigger'), 126);
assert.equal(count('view'), 5); assert.equal(count('view'), 5);
}); });
+4 -4
View File
@@ -530,9 +530,9 @@ test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are
const before = tables(path); const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
const after = tables(path); const after = tables(path);
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) { for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'store_tasks']) {
assert.deepEqual(after[name], [], name); assert.deepEqual(after[name], [], name);
delete after[name]; delete after[name];
} }
@@ -571,7 +571,7 @@ async function engine3m(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8'); const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source 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_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3m';") .replace("export const SCHEMA_VERSION = '3w';", "export const SCHEMA_VERSION = '3m';")
.replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); .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'); 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); 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); const Store3m = await engine3m(root);
assert.throws(() => new Store3m(root), /schema-mismatch/); assert.throws(() => new Store3m(root), /schema-mismatch/);
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/); assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before); 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. // The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
+3 -3
View File
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
const { root, path } = source3k(t); const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
// The migrated schema is the fresh one, object for object. // The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-')); const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true })); 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 source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source 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_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3k';") .replace("export const SCHEMA_VERSION = '3w';", "export const SCHEMA_VERSION = '3k';")
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); .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'); 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); 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); const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/); assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/); assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before); 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. // The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
try { try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v'); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null }); assert.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.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []); assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
-109
View File
@@ -1,109 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
test('project selections require enabled types, retain inactive selections, and only the owner writes', t => {
const root = mkdtempSync(join(tmpdir(), 'sys-'));
const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: [], crossRole: [] } } }, projects: {}, plugins: { 'system-type': ['coding'] }, vars: { 'bus.tester': true } };
const broker = new Broker({ store, businesses: { demo: business } });
const owner = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = broker.bindTester({ business: 'demo' });
const agent = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
const call = (verb, args = {}, cap = owner) => broker.request(cap, { verb, args });
const project = call('project.create', { name: 'example' }).id;
assert.deepEqual(call('project.system-types', { project }), { project, selected: [], active: [], inactive: [] });
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['montage'] }), /system-type-not-enabled/);
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['coding'] }, tester), /tester-refused/);
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['coding'] }, agent), /human-required/);
assert.throws(() => call('project.system-types.set', { project, systemTypes: ['coding', 'coding'] }), /invalid-request/);
call('project.system-types.set', { project, systemTypes: ['coding'] });
assert.deepEqual(call('project.system-types', { project }).active, ['coding']);
broker.reloadBusiness('demo', { ...business, plugins: { 'system-type': [] } });
assert.deepEqual(call('project.system-types', { project }), { project, selected: ['coding'], active: [], inactive: ['coding'] });
assert.deepEqual(store.all('SELECT by FROM project_system_types').map(r => r.by), ['jason']);
assert.throws(() => store.run("UPDATE project_system_types SET types='[]'"), /append-only/);
assert.throws(() => store.run('DELETE FROM project_system_types'), /append-only/);
assert.throws(() => store.run("INSERT INTO project_system_types(at,business,project,types,by) VALUES(?,?,?,?,?)", new Date().toISOString(), 'other', project, '[]', 'jason'), /belongs to its project/);
});
test('console owner can read system types but cannot write project selections', async t => {
const root = mkdtempSync(join(tmpdir(), 'sys-'));
const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: [], crossRole: [] } } }, projects: {}, plugins: { 'system-type': ['coding'] }, vars: { 'bus.tester': true } };
let discoveries = 0, enabled = ['coding'];
const broker = new Broker({ store, businesses: { demo: business }, organization: (_id, verb) => { if (verb === 'system-types.current') return enabled; if (verb === 'system-types.installed') { discoveries++; return installedSystemTypes(); } } });
const owner = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const consoleOwner = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
const tester = broker.bindTester({ business: 'demo' });
assert.throws(() => broker.request(tester, { verb: 'plugins.set', args: { revision: 'a'.repeat(64), plugins: { 'system-type': ['coding'] } } }), /human-required/);
const project = broker.request(owner, { verb: 'project.create', args: { name: 'example' } }).id;
const result = await broker.request(consoleOwner, { verb: 'system-types.console', args: { project } });
assert.deepEqual(result.enabled, ['coding']);
assert.equal(result.installed.length, 5);
assert.equal(discoveries, 1);
broker.request(owner, { verb: 'project.system-types.set', args: { project, systemTypes: ['coding'] } });
enabled = [];
assert.deepEqual(broker.request(owner, { verb: 'project.system-types', args: { project } }).inactive, ['coding']);
assert.throws(() => broker.request(owner, { verb: 'project.system-types.set', args: { project, systemTypes: ['coding'] } }), /system-type-not-enabled/);
assert.deepEqual(result.project, { project, selected: [], active: [], inactive: [] });
assert.throws(() => broker.request(consoleOwner, { verb: 'project.system-types.set', args: { project, systemTypes: ['coding'] } }), /human-required/);
});
test('3t store migrates offline without altering existing project rows', async t => {
const { DatabaseSync } = await import('node:sqlite');
const { readFileSync, mkdirSync, chmodSync } = await import('node:fs');
const { schemaDigest } = await import('../src/store.mjs');
const root = mkdtempSync(join(tmpdir(), 'sys-migration-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const dbpath = join(root, 'bus/bus.sqlite');
const db = new DatabaseSync(dbpath);
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
const step = readFileSync(new URL('../src/migrations/3t-3u.sql', import.meta.url), 'utf8');
assert.equal(ddl.split(step).length, 2);
// 3u-3v (U4a) adds store_tasks after this step; a 3t store has neither.
const later = readFileSync(new URL('../src/migrations/3u-3v.sql', import.meta.url), 'utf8');
db.exec(ddl.replace(step, '').replace(later, ''));
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3t');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', '2026-01-01T00:00:00.000Z');
db.prepare('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)').run('old', '2026-01-01T00:00:00.000Z', 'demo', 'old', null, 'jason');
db.close(); chmodSync(dbpath, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/);
const migrated = new Store(root, { migrateSchema: true }); t.after(() => migrated.close());
assert.equal(migrated.get('SELECT name FROM projects WHERE id=?', 'old').name, 'old');
assert.equal(migrated.get('SELECT count(*) AS n FROM project_system_types').n, 0);
assert.equal(migrated.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v');
});
test('runtime reads the current organization file for project selections and console discovery', async t => {
const { mkdirSync } = await import('node:fs');
const { startBroker } = await import('../src/runtime.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../organization/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'sys-runtime-'));
const rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
doc.plugins = { 'system-type': ['coding'] };
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: business }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const consoleOwner = bus.broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
const call = (verb, args = {}, cap = human) => bus.broker.request(cap, { verb, args });
const project = call('project.create', { name: 'example' }).id;
call('project.system-types.set', { project, systemTypes: ['coding'] });
const view = await call('system-types.console', { project }, consoleOwner);
assert.deepEqual(view.enabled, ['coding']);
assert.deepEqual(view.project.active, ['coding']);
assert.equal(view.installed.length, 5);
const plugins = call('plugins');
call('plugins.set', { revision: plugins.revision, plugins: { 'system-type': [] } });
assert.deepEqual(call('project.system-types', { project }).inactive, ['coding']);
});
+3 -2
View File
@@ -402,7 +402,8 @@ test('REQ-TASK-1 a 3u store migrates to 3v: store_tasks is new and empty, every
t.after(() => rmSync(root, { recursive: true, force: true })); t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 }); mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path); const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
db.exec(currentDDL.replace(stepSQL, '')); // SP9 (#1873): a 3u store still has what 3t-3u created; 3v-3w drops it on the way to the fresh schema.
db.exec(currentDDL.replace(stepSQL, '') + readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'));
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3u'); db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3u');
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db)); db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
db.prepare('INSERT INTO meta VALUES (?,?)').run('comment_cursor_epoch', '2026-11-09T00:00:00.000Z'); db.prepare('INSERT INTO meta VALUES (?,?)').run('comment_cursor_epoch', '2026-11-09T00:00:00.000Z');
@@ -414,7 +415,7 @@ test('REQ-TASK-1 a 3u store migrates to 3v: store_tasks is new and empty, every
assert.deepEqual(master(path), was, 'the refused open changed nothing'); assert.deepEqual(master(path), was, 'the refused open changed nothing');
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
t.after(() => store.close()); t.after(() => store.close());
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
assert.equal(store.get('SELECT id FROM events').id, 'preserved'); assert.equal(store.get('SELECT id FROM events').id, 'preserved');
assert.deepEqual(store.all('SELECT * FROM store_tasks'), []); assert.deepEqual(store.all('SELECT * FROM store_tasks'), []);
const freshRoot = mkdtempSync(join(tmpdir(), 'ts-fresh-')); const freshRoot = mkdtempSync(join(tmpdir(), 'ts-fresh-'));
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8'); const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source 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_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3d';") .replace("export const SCHEMA_VERSION = '3w';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); .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'); assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned); 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/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
t.after(() => store.close()); t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v'); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens']) for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table); 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. // 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 command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' }); const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr); assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3v ready', ''].join('\n')); assert.equal(migrated.stdout, [...REPORT, 'bus schema 3w ready', ''].join('\n'));
}); });
+4 -4
View File
@@ -403,12 +403,12 @@ test('a 3l store migrates to 3m: thread_shares is new and empty, and every other
const before = tables(path); const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
const after = tables(path); const after = tables(path);
assert.deepEqual(after.thread_shares, []); assert.deepEqual(after.thread_shares, []);
delete after.thread_shares; delete after.thread_shares;
// Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too. // Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too.
for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'project_system_types', 'store_tasks']) { for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives', 'store_tasks']) {
assert.deepEqual(after[name], [], name); assert.deepEqual(after[name], [], name);
delete after[name]; delete after[name];
} }
@@ -439,7 +439,7 @@ async function engine3l(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8'); const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source 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_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3l';") .replace("export const SCHEMA_VERSION = '3w';", "export const SCHEMA_VERSION = '3l';")
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); .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'); 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); 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); const Store3l = await engine3l(root);
assert.throws(() => new Store3l(root), /schema-mismatch/); assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/); assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3v'); assert.equal(version(path), '3w');
assert.deepEqual(master(path), was); assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before); 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. // The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
try { try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3v'); assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3w');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null }); assert.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 count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder'); assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
-9
View File
@@ -1370,15 +1370,6 @@ field, digest and archive checks. Edit and archive need the task's digest from
`mosaic tasks --json` as `--expect`; a stale one is refused `task-conflict` `mosaic tasks --json` as `--expect`; a stale one is refused `task-conflict`
(exit 2) and nothing is written. They refuse inside an agent run. (exit 2) and nothing is written. They refuse inside an agent run.
System types are installed plug-ins. `mosaic org system-types installed` prints
module metadata; `mosaic org system-types enabled ID` prints an organization's enabled
ids. The owner changes the enabled list through the existing organization file
update or `mosaic plugins set`. `mosaic project system-types PROJECT --org ID`
shows selected, active and inactive ids. The owner replaces the selection with
`mosaic project system-types-set PROJECT --types coding,montage --org ID`; an
empty `--types ''` clears it. Disabled selections stay recorded but become
inactive. Project settings changes are append-only and require human proof.
## Accounts ## Accounts
`mosaic accounts` administers the instance-level accounts store locally, as the `mosaic accounts` administers the instance-level accounts store locally, as the
+2 -1
View File
@@ -75,7 +75,8 @@ export function readAccounting({ dataRoot,business,start,end }) {
try { try {
db.exec('BEGIN'); db.exec('BEGIN');
// U4a (#1873) 3u-3v adds only store_tasks, which accounting never reads, so 3v joins the closed list. // U4a (#1873) 3u-3v adds only store_tasks, which accounting never reads, so 3v joins the closed list.
if (!['3s','3t','3u','3v'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused'); // SP9 (#1873) 3v-3w drops only the table 3t-3u added, which accounting never reads, so 3w joins too.
if (!['3s','3t','3u','3v','3w'].includes(db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value)) fail('test-store-schema-refused');
const store = { all:(sql,...args) => db.prepare(sql).all(...args) }; const store = { all:(sql,...args) => db.prepare(sql).all(...args) };
const records = turnRecords(store,business); const records = turnRecords(store,business);
const raw = store.all("SELECT * FROM usage_records WHERE business=? AND source<>'@proxy/cliproxy-queue' ORDER BY seq",business); const raw = store.all("SELECT * FROM usage_records WHERE business=? AND source<>'@proxy/cliproxy-queue' ORDER BY seq",business);
+1 -1
View File
@@ -79,7 +79,7 @@ test('accounting reads a real store at the current bus schema version',t=>{
const records=readAccounting({dataRoot:f.dataRoot,business:f.c.business,start:f.c.start,end:f.c.end}); 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); assert.equal(records.length,1);assert.equal(records[0].input,1);
}); });
for(const version of ['3s','3r','3v',null]) test(`accounting ${version==='3s'?'accepts':'refuses'} schema ${version??'missing'}`,t=>{ for(const version of ['3s','3r','3x',null]) test(`accounting ${version==='3s'?'accepts':'refuses'} schema ${version??'missing'}`,t=>{
const f=setup(t,100); const f=setup(t,100);
// Only this disposable fixture is altered; the production meta table is append-only. // 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'); f.store.run('DROP TRIGGER meta_no_update');f.store.run('DROP TRIGGER meta_no_delete');
+1 -1
View File
@@ -778,7 +778,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps); if (["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] === "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" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
if (verb === "project") return ["create", "list", "archive", "unarchive", "system-types", "system-types-set"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps)); if (verb === "project") return ["create", "list", "archive", "unarchive"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps)); if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
if (verb === "session") return sessionCommand(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); if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
+3 -7
View File
@@ -4,7 +4,6 @@ import { basename } from 'node:path';
import { listOrgs, orgLock, resolveOrg } from '../../organization/src/org-registry.mjs'; import { listOrgs, orgLock, resolveOrg } from '../../organization/src/org-registry.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs'; import { organizationWriter } from '../../organization/src/organization.mjs';
import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, projectRepoSet, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs'; import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, projectRepoSet, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs';
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
import { installedStarterPacks } from '../../plugins/src/starter-pack.mjs'; import { installedStarterPacks } from '../../plugins/src/starter-pack.mjs';
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs'; import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
import { loadSystem, orgPaths, rolesDir } from './config.mjs'; import { loadSystem, orgPaths, rolesDir } from './config.mjs';
@@ -19,7 +18,7 @@ import { orgOwnerCommand, ORG_OWNER_USAGE, ORG_OWNER_VERBS } from './org-owner.m
export const PACKS_USAGE = 'mosaic org pack list | pack preview ID PACK [--project P] [--as KIND.NAME=NEW]... | pack apply ID PACK --digest D [--project P] [--as KIND.NAME=NEW]... | board set ID FILE [--project P] | workflow set ID NAME FILE [--project P] | workflow remove ID NAME [--project P]'; export const PACKS_USAGE = 'mosaic org pack list | pack preview ID PACK [--project P] [--as KIND.NAME=NEW]... | pack apply ID PACK --digest D [--project P] [--as KIND.NAME=NEW]... | board set ID FILE [--project P] | workflow set ID NAME FILE [--project P] | workflow remove ID NAME [--project P]';
// #1873 SP8: saving the organization, or one project, as a template; the owner's alone. // #1873 SP8: saving the organization, or one project, as a template; the owner's alone.
export const TEMPLATE_USAGE = 'mosaic org template save ID NAME [--project P] (--preview | --digest D) | template list ID'; export const TEMPLATE_USAGE = 'mosaic org template save ID NAME [--project P] (--preview | --digest D) | template list ID';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | project repo set ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | ' + PACKS_USAGE.slice('mosaic org '.length) + ' | ' + TEMPLATE_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]'; export const ORG_USAGE = 'mosaic org lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | project repo set ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | ' + PACKS_USAGE.slice('mosaic org '.length) + ' | ' + TEMPLATE_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk', '--no-bus'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; } function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk', '--no-bus'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
// A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916). // A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916).
const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable'; const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable';
@@ -92,8 +91,7 @@ async function packsCommand(args, io, deps) {
else if (word === 'workflow' && sub === 'remove' && pos.length === 4) { allowed('--project'); verb = 'workflow.set'; revised = true; request = { name, workflow: null, ...project }; } else if (word === 'workflow' && sub === 'remove' && pos.length === 4) { allowed('--project'); verb = 'workflow.set'; revised = true; request = { name, workflow: null, ...project }; }
else throw new CliError(PACKS_USAGE, 4); else throw new CliError(PACKS_USAGE, 4);
// pack list names no organization, so it reads what the bus's `packs` verb reads (the shipped and // pack list names no organization, so it reads what the bus's `packs` verb reads (the shipped and
// installed packs, never a saved template) from this instance's data root, as `system-types // installed packs, never a saved template) from this instance's data root.
// installed` does.
if (verb === 'packs') { if (verb === 'packs') {
const system = deps.system ?? loadSystem({ env: io.env, instance: devOwnerInstance(io.env) }); const system = deps.system ?? loadSystem({ env: io.env, instance: devOwnerInstance(io.env) });
const { packs } = await installedStarterPacks({ dataRoot: system.dataRoot, rolesDir: rolesDir(io.env) }); const { packs } = await installedStarterPacks({ dataRoot: system.dataRoot, rolesDir: rolesDir(io.env) });
@@ -179,10 +177,8 @@ export async function orgCommand(args, io, deps = {}) {
let lifecycle = null; let lifecycle = null;
if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json', ...(words[0] === 'restore' ? ['--no-bus'] : [])], ['--if-revision']); lifecycle = { ifRevision: revision(p.values['--if-revision'], true) }; if (words[0] === 'archive' || !p.flags.has('--no-bus')) refuseAgentRun(io); } if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json', ...(words[0] === 'restore' ? ['--no-bus'] : [])], ['--if-revision']); lifecycle = { ifRevision: revision(p.values['--if-revision'], true) }; if (words[0] === 'archive' || !p.flags.has('--no-bus')) refuseAgentRun(io); }
else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const ifRevision = revision(p.values['--if-revision'], false); lifecycle = { confirm: need('--confirm'), ifRevision }; refuseAgentRun(io); } else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const ifRevision = revision(p.values['--if-revision'], false); lifecycle = { confirm: need('--confirm'), ifRevision }; refuseAgentRun(io); }
if (words[0] === 'system-types' && words[1] === 'installed' && words.length === 2) { allowed(['--json'], []); io.stdout.write(JSON.stringify(await installedSystemTypes(), null, 2) + '\n'); return; }
const instance = devOwnerInstance(io.env); const system = deps.system ?? loadSystem({ env: io.env, instance }); const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER }; const instance = devOwnerInstance(io.env); const system = deps.system ?? loadSystem({ env: io.env, instance }); const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
if (words[0] === 'system-types' && words[1] === 'enabled' && words.length === 3) { allowed(['--json'], []); const org = await showOrg(ctx, words[2]); result = { organization: words[2], enabled: org.doc.plugins?.['system-type'] ?? [] }; } 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] === '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] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); }
else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); } else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); }
else if (words[0] === 'archive' && words.length === 2) result = await archiveOrg(ctx, words[1], { ...lifecycle, busStop: busStopForDelete, copyRemote: await (deps.copyRemoteFor ?? copyRemoteFor)(ctx) }); else if (words[0] === 'archive' && words.length === 2) result = await archiveOrg(ctx, words[1], { ...lifecycle, busStop: busStopForDelete, copyRemote: await (deps.copyRemoteFor ?? copyRemoteFor)(ctx) });
+2 -15
View File
@@ -14,8 +14,6 @@ export const PROJECT_USAGE = 'mosaic project init <business> <project> [--tester
// and check above work on the repository on disk and never contact the bus, except that // 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. // init --tester (row 175, #1673) binds the tester once before it writes.
export const PROJECT_BUS_USAGE = [ export const PROJECT_BUS_USAGE = [
'mosaic project system-types <name-or-id> [--business <id>] [--json]',
'mosaic project system-types-set <name-or-id> --types <comma-separated-ids> [--business <id>]',
'mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]', 'mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]',
'mosaic project list [--archived] [--business <id>] [--json] [--tester]', 'mosaic project list [--archived] [--business <id>] [--json] [--tester]',
'mosaic project archive <name-or-id> [--business <id>]', 'mosaic project archive <name-or-id> [--business <id>]',
@@ -30,8 +28,8 @@ export async function projectBusCommand(args, io, callFor) {
? { flags: ['--tester'], values: ['--repo', '--business'] } ? { flags: ['--tester'], values: ['--repo', '--business'] }
: sub === 'list' : sub === 'list'
? { flags: ['--json', '--tester', '--archived'], values: ['--business'] } ? { flags: ['--json', '--tester', '--archived'], values: ['--business'] }
: { flags: sub === 'system-types' ? ['--json'] : [], values: sub === 'system-types-set' ? ['--business', '--types'] : ['--business'] }); : { flags: [], values: ['--business'] });
const one = ['create', 'archive', 'unarchive', 'system-types', 'system-types-set'].includes(sub); const one = ['create', 'archive', 'unarchive'].includes(sub);
if (parsed.positional.length !== (one ? 1 : 0)) 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); 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); const call = callFor(parsed);
@@ -47,17 +45,6 @@ export async function projectBusCommand(args, io, callFor) {
} }
return io.stdout.write(`created project ${name} id ${made.id}\n`); 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, // #797 MVP3: the owner's reversible archive. Resolution reads the full list, archived included,
// so an archived project can be named for unarchive. // so an archived project can be named for unarchive.
if (sub === 'archive' || sub === 'unarchive') { if (sub === 'archive' || sub === 'unarchive') {
-1
View File
@@ -12,7 +12,6 @@ test('organization CLI renders, validates, creates, updates and initializes git'
const file = join(home, 'alpha.json'); const invoke = (...args) => { const r = run(env, ...args); assert.equal(r.status, 0, r.stderr); return r.stdout; }; 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', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file);
invoke('org', 'validate', file); invoke('org', 'create', 'alpha', '--from', 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); 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); 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)); invoke('org', 'update', 'alpha', '--from', file, '--if-revision', String(info.revision));
+4 -4
View File
@@ -306,7 +306,7 @@ test('#811 migration readers follow the engine order and refuse malformed pairs'
assert.notEqual(engineSource,source); assert.notEqual(engineSource,source);
const engine=await import(`data:text/javascript;base64,${Buffer.from(engineSource).toString('base64')}`); const engine=await import(`data:text/javascript;base64,${Buffer.from(engineSource).toString('base64')}`);
const pairs=engine.migrations.map(({from,to})=>[from,to]); const pairs=engine.migrations.map(({from,to})=>[from,to]);
assert.equal(pairs.length,20);assert.deepEqual(pairs.at(-1),['3u','3v']); assert.equal(pairs.length,21);assert.deepEqual(pairs.at(-1),['3v','3w']);
const release=readFileSync(join(ROOT,'scripts/stack-release.sh'),'utf8'); const 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 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'); 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]; 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 // sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
// well as T46. // well as T46.
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','project_archives','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_system_types']; const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','project_archives','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events'];
const LATE='2999-01-01T00:00:00.000Z'; const 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()}; 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('3f.sqlite',['events'],['events','instance_names']),'1\t1 event(s)\n');
both(store('none.sqlite',[],['instance_names']),'0\t0 event(s)\n'); both(store('none.sqlite',[],['instance_names']),'0\t0 event(s)\n');
}); });
for(const x of ['instance_names','usage_records','project_system_types'])await t.test(`one ${x} row and no event refuses before any stop; --events 1 restores`,async(t)=>{ for(const x of ['instance_names','usage_records','request_project_events'])await t.test(`one ${x} row and no event refuses before any stop; --events 1 restores`,async(t)=>{
const e=await mk(t);await seed(e); const e=await mk(t);await seed(e);
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation; assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
addRow(e.store,x); addRow(e.store,x);
@@ -925,7 +925,7 @@ test('T45 row 181 items 2 and 3: the restore guard counts events and every COUNT
assert.equal(r.code,3,r.out+r.err);assert.ok(r.err.endsWith(`stack-release: refusing restore-mismatch: cannot count the rows after ${stamp}\n`),r.err); assert.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'); assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).at(-1).code,'restore-mismatch');
}); });
for(const x of ['thread_opens','project_system_types'])await t.test(`the in-run recount counts the listed tables too (${x})`,async(t)=>{ for(const x of ['thread_opens','project_archives'])await t.test(`the in-run recount counts the listed tables too (${x})`,async(t)=>{
const e=await mk(t);await seed(e); const e=await mk(t);await seed(e);
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation; assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
fault(e,'stop-writes-row',x); fault(e,'stop-writes-row',x);
-53
View File
@@ -1,53 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { orgCommand } from '../src/org.mjs';
import { projectBusCommand } from '../src/project.mjs';
test('org installed system types and project selection reads print JSON', async () => {
let output = '';
const io = { stdout: { write: s => { output += s; } }, env: {} };
await orgCommand(['system-types', 'installed'], io);
assert.deepEqual(JSON.parse(output).map(x => x.id), ['business-management', 'coding', 'creative-writing', 'marketing', 'montage']);
output = '';
const calls = [];
await projectBusCommand(['system-types', 'work', '--business', 'demo', '--json'], io, () => async (verb, args) => {
calls.push([verb, args]);
return verb === 'project.list' ? [{ id: 'project-id', name: 'work' }] : { project: 'project-id', selected: ['coding'], active: ['coding'], inactive: [] };
});
assert.deepEqual(calls.map(x => x[0]), ['project.list', 'project.system-types']);
assert.deepEqual(JSON.parse(output).active, ['coding']);
});
// sys1-review P2: both commands go through run() and the real CLI channel module, so a verb the
// channel does not admit fails here. A disabled channel and an agent run still refuse.
test('project system-types and system-types-set pass the CLI channel', async t => {
const { run } = await import('../src/cli.mjs');
const { fixture, humanEnv, io: mkio, tmp } = await import('./helpers.mjs');
const root = tmp(t), f = fixture(root);
const P = '10000000-0000-4000-8000-000000000001';
const transportFor = channels => {
const calls = [];
const transport = () => async (verb, args) => {
if (verb === 'plugins') return { business: 'acme', revision: 'a'.repeat(64), plugins: { channel: channels } };
calls.push([verb, args]);
if (verb === 'project.list') return [{ id: P, at: '2026-11-09T00:00:00.000Z', name: 'work', repo: null }];
if (verb === 'project.system-types') return { project: P, selected: ['coding'], active: ['coding'], inactive: [] };
if (verb === 'project.system-types.set') return { project: P, selected: args.systemTypes, active: args.systemTypes, inactive: [] };
throw Object.assign(new Error(verb), { code: 'bus-refused' });
};
return { calls, transport };
};
const read = transportFor(['cli']), rx = mkio(humanEnv(f.env));
assert.equal(await run(['project', 'system-types', 'work', '--business', 'acme'], rx, { transport: read.transport }), 0, rx.err?.text);
assert.deepEqual(read.calls, [['project.list', { includeArchived: true }], ['project.system-types', { project: P }]]);
assert.deepEqual(JSON.parse(rx.out.text).active, ['coding']);
const write = transportFor(['cli']), wx = mkio(humanEnv(f.env));
assert.equal(await run(['project', 'system-types-set', 'work', '--types', 'coding,marketing', '--business', 'acme'], wx, { transport: write.transport }), 0, wx.err?.text);
assert.deepEqual(write.calls.at(-1), ['project.system-types.set', { project: P, systemTypes: ['coding', 'marketing'] }]);
const off = transportFor(['webui']), ox = mkio(humanEnv(f.env));
assert.equal(await run(['project', 'system-types', 'work', '--business', 'acme'], ox, { transport: off.transport }), 3);
assert.deepEqual(off.calls, []);
const agent = transportFor(['cli']);
assert.notEqual(await run(['project', 'system-types-set', 'work', '--types', 'coding', '--business', 'acme'], mkio({ ...humanEnv(f.env), CODEX_THREAD_ID: 'fixture-agent' }), { transport: agent.transport }), 0);
assert.deepEqual(agent.calls, []);
});
-5
View File
@@ -615,11 +615,6 @@ selection on this release's throwaway development instances. Receipt subjects
name that principal, not Jason. The dev-purpose bootstrap fence is a separate name that principal, not Jason. The dev-purpose bootstrap fence is a separate
follow-up before person-used deployment. follow-up before person-used deployment.
The optional `plugins.system-type` list enables installed system-type modules for
an organization. Missing means none. Unknown ids refuse validation. Changes
are owner-only; project selections are separate bus settings and a selection
whose type is disabled remains recorded as inactive.
## Instance accounts (Phase M2) ## Instance accounts (Phase M2)
`src/accounts.mjs` validates `<configDir>/accounts.json` without a dependency or `src/accounts.mjs` validates `<configDir>/accounts.json` without a dependency or
-1
View File
@@ -87,7 +87,6 @@ export const VERB_RIGHTS = record({
'request.draft.console': 'request.write', 'request.draft.console': 'request.write',
'prd.revoke.console': 'request.write', 'prd.revoke.console': 'request.write',
'plugins.set': 'org.setup', 'plugins.set': 'org.setup',
'system-types.console': 'org.setup',
'project.remote.get.console': 'org.setup', 'project.remote.get.console': 'org.setup',
'project.remote.set.console': 'org.setup', 'project.remote.set.console': 'org.setup',
// #1873 SP4: starter packs, the board and workflows. The list is a board read; every write is // #1873 SP4: starter packs, the board and workflows. The list is a board read; every write is
@@ -14,8 +14,6 @@ import { loadRole } from "./role.mjs";
import { checkDefinition, checkDefinitions, checkDefinitionName, orgDefinitionName, ORG_PREFIX } from "./definitions.mjs"; import { checkDefinition, checkDefinitions, checkDefinitionName, orgDefinitionName, ORG_PREFIX } from "./definitions.mjs";
import { END_PROTECTED, parseRepoRemote } from "./vocabulary.mjs"; import { END_PROTECTED, parseRepoRemote } from "./vocabulary.mjs";
import { checkPlugins, checkUsageSettings, NAME as KIND_NAME } from "../../plugins/src/kinds.mjs"; import { checkPlugins, checkUsageSettings, NAME as KIND_NAME } from "../../plugins/src/kinds.mjs";
import { hasModule } from "../../plugins/src/loader.mjs";
import { readPluginLock } from "../../plugins/src/pinned.mjs";
import { checkMapping } from "../../library/src/mapping.mjs"; import { checkMapping } from "../../library/src/mapping.mjs";
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs"; import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
import { checkAppearance } from "./appearance.mjs"; import { checkAppearance } from "./appearance.mjs";
@@ -505,14 +503,6 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
// here; whether each module loads is the loader's check, at load time. // here; whether each module loads is the loader's check, at load time.
const plugins = document.plugins === undefined ? {} const plugins = document.plugins === undefined ? {}
: structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) })); : structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
let installedNames = new Set();
if (dataRoot !== null && plugins["system-type"]?.some(id=>!hasModule("system-type",id))) {
try { installedNames = new Set(Object.keys(readPluginLock(dataRoot).plugins)); }
catch { installedNames = null; } // the loader reports an unreadable lock; core still loads
}
for (const id of plugins["system-type"] ?? []) {
if (!hasModule("system-type", id) && installedNames !== null && !installedNames.has(id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
}
const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage); const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage);
const autonomy = checkAutonomy(document.autonomy); const autonomy = checkAutonomy(document.autonomy);
if(autonomy?.authorityResolver) { if(autonomy?.authorityResolver) {
@@ -126,13 +126,6 @@ function autonomyFence(old, document, actor) {
if(actor!==old.human || actor==='tester' || agents.includes(actor))fail('human-required'); if(actor!==old.human || actor==='tester' || agents.includes(actor))fail('human-required');
} }
function systemTypesFence(old, document, actor) {
if (canonicalJson(old.plugins?.['system-type'] ?? []) === canonicalJson(document?.plugins?.['system-type'] ?? [])) return;
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
if (actor === old.human && actor !== 'tester' && !agents.includes(actor)) return;
fail('organization-system-types-human-only');
}
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is // 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 // 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. // name. Compared as M9 compares projects, on the document and again on what lands on disk.
@@ -320,7 +313,6 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
replyLimitsFence(old, document, actor); replyLimitsFence(old, document, actor);
modelQuotaFence(old, document, actor); modelQuotaFence(old, document, actor);
usageFence(old, document, actor); usageFence(old, document, actor);
systemTypesFence(old, document, actor);
autonomyFence(old, document, actor); autonomyFence(old, document, actor);
validateBusinessDocument(document, file, { rolesDir, dataRoot }); validateBusinessDocument(document, file, { rolesDir, dataRoot });
const bytes = JSON.stringify(document, null, 2) + "\n"; const bytes = JSON.stringify(document, null, 2) + "\n";
@@ -332,7 +324,6 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
replyLimitsFence(old, written, actor); replyLimitsFence(old, written, actor);
modelQuotaFence(old, written, actor); modelQuotaFence(old, written, actor);
usageFence(old, written, actor); usageFence(old, written, actor);
systemTypesFence(old, written, actor);
autonomyFence(old, written, actor); autonomyFence(old, written, actor);
validateBusinessDocument(written, file, { rolesDir, dataRoot }); validateBusinessDocument(written, file, { rolesDir, dataRoot });
if (beforeWrite) { if (beforeWrite) {
@@ -79,7 +79,6 @@ const VERBS = {
'request.draft.console': 'request.write', 'request.draft.console': 'request.write',
'prd.revoke.console': 'request.write', 'prd.revoke.console': 'request.write',
'plugins.set': 'org.setup', 'plugins.set': 'org.setup',
'system-types.console': 'org.setup',
'project.remote.get.console': 'org.setup', 'project.remote.get.console': 'org.setup',
'project.remote.set.console': 'org.setup', 'project.remote.set.console': 'org.setup',
// #1873 SP4: starter packs, the board and workflows. The list is a board read; every write is // #1873 SP4: starter packs, the board and workflows. The list is a board read; every write is
@@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { KINDS, checkPlugins } from '../../plugins/src/kinds.mjs';
import { validateBusinessDocument } from '../src/organization-config.mjs';
import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs';
// #1873 SP9: the system-type kind is removed outright (Jason, 2026-11-12). An org file that still
// names it refuses as an unknown kind, like any other name not in KINDS, even with an empty list.
test('SP9 an org file naming the removed system-type kind refuses unknown-kind', () => {
assert.equal(Object.hasOwn(KINDS, 'system-type'), false);
for (const list of [['coding'], []]) {
assert.throws(() => checkPlugins({ 'system-type': list }, 'acme.json plugins'),
(e) => e.code === 'unknown-kind' && /unknown kind "system-type"/.test(e.message));
const root = tmp(), doc = businessDoc(root), file = join(root, 'businesses/acme.json');
doc.plugins = { 'system-type': list };
assert.throws(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /unknown kind "system-type"/);
}
});
@@ -1,29 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { validateBusinessDocument } from '../src/organization-config.mjs';
import { businessDoc, REPO_ROLES, tmp } from './helpers.mjs';
test('organization enables only installed system types; absent means none', () => {
const root = tmp(), doc = businessDoc(root), file = join(root, 'businesses/acme.json');
const validate = () => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES });
assert.deepEqual(validate().plugins['system-type'] ?? [], []);
doc.plugins = { 'system-type': ['coding', 'montage'] };
assert.deepEqual(validate().plugins['system-type'], ['coding', 'montage']);
doc.plugins['system-type'] = ['not-installed'];
assert.throws(validate, /system-type.*not-installed/);
});
test('system type changes through the organization writer are owner-only', async t => {
const { rolesCopy, writeJson } = await import('./helpers.mjs');
const { pluginSettings } = await import('../src/plugin-settings.mjs');
const { rmSync } = await import('node:fs');
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root), doc = businessDoc(root), file = join(root, 'businesses/acme.json');
writeJson(file, doc);
const settings = pluginSettings({ dir: root, rolesDir });
const revision = settings.read('acme').revision;
assert.throws(() => settings.set('acme', { revision, plugins: { 'system-type': ['coding'] } }, 'tester'), /human-only/);
assert.deepEqual(settings.read('acme').plugins, {});
assert.deepEqual(settings.set('acme', { revision, plugins: { 'system-type': ['coding'] } }, doc.human).plugins['system-type'], ['coding']);
});
-9
View File
@@ -499,15 +499,6 @@ record separation, bounded reads and per-key quota settings.
A harness declares `outputPolicy({ proxy })` as `adapter`, `non-newline-stdout`, or `stdout`. The launcher validates and serializes that declaration; old sessions retain their recorded activity behavior. A harness declares `outputPolicy({ proxy })` as `adapter`, `non-newline-stdout`, or `stdout`. The launcher validates and serializes that declaration; old sessions retain their recorded activity behavior.
### System types
`system-type` modules export `interfaceVersion = 1` and `systemType()`, returning
`{ id, label, description, toolsets }`. The id is the module directory name;
`toolsets` is a distinct list of toolset ids. The installed list is discovered
from module directories, and malformed modules refuse discovery. An organization
selects modules with `plugins.system-type`; an omitted list enables none. The
initial five modules declare no toolsets yet.
### Instance-installed packages (PL1, #1873) ### Instance-installed packages (PL1, #1873)
The instance owner may install trusted plugin code from the host shell: The instance owner may install trusted plugin code from the host shell:
-2
View File
@@ -6,8 +6,6 @@ const VERBS = new Set([
"project.create", "project.create",
"project.archive", "project.archive",
"project.unarchive", "project.unarchive",
"project.system-types",
"project.system-types.set",
"thread.list", "thread.list",
"thread.create", "thread.create",
"thread.messages", "thread.messages",
-4
View File
@@ -80,10 +80,6 @@ export const KINDS = Object.freeze({
"model-provider": Object.freeze({ "model-provider": Object.freeze({
kind: "model-provider", interfaceVersion: 1, exports: Object.freeze(["catalogue", "preflight", "route"]), switch: "list", kind: "model-provider", interfaceVersion: 1, exports: Object.freeze(["catalogue", "preflight", "route"]), switch: "list",
}), }),
// Project system types are installed modules, enabled per organization.
"system-type": Object.freeze({
kind: "system-type", interfaceVersion: 1, exports: Object.freeze(["systemType"]), switch: "list",
}),
// A starter pack (#1873 SP1): starterPack() takes nothing and returns a pack document, which // A starter pack (#1873 SP1): starterPack() takes nothing and returns a pack document, which
// starter-pack.mjs checks with the origin of where it was read. A pack is copied into an // starter-pack.mjs checks with the origin of where it was read. A pack is copied into an
// organization, never switched on, so its switch is "none": `plugins` can never name one. // organization, never switched on, so its switch is "none": `plugins` can never name one.
-19
View File
@@ -1,19 +0,0 @@
import { loadInstalled } from './loader.mjs';
import { NAME } from './kinds.mjs';
// Discovery is fail-closed: malformed or unloadable installed modules cannot be advertised.
export async function installedSystemTypes({dataRoot = null} = {}) {
const { modules, refused } = await loadInstalled('system-type',{dataRoot});
if (refused.length) throw Object.assign(new Error('system type module refused'), { code: 'system-type-invalid' });
return modules.map(({ name, module }) => {
const value = module.systemType();
if (!value || Object.keys(value).sort().join(',') !== 'description,id,label,toolsets' || value.id !== name ||
typeof value.label !== 'string' || !value.label.trim() || /[\r\n]/.test(value.label) ||
typeof value.description !== 'string' || !value.description.trim() || /[\r\n]/.test(value.description) ||
!Array.isArray(value.toolsets) || value.toolsets.some(t => typeof t !== 'string' || !NAME.test(t)) ||
new Set(value.toolsets).size !== value.toolsets.length) {
throw Object.assign(new Error(`invalid system type ${name}`), { code: 'system-type-invalid' });
}
return structuredClone(value);
});
}
@@ -1,5 +0,0 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "business-management", label: "Business management", description: "Coordinate and manage business operations.", toolsets: [] };
}
@@ -1,5 +0,0 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "coding", label: "Coding", description: "Build and maintain software.", toolsets: [] };
}
@@ -1,5 +0,0 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "creative-writing", label: "Creative writing", description: "Develop and write creative works.", toolsets: [] };
}
@@ -1,5 +0,0 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "marketing", label: "Marketing", description: "Plan and produce marketing work.", toolsets: [] };
}
@@ -1,5 +0,0 @@
export const interfaceVersion = 1;
export function systemType() {
return { id: "montage", label: "Mosaic Montage", description: "Create and manage media productions.", toolsets: [] };
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { KINDS, NAME, PluginError, checkPlugins } from "../src/kinds.mjs";
const code = expected => e => e instanceof PluginError && e.code === expected; 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", () => { test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1, limit-check at 2 (row 125), each a list", () => {
assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "system-type", "starter-pack", "theme", "task-tracker"]); assert.deepEqual(Object.keys(KINDS), ["usage-source", "limit-check", "search-source", "channel", "harness", "usage", "model-provider", "starter-pack", "theme", "task-tracker"]);
assert.deepEqual({ ...KINDS["task-tracker"], exports: [...KINDS["task-tracker"].exports] }, { kind: "task-tracker", interfaceVersion: 1, exports: ["adapter"], switch: "one" }); assert.deepEqual({ ...KINDS["task-tracker"], exports: [...KINDS["task-tracker"].exports] }, { kind: "task-tracker", interfaceVersion: 1, exports: ["adapter"], switch: "one" });
assert.ok(Object.isFrozen(KINDS["task-tracker"]) && Object.isFrozen(KINDS["task-tracker"].exports)); assert.ok(Object.isFrozen(KINDS["task-tracker"]) && Object.isFrozen(KINDS["task-tracker"].exports));
// Stream S4 (PLAN C5): a theme is data. interfaceVersion 1, one export, a list switch. // Stream S4 (PLAN C5): a theme is data. interfaceVersion 1, one export, a list switch.
+5 -5
View File
@@ -12,7 +12,7 @@ import { validateBusinessDocument } from "../../organization/src/organization-co
const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] }; const GOOD = { "usage-source": ["meter"], "limit-check": ["cap"] };
// What loaded() reports for GOOD: search-source is a kind too, switched off here. // What loaded() reports for GOOD: search-source is a kind too, switched off here.
const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], "starter-pack": [], theme: [], "task-tracker": [] }; const GOOD_LOADED = { ...GOOD, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "starter-pack": [], theme: [], "task-tracker": [] };
// A tree with one good module of each kind, already loaded. // A tree with one good module of each kind, already loaded.
async function loadedTree(t, options) { 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 loader = await s.loader();
const result = await loader.load(business.plugins); const result = await loader.load(business.plugins);
assert.deepEqual(result.refused, []); assert.deepEqual(result.refused, []);
assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], "starter-pack": [], theme: [], "task-tracker": [] }); assert.deepEqual(result.loaded, { ...doc.plugins, "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "starter-pack": [], theme: [], "task-tracker": [] });
assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage"); assert.equal(loader.modules("usage-source")[0].readTurn(), "fixture-usage");
assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit"); assert.equal(loader.modules("limit-check")[0].mayLaunch(), "fixture-limit");
assert.equal(loader.modules("limit-check")[0].mustStop(), false); assert.equal(loader.modules("limit-check")[0].mustStop(), false);
@@ -171,7 +171,7 @@ test("REQ-PLUG-1 a path that becomes unreadable after the lstat checks refuses a
assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/); assert.match(result.refused[0].message, /packages\/plugins can't be read \(EACCES\)/);
assert.deepEqual(result.refused[0].kept, ["meter"]); assert.deepEqual(result.refused[0].kept, ["meter"]);
assert.deepEqual(s.loader.logged.slice(before), result.refused); assert.deepEqual(s.loader.logged.slice(before), result.refused);
assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], "starter-pack": [], theme: [], "task-tracker": [] }); assert.deepEqual(s.loader.loaded(), { "usage-source": ["meter"], "limit-check": [], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "starter-pack": [], theme: [], "task-tracker": [] });
} finally { } finally {
if (lock) chmodSync(lock, 0o755); if (lock) chmodSync(lock, 0o755);
} }
@@ -233,7 +233,7 @@ test("REQ-PLUG-1 a kind refused on its first load has nothing switched on, and t
const loader = await s.loader(); const loader = await s.loader();
// meter loads, absent refuses: the whole kind stays off, not half on. // meter loads, absent refuses: the whole kind stays off, not half on.
const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] }); const result = await loader.load({ "usage-source": ["meter", "absent"], "limit-check": ["cap"] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], "starter-pack": [], theme: [], "task-tracker": [] }); assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "starter-pack": [], theme: [], "task-tracker": [] });
assert.deepEqual(loader.loaded(), result.loaded); assert.deepEqual(loader.loaded(), result.loaded);
assert.deepEqual(loader.modules("usage-source"), []); assert.deepEqual(loader.modules("usage-source"), []);
assert.equal(loader.logged.length, 1); assert.equal(loader.logged.length, 1);
@@ -261,7 +261,7 @@ test("REQ-PLUG-1 the setting's shape: unknown kinds refuse alone, a bad whole se
await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/); await refusesKeepingOld(s, ["meter", "meter"], "bad-setting", "meter", /twice/);
// An absent key switches the kind off. // An absent key switches the kind off.
result = await s.loader.load({ "limit-check": ["cap"] }); result = await s.loader.load({ "limit-check": ["cap"] });
assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "system-type": [], "starter-pack": [], theme: [], "task-tracker": [] }); assert.deepEqual(result.loaded, { "usage-source": [], "limit-check": ["cap"], "search-source": [], channel: [], harness: [], usage: [], "model-provider": [], "starter-pack": [], theme: [], "task-tracker": [] });
}); });
test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => { test("REQ-PLUG-1 loads run one at a time, so the last requested setting wins", async t => {
-16
View File
@@ -207,22 +207,6 @@ test('PL1 tool exceptions cannot impersonate trusted refusals or echo payload by
await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:'hello'}),e=>e.code==='plugin-tool-failed' && e.plugin==='acmeplug' && !e.message.includes(SECRET)); await assert.rejects(service.call('acme','writer','acmeplug.echo',{text:'hello'}),e=>e.code==='plugin-tool-failed' && e.plugin==='acmeplug' && !e.message.includes(SECRET));
}); });
test('PL1 A3 organization copy readers preserve the derived installed-plugin root',async t=>{
const {withCopies,loadWithCopies}=await import('../../organization/src/agent-copies.mjs');
const f=fixture(t);f.manifest.kinds=['system-type'];f.manifest.interfaceVersions={'system-type':1};
mkdirSync(join(f.src,'system-type'));writeFileSync(join(f.src,'system-type/index.mjs'),'export const interfaceVersion=1;export function systemType(){return {}};');
const hash=f.seal();f.install(f.src,{expectedHash:hash});
const doc=businessDoc(f.root),rolesDir=rolesCopy(f.root);doc.plugins={...doc.plugins,'system-type':['acmeplug']};writeJson(join(f.root,'businesses/acme.json'),doc);
const options={rolesDir,dataRoot:f.dataRoot,snapshot:null};
assert.deepEqual(withCopies(doc,join(f.root,'businesses/acme.json'),options).plugins['system-type'],['acmeplug']);
assert.deepEqual(loadWithCopies('acme',{...options,dir:f.root}).plugins['system-type'],['acmeplug']);
const installed=join(f.dataRoot,'plugins/acmeplug/system-type/index.mjs');chmodSync(installed,0o600);writeFileSync(installed,SECRET);
// Config checks names only; the loader owns bad-pin refusal and preserves core.
assert.deepEqual(withCopies(doc,join(f.root,'businesses/acme.json'),options).plugins['system-type'],['acmeplug']);
doc.plugins['system-type']=['defualt'];
assert.throws(()=>withCopies(doc,join(f.root,'businesses/acme.json'),options),/unknown module/);
});
test('PL1 replacement pins refuse across loader and host tools until a fresh process',async t=>{ test('PL1 replacement pins refuse across loader and host tools until a fresh process',async t=>{
for(const first of ['loader','tool']) { for(const first of ['loader','tool']) {
const f=fixture(t); const f=fixture(t);
@@ -1,19 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createLoader, loadInstalled } from '../src/loader.mjs';
test('installed system types describe the five modules and unknown names refuse', async () => {
const { modules, refused } = await loadInstalled('system-type');
assert.deepEqual(refused, []);
assert.deepEqual(modules.map(m => m.name), ['business-management', 'coding', 'creative-writing', 'marketing', 'montage']);
for (const { name, module } of modules) {
const info = module.systemType();
assert.equal(info.id, name);
assert.ok(info.label && info.description && Array.isArray(info.toolsets));
if (name === 'montage') assert.equal(info.label, 'Mosaic Montage');
}
const loader = createLoader({ log() {} });
const result = await loader.load({ 'system-type': ['unknown'] });
assert.equal(result.refused.length, 1);
assert.deepEqual(result.loaded['system-type'], []);
});
+2 -2
View File
@@ -70,8 +70,8 @@
{ "id": "REQ-LIMIT-4", "title": "Usage sources and limit checks are the first plug-in kinds; an unknown turn is logged and shown, never counted as zero" }, { "id": "REQ-LIMIT-4", "title": "Usage sources and limit checks are the first plug-in kinds; an unknown turn is logged and shown, never counted as zero" },
{ "id": "REQ-PLUG-1", "title": "Every part the core calls out to is a plug-in kind; modules load from packages/plugins, switched on in the org file" }, { "id": "REQ-PLUG-1", "title": "Every part the core calls out to is a plug-in kind; modules load from packages/plugins, switched on in the org file" },
{ "id": "REQ-PLUG-2", "title": "New call-outs go through a plug-in kind from the start; a hard-coded part converts when a row changes it" }, { "id": "REQ-PLUG-2", "title": "New call-outs go through a plug-in kind from the start; a hard-coded part converts when a row changes it" },
{ "id": "REQ-SYS-1", "title": "System types are a plug-in kind; organizations enable a list of modules" }, { "id": "REQ-SYS-1", "title": "Starter packs are a plug-in kind; an organization or project copies one in once and owns the copy" },
{ "id": "REQ-SYS-2", "title": "Projects use enabled system types and their agents receive the corresponding toolsets" }, { "id": "REQ-SYS-2", "title": "Applying a pack grants nothing; every proposed agent is hired through the hire decision" },
{ "id": "REQ-HARN-1", "title": "The meta-harness generates each session's launch bundle from role and variables" }, { "id": "REQ-HARN-1", "title": "The meta-harness generates each session's launch bundle from role and variables" },
{ "id": "REQ-HARN-2", "title": "Credential scope, verbs, the tool limit and a container enforce the rules" }, { "id": "REQ-HARN-2", "title": "Credential scope, verbs, the tool limit and a container enforce the rules" },
{ "id": "REQ-EVT-1", "title": "Every slice 1 step emits an event; measures come from events" }, { "id": "REQ-EVT-1", "title": "Every slice 1 step emits an event; measures come from events" },
-4
View File
@@ -6,7 +6,6 @@ import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serv
import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs'; import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
import { organizationConsole } from './organizations.mjs'; import { organizationConsole } from './organizations.mjs';
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs'; import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
import { SYSTEM_TYPE_VERBS } from './system-types-http.mjs';
import { packsCaller } from './packs-http.mjs'; import { packsCaller } from './packs-http.mjs';
import { templatesCaller } from './template-http.mjs'; import { templatesCaller } from './template-http.mjs';
import { autonomyCaller } from './autonomy-http.mjs'; import { autonomyCaller } from './autonomy-http.mjs';
@@ -192,9 +191,6 @@ try {
// The owner reads request status on the selected organization's bus, as on the root Console. Request // The owner reads request status on the selected organization's bus, as on the root Console. Request
// writes stay off: the README "Console actions" inventory marks them unavailable for the organizations release. // writes stay off: the README "Console actions" inventory marks them unavailable for the organizations release.
requestRead: session.user === undefined ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null, requestRead: session.user === undefined ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
// Phase P2 (#1873): the owner's System types writes, the organization's enable switch (plugins.set
// with its revision) and a project's selection, over the owner's human transport under --send only.
systemTypesWrite: session.user === undefined && sendOn ? agentCaller(owner, SYSTEM_TYPE_VERBS) : null,
// D1 (#1873): the owner's autonomy writes (save, review, confirm, reverse) on this organization's bus. // D1 (#1873): the owner's autonomy writes (save, review, confirm, reverse) on this organization's bus.
// These options exist only on a --dev-local-owner Console (`organizations` above). The fence passes // These options exist only on a --dev-local-owner Console (`organizations` above). The fence passes
// those four verbs and nothing else the console transport carries. // those four verbs and nothing else the console transport carries.
+1 -1
View File
@@ -1928,7 +1928,7 @@
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')||wsPage(path)) render(false); else if (window.MosaicOrg ? path === '/board' : path === '/' || path === '') paintHeld(heldErr); if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')||wsPage(path)) render(false); else if (window.MosaicOrg ? path === '/board' : path === '/' || path === '') paintHeld(heldErr);
}; };
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {}); configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {});
// E1 (P1-5): after Restart bus the organization dashboard draws its bus line and system types now, not at the next poll. // E1 (P1-5): after Restart bus the organization dashboard draws its bus line now, not at the next poll.
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } }); document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } });
// The cross-organization pages ask for a repaint after a filter, sort or selection change, // The cross-organization pages ask for a repaint after a filter, sort or selection change,
// and report the inbox count across organizations for the sidebar. // and report the inbox count across organizations for the sidebar.
+1 -4
View File
@@ -620,7 +620,7 @@
html += p && !c.refused && redesigned ? window.MosaicProjects.header(p, c.section, c.threads) : heading(p?.name ?? 'Project unavailable'); html += p && !c.refused && redesigned ? window.MosaicProjects.header(p, c.section, c.threads) : heading(p?.name ?? 'Project unavailable');
if (p && !c.refused) { if (p && !c.refused) {
if (p.archived && redesigned && !c.canSend) html += '<p class="project-panel" role="status">This project is archived. Its saved conversations and records stay readable.</p>'; if (p.archived && redesigned && !c.canSend) html += '<p class="project-panel" role="status">This project is archived. Its saved conversations and records stay readable.</p>';
if (c.settings) html += `<p><a href="#/projects/${esc(c.id)}">Project overview</a></p><h2>Project information</h2><p>Repository: ${txt(p.repo || 'Not linked')}</p><div data-chat-remote>Repository setting unavailable.</div><div data-chat-system-types>System types unavailable.</div>`; if (c.settings) html += `<p><a href="#/projects/${esc(c.id)}">Project overview</a></p><h2>Project information</h2><p>Repository: ${txt(p.repo || 'Not linked')}</p><div data-chat-remote>Repository setting unavailable.</div>`;
else if (c.section === 'conversations' && redesigned) html += `<div class="project-section-title"><h2>Conversations</h2></div><div data-project-conversations>${c.threads.filter(t=>!t.shared && t.project===p.id).map(t=>`<a class="project-conversation-row" href="${threadPath(esc(t.id))}" data-conversation-id="${esc(t.id)}"><span class="project-conversation-icon" aria-hidden="true">◌</span><span><strong>${txt(t.name)}</strong><small>With ${txt(t.instance)}</small></span><span data-conversation-status></span><span aria-hidden="true">›</span></a>`).join('') || '<p>No conversations yet. Start one to begin work.</p>'}</div>${p.archived ? '' : `<details class="project-new-conversation"${location.hash.includes('new=1')?' open':''}><summary class="btn primary">New conversation</summary>${creation('thread', c.id)}</details>`}<h2>Shared with me</h2>${c.threads.some(t=>t.shared && t.project===p.id)?threads(c.threads.filter(t=>t.shared && t.project===p.id)):'<p>Nothing is shared with you yet.</p>'}`; else if (c.section === 'conversations' && redesigned) html += `<div class="project-section-title"><h2>Conversations</h2></div><div data-project-conversations>${c.threads.filter(t=>!t.shared && t.project===p.id).map(t=>`<a class="project-conversation-row" href="${threadPath(esc(t.id))}" data-conversation-id="${esc(t.id)}"><span class="project-conversation-icon" aria-hidden="true">◌</span><span><strong>${txt(t.name)}</strong><small>With ${txt(t.instance)}</small></span><span data-conversation-status></span><span aria-hidden="true">›</span></a>`).join('') || '<p>No conversations yet. Start one to begin work.</p>'}</div>${p.archived ? '' : `<details class="project-new-conversation"${location.hash.includes('new=1')?' open':''}><summary class="btn primary">New conversation</summary>${creation('thread', c.id)}</details>`}<h2>Shared with me</h2>${c.threads.some(t=>t.shared && t.project===p.id)?threads(c.threads.filter(t=>t.shared && t.project===p.id)):'<p>Nothing is shared with you yet.</p>'}`;
else if (c.section === 'conversations') html += '<h2>Conversations</h2>' + threads(c.threads.filter(t => !t.shared)) + (p.archived ? '' : creation('thread', c.id)); else if (c.section === 'conversations') html += '<h2>Conversations</h2>' + threads(c.threads.filter(t => !t.shared)) + (p.archived ? '' : creation('thread', c.id));
else if (redesigned) html += '<div data-project-content>Reading…</div>'; else if (redesigned) html += '<div data-project-content>Reading…</div>';
@@ -687,9 +687,6 @@
// which names the project as the organization file does: the chat project's repo key. // which names the project as the organization file does: the chat project's repo key.
if (remote && roomOwner() && window.MosaicOrg && typeof window.MosaicProjectRepo?.mount === 'function') window.MosaicProjectRepo.mount({ container: remote, project: project?.repo ?? null, archived: !!c.projectArchived }); if (remote && roomOwner() && window.MosaicOrg && typeof window.MosaicProjectRepo?.mount === 'function') window.MosaicProjectRepo.mount({ container: remote, project: project?.repo ?? null, archived: !!c.projectArchived });
else if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); } else if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); }
// Phase P2 (#1873): the project's System types selection, the owner's, mounted like the remote.
const types = c.container.querySelector('[data-chat-system-types]');
if (types && roomOwner() && typeof window.MosaicSystemTypes?.mount === 'function') window.MosaicSystemTypes.mount({ container: types, projectId: c.id, archived: !!c.projectArchived });
if (!window.MosaicOrg && (!redesigned || c.kind !== 'project' || c.settings || c.projectArchived)) window.MosaicProjectArchive?.mount(c.container, c); if (!window.MosaicOrg && (!redesigned || c.kind !== 'project' || c.settings || c.projectArchived)) window.MosaicProjectArchive?.mount(c.container, c);
if (redesigned && c.kind === 'project' && c.projectArchived && !c.settings) { if (redesigned && c.kind === 'project' && c.projectArchived && !c.settings) {
const controls = c.container.querySelector('.project-archive-control'); const controls = c.container.querySelector('.project-archive-control');
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script> <link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script> <link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/panes.css"> <link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/panes.css">
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/system-types.css"><script src="/system-types.js" defer></script><link rel="stylesheet" href="/autonomy.css"><script src="/autonomy.js" defer></script><link rel="stylesheet" href="/packs.css"><script src="/packs.js" defer></script><script src="/workflows.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/panes.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head> <script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/autonomy.css"><script src="/autonomy.js" defer></script><link rel="stylesheet" href="/packs.css"><script src="/packs.js" defer></script><script src="/workflows.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/panes.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<body> <body>
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div> <div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
<a class="skip" href="#main">Skip to content</a> <a class="skip" href="#main">Skip to content</a>
+3 -3
View File
@@ -487,8 +487,8 @@ html[data-organizations] .ws-meta code{color:var(--text)}
html[data-organizations] .ws-meta .ws-dot{color:var(--muted)} html[data-organizations] .ws-meta .ws-dot{color:var(--muted)}
html[data-organizations] .ws-meta .ws-dot.s-working b{color:var(--success)} html[data-organizations] .ws-meta .ws-dot.s-working b{color:var(--success)}
html[data-organizations] .ws-meta .ws-dot.s-offline b,html[data-organizations] .ws-meta .ws-dot.s-error b{color:var(--danger)} html[data-organizations] .ws-meta .ws-dot.s-offline b,html[data-organizations] .ws-meta .ws-dot.s-error b{color:var(--danger)}
html[data-organizations] .ws-typechips,html[data-organizations] .ws-docrow{margin-top:8px} html[data-organizations] .ws-docrow{margin-top:8px}
html[data-organizations] .ws-typechips .ws-label,html[data-organizations] .ws-docrow .ws-label{min-width:104px} html[data-organizations] .ws-docrow .ws-label{min-width:104px}
html[data-organizations] .ws-doc{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:2px 8px;font-size:12.5px;font-weight:600;color:var(--strong);text-decoration:none} html[data-organizations] .ws-doc{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:2px 8px;font-size:12.5px;font-weight:600;color:var(--strong);text-decoration:none}
html[data-organizations] .ws-doc:hover{background:var(--raised)} html[data-organizations] .ws-doc:hover{background:var(--raised)}
html[data-organizations] .ws-doc svg.i{width:14px;height:14px;color:var(--muted)} html[data-organizations] .ws-doc svg.i{width:14px;height:14px;color:var(--muted)}
@@ -697,7 +697,7 @@ html[data-organizations] #copy-status:empty{display:none}
html[data-organizations] .ws-busctl,html[data-organizations] .ws-busctl .bus-restart{align-items:flex-start} html[data-organizations] .ws-busctl,html[data-organizations] .ws-busctl .bus-restart{align-items:flex-start}
html[data-organizations] .ws-busctl [data-bus-restart-receipt]{text-align:left} html[data-organizations] .ws-busctl [data-bus-restart-receipt]{text-align:left}
html[data-organizations] .ws-busctl .bus-buttons{justify-content:flex-start} html[data-organizations] .ws-busctl .bus-buttons{justify-content:flex-start}
html[data-organizations] .ws-typechips .ws-label,html[data-organizations] .ws-docrow .ws-label{min-width:0} html[data-organizations] .ws-docrow .ws-label{min-width:0}
html[data-organizations] .ws-levels{grid-template-columns:minmax(0,1fr)!important} html[data-organizations] .ws-levels{grid-template-columns:minmax(0,1fr)!important}
html[data-organizations] .ws-levels>div{border-right:0} html[data-organizations] .ws-levels>div{border-right:0}
html[data-organizations] .ws-levels>.hide-m,html[data-organizations] .ws .hide-m{display:none} html[data-organizations] .ws-levels>.hide-m,html[data-organizations] .ws .hide-m{display:none}
@@ -1,33 +0,0 @@
/* Phase P2 (#1873): System types on the organization dashboard (.ws-types, the chips and the
Settings row's Open) and on a project's information page (.project-types). */
html[data-organizations] .ws-chip{display:inline-flex;align-items:center;font-size:12px;font-weight:500;padding:1px 7px;border:1px solid var(--line);border-radius:4px;color:var(--text);white-space:nowrap}
html[data-organizations] .ws-cfg button.go{border:0;background:none;padding:0;cursor:pointer;font:inherit;font-size:12.5px;font-weight:600;color:var(--action)}
html[data-organizations] .ws-cfg button.go:hover{text-decoration:underline}
html[data-organizations] .ws-types-list{display:grid;list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--surface)}
html[data-organizations] .ws-types-list li{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:4px 12px;padding:8px 12px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent)}
html[data-organizations] .ws-types-list li:last-child{border-bottom:0}
html[data-organizations] .ws-types-list .t{display:flex;flex-direction:column;min-width:0}
html[data-organizations] .ws-types-list .t b{font-size:13px;font-weight:600;color:var(--strong)}
html[data-organizations] .ws-types-list .t .d{font-size:12px;color:var(--muted);overflow-wrap:anywhere}
html[data-organizations] .ws-types-list .ws-dot{font-size:12.5px}
html[data-organizations] .ws-types-list .ws-btn[aria-disabled="true"]{color:var(--muted);cursor:progress}
html[data-organizations] .ws-types>.note{margin:8px 0 0}
html[data-organizations] .ws-types-status:empty{display:none}
html[data-organizations] .ws-types-status code{overflow-wrap:anywhere}
html[data-organizations] .ws-types h2:focus{outline:none}
@media (max-width:759px){
html[data-organizations] .ws-types-list li{grid-template-columns:minmax(0,1fr) auto}
html[data-organizations] .ws-types-list .t{grid-column:1/-1}
}
.project-types{border:1px solid var(--border, var(--line, #aaa));border-radius:4px;padding:1rem;margin-top:1rem;overflow-wrap:anywhere}
.project-types fieldset{border:0;margin:0;padding:0;display:grid;gap:.5rem}
.project-types legend{font-weight:600;margin-bottom:.25rem}
.project-types-option{display:flex;align-items:flex-start;gap:.5rem;min-height:44px}
.project-types-option input{margin-top:.2rem;width:20px;height:20px;flex:none}
.project-types-option span{display:flex;flex-direction:column;min-width:0}
.project-types-option small{color:var(--muted, inherit)}
.project-types button{min-height:44px;margin-top:.75rem}
.project-types-tag{font-size:.8em;font-weight:600;padding:0 .4em;border:1px solid currentColor;border-radius:4px;color:var(--warning, inherit)}
.project-types [role="status"]:empty{display:none}
.project-types [role="status"]:focus{outline:none}
-195
View File
@@ -1,195 +0,0 @@
// Phase P2 (#1873): System types, the owner's control on the organization dashboard and on a project's
// information page. The modules, their labels and descriptions come from the system-type plug-ins
// through /api/system-types; nothing here lists a module. Writes go to the same route, which uses the
// organization writer (revision, owner fence, receipt) or project.system-types.set; the server and the
// bus recheck every write, so a control shown here grants nothing.
(() => {
'use strict';
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const enc = encodeURIComponent;
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
const orgPath = (org, rest) => (org ? `/api/orgs/${enc(org)}/${rest}` : `/api/${rest}`);
const LINES = {
'sign-in-required': 'Sign in again to see or change system types.',
'owner-only': "System types are the organization owner's.",
'read-only': 'This Console only reads. Changing system types needs signed-in send mode.',
'human-required': "Only the organization's owner can change system types.",
'organization-system-types-human-only': "Only the organization's owner can change system types.",
'revision-conflict': 'The organization file changed after this page read it. The list was read again; try again.',
'organization-busy': 'The organization file is being written. Try again in a moment.',
'system-type-not-enabled': 'A chosen type is not enabled in the organization any more. The list was read again.',
'system-type-unknown': 'A chosen type is not installed in this tree.',
'project-not-found': 'This project was not found.',
'not-configured': 'System types are not configured on this Console.',
'system-type-invalid': 'An installed system type module is invalid, so none can be read.',
'no-bus-host': 'The bus is not running.',
'invalid-request': 'The request was refused as invalid.',
};
const line = code => LINES[code] || 'The bus did not confirm the outcome. The list was read again; check it before you retry.';
// One request; a refusal keeps its code so the page can say why.
async function call(path, body) {
const init = body === undefined ? { cache: 'no-store', redirect: 'manual' }
: { method: 'POST', cache: 'no-store', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) };
let response;
try { response = await fetch(path, init); } catch { return { ok: false, code: 'outcome-unknown' }; }
if (response.type === 'opaqueredirect') return { ok: false, code: 'sign-in-required' };
const value = await response.json().catch(() => null);
if (!response.ok) return { ok: false, code: typeof value?.error === 'string' ? value.error : 'outcome-unknown' };
return value && typeof value === 'object' ? { ok: true, value } : { ok: false, code: 'outcome-unknown' };
}
const read = (org, project = null) => call(orgPath(org, project === null ? 'system-types' : `system-types/projects/${enc(project)}`));
const labelOf = (value, id) => value.installed.find(m => m.id === id)?.label || id;
const valid = value => value && Array.isArray(value.installed) && Array.isArray(value.enabled);
// ---------- organization dashboard ----------
// Per organization: the last read, the type a write is pending on, and the last outcome line.
const orgs = new Map();
const slot = org => { if (!orgs.has(org)) orgs.set(org, { value: null, code: null, pending: null, outcome: null }); return orgs.get(org); };
function remember(org, result) {
const s = slot(org);
if (result.ok && valid(result.value)) { s.value = result.value; s.code = null; } else { s.value = null; s.code = result.ok ? 'outcome-unknown' : result.code; }
return s;
}
// The chip row under the header: the enabled modules' labels.
function chips(org) {
const s = slot(org), v = s.value;
const body = !v ? `<span class="note">${esc(s.code === 'owner-only' ? line('owner-only') : 'System types unavailable')}</span>`
: !v.installed.length ? '<span class="note">No system type modules are installed.</span>'
: v.enabled.length ? `${v.enabled.map(id => `<span class="ws-chip">${esc(labelOf(v, id))}</span>`).join('')}<span class="note">Each project picks from these; none is required.</span>`
: '<span class="note">None enabled. Enable them under System types below; each project picks from the enabled ones.</span>';
return `<div class="ws-typechips ws-chips" data-system-type-chips><span class="ws-label">System types</span>${body}</div>`;
}
// The Settings row's value.
function summary(org) {
const v = slot(org).value;
return v ? `${v.enabled.filter(id => v.installed.some(m => m.id === id)).length} of ${v.installed.length} installed enabled` : 'Could not be read';
}
// A side-by-side pane (U5) passes { pane: true, idPrefix }: it shows the types without the switch,
// which stays in the organization's full view, and its heading id carries the pane's prefix.
function section(org, { pane = false, idPrefix = '' } = {}) {
const s = slot(org), v = s.value;
const head = `<div class="ws-sec-h"><h2 id="${esc(idPrefix)}ws-types-h" tabindex="-1" data-system-types-heading>System types</h2><span class="note">installed modules; each project picks from the enabled ones</span></div>`;
const status = `<p class="note ws-types-status" role="status" data-system-types-status>${s.outcome ? s.outcome : ''}</p>`;
if (!v) return `<div class="ws-types" data-system-types>${head}<p class="ws-panel tight note">${esc(s.code === 'owner-only' ? line('owner-only') : `System types unavailable. ${line(s.code)}`)}</p>${status}</div>`;
if (!v.installed.length) return `<div class="ws-types" data-system-types>${head}<p class="ws-panel tight note">No system type modules are installed.</p>${status}</div>`;
const rows = v.installed.map(m => {
const on = v.enabled.includes(m.id), busy = s.pending !== null;
const state = `<span class="ws-dot ${on ? 's-working' : 's-idle'}"><b aria-hidden="true">${on ? '●' : '–'}</b>${on ? 'enabled' : 'off'}</span>`;
const control = v.writable && !pane
? `<button type="button" class="ws-btn" data-system-type-toggle="${esc(m.id)}" data-on="${on}" data-focus-key="${esc(`st:${m.id}`)}" aria-label="${esc(`${on ? 'Disable' : 'Enable'} ${m.label}`)}"${busy ? ' aria-disabled="true"' : ''}>${s.pending === m.id ? 'Saving…' : on ? 'Disable' : 'Enable'}</button>`
: '';
return `<li data-system-type="${esc(m.id)}"><span class="t"><b>${esc(m.label)}</b><span class="d">${esc(m.description)}</span></span>${state}${control}</li>`;
}).join('');
const why = pane ? "<p class=\"note\">Change system types from this organization's full view.</p>"
: v.writable ? '<p class="note">Disabling a type keeps it in the projects that pick it; they show it as inactive until it is enabled again.</p>'
: `<p class="note">${esc(line('read-only'))}</p>`;
return `<div class="ws-types" data-system-types>${head}<ul class="ws-types-list">${rows}</ul>${why}${status}</div>`;
}
// The dashboard asks for this before it paints; the result stays for chips(), summary() and section().
const load = async org => remember(org, await read(org));
// After a write, the three parts are repainted in place; the 10 s dashboard repaint reads again anyway.
function repaint(org) {
if (window.MosaicOrg?.id !== org) return;
const view = [...document.querySelectorAll('[data-org-dashboard]')].find(v => !v.closest('[data-pane]')); if (!view) return;
const a = document.activeElement, key = view.contains(a) ? a.dataset?.focusKey || null : null;
const swap = (selector, html) => { const el = view.querySelector(selector); if (el) el.outerHTML = html; };
swap('[data-system-type-chips]', chips(org));
swap('[data-system-types]', section(org));
const value = view.querySelector('[data-org-setting="System types"] .v'); if (value) { value.textContent = summary(org); value.title = summary(org); }
if (key) view.querySelector(`[data-focus-key="${CSS.escape(key)}"]`)?.focus({ preventScroll: true });
}
async function toggle(button) {
const org = window.MosaicOrg?.id, s = org ? slot(org) : null, type = button.dataset.systemTypeToggle;
if (!s?.value?.writable || s.pending !== null || typeof s.value.revision !== 'string') return;
const on = s.value.enabled.includes(type);
const enabled = on ? s.value.enabled.filter(id => id !== type) : [...s.value.enabled, type];
s.pending = type; s.outcome = null; repaint(org);
const result = await call(orgPath(org, 'system-types'), { revision: s.value.revision, enabled });
s.pending = null;
const label = labelOf(s.value, type);
if (result.ok && typeof result.value.transaction === 'string') {
s.outcome = `${esc(label)} ${on ? 'disabled' : 'enabled'}. Receipt <code>${esc(result.value.transaction)}</code>.`;
} else s.outcome = esc(result.ok ? line('outcome-unknown') : line(result.code));
await load(org);
repaint(org);
}
document.addEventListener('click', e => {
const button = e.target.closest?.('[data-system-type-toggle]');
// A pending write marks the buttons aria-disabled, not disabled, so focus stays on the one pressed.
if (button) { e.preventDefault(); if (button.getAttribute('aria-disabled') !== 'true' && !button.closest('[data-pane]')) toggle(button); return; }
const open = e.target.closest?.('[data-system-types-open]');
if (open) { e.preventDefault(); const h = (open.closest('[data-org-dashboard]') || document).querySelector('[data-system-types-heading]'); h?.scrollIntoView({ block: 'start' }); h?.focus({ preventScroll: true }); }
});
// ---------- project information page ----------
let active = null;
document.addEventListener('mosaic-signed-out', () => { active?.dispose(); active = null; });
// The chat view mounts again on every repaint and keeps the section; leaving the page marks it stale,
// so coming back (say, after the dashboard changed the enabled set) reads again.
window.addEventListener('hashchange', () => { if (active) active.stale = true; });
function mount({ container, projectId, archived = false }) {
if (!(container instanceof Element)) return;
const org = window.MosaicOrg?.id ?? null;
if (active && active.org === org && active.projectId === projectId) {
container.replaceChildren(active.section);
if (active.archived !== archived) { active.archived = archived; active.paint(); }
if (active.stale) { active.stale = false; active.refresh(); }
return;
}
active?.dispose();
const section = document.createElement('section');
section.className = 'project-types';
section.setAttribute('data-project-system-types', '');
container.replaceChildren(section);
let value = null, code = null, outcome = '', saving = false, disposed = false, generation = 0;
const state = { org, projectId, section, archived, stale: false, paint, refresh: () => refresh(), dispose: () => { disposed = true; generation++; section.replaceChildren(); } };
active = state;
function paint() {
if (disposed) return;
const head = '<h2 tabindex="-1">System types</h2>';
const status = `<p role="status" tabindex="-1" data-project-system-types-status>${outcome}</p>`;
if (!ID.test(projectId ?? '')) { section.innerHTML = `${head}<p>System types unavailable.</p>`; return; }
if (!value) { section.innerHTML = `${head}<p>${esc(code ? `System types unavailable. ${line(code)}` : 'Reading system types…')}</p>${status}`; return; }
const sel = value.project, enabled = value.installed.filter(m => value.enabled.includes(m.id));
const inactive = sel.inactive.length
? `<div data-project-system-types-inactive><p>Inactive, because the organization has disabled them:</p><ul>${sel.inactive.map(id => `<li data-inactive-type="${esc(id)}">${esc(labelOf(value, id))} <span class="project-types-tag">inactive</span></li>`).join('')}</ul>${value.writable && !state.archived ? '<p class="muted">Saving keeps only enabled types, so it removes these from the project.</p>' : ''}</div>`
: '';
const intro = '<p>This project picks from the system types its organization has enabled. None is required.</p>';
const canWrite = value.writable && !state.archived;
if (!enabled.length) {
// With nothing enabled the only change left is clearing what the project still selects.
const clear = canWrite && sel.inactive.length
? `<form data-project-system-types-form><button type="submit" class="btn"${saving ? ' disabled' : ''}>${saving ? 'Saving…' : 'Clear the inactive types'}</button></form>` : '';
section.innerHTML = `${head}${intro}<p>The organization has no system types enabled. The owner enables them on the organization dashboard.</p>${inactive}${clear}${status}`;
return;
}
const boxes = enabled.map(m => `<label class="project-types-option"><input type="checkbox" name="system-type" value="${esc(m.id)}"${sel.active.includes(m.id) ? ' checked' : ''}${canWrite && !saving ? '' : ' disabled'}><span><strong>${esc(m.label)}</strong><small>${esc(m.description)}</small></span></label>`).join('');
const why = state.archived ? '<p class="muted">This project is archived; its selection is shown read only.</p>' : value.writable ? '' : `<p class="muted">${esc(line('read-only'))}</p>`;
section.innerHTML = `${head}${intro}<form data-project-system-types-form><fieldset><legend>Selected system types</legend>${boxes}</fieldset>${inactive}${canWrite ? `<button type="submit" class="btn"${saving ? ' disabled' : ''}>${saving ? 'Saving…' : 'Save system types'}</button>` : ''}</form>${why}${status}`;
}
async function refresh() {
const turn = ++generation;
const result = await read(org, projectId);
if (disposed || turn !== generation) return;
if (result.ok && valid(result.value) && result.value.project) { value = result.value; code = null; } else { value = null; code = result.ok ? 'outcome-unknown' : result.code; }
paint();
}
section.addEventListener('submit', async e => {
e.preventDefault();
if (saving || !value?.writable || state.archived) return;
const chosen = [...section.querySelectorAll('input[name="system-type"]:checked')].map(i => i.value);
saving = true; outcome = ''; paint();
const result = await call(orgPath(org, `system-types/projects/${enc(projectId)}`), { systemTypes: chosen });
if (disposed) return;
saving = false;
outcome = result.ok ? esc(result.value.selected.length ? `Saved. Selected: ${result.value.selected.map(id => labelOf(value, id)).join(', ')}.` : 'Saved. No system types selected.') : esc(line(result.code));
await refresh();
section.querySelector('[data-project-system-types-status]')?.focus({ preventScroll: true });
});
paint();
refresh();
}
window.MosaicSystemTypes = Object.freeze({ load, chips, summary, section, mount });
})();
+7 -14
View File
@@ -1,8 +1,7 @@
// The unified workspace screens in organization mode: the sidebar's Organizations and Projects // The unified workspace screens in organization mode: the sidebar's Organizations and Projects
// groups (sample 01), the organization dashboard (sample 03) and the Agents levels (sample 08). // groups (sample 01), the organization dashboard (sample 03) and the Agents levels (sample 08).
// Every read goes through MosaicWorkspaceData (workspace-data.js). Nothing here writes; the bus // Every read goes through MosaicWorkspaceData (workspace-data.js). Nothing here writes; the bus
// restart button is console-restart.js's own control, with its gating and trail unchanged, and the // restart button is console-restart.js's own control, with its gating and trail unchanged.
// System types chips, row and section are system-types.js's (Phase P2), with its own reads and writes.
(() => { (() => {
'use strict'; 'use strict';
if (!document.documentElement.hasAttribute('data-organizations')) return; if (!document.documentElement.hasAttribute('data-organizations')) return;
@@ -158,7 +157,7 @@
const secH = (title, note = '', link = '') => `<div class="ws-sec-h"><h2>${title}</h2>${note ? `<span class="note">${note}</span>` : ''}${link}</div>`; const secH = (title, note = '', link = '') => `<div class="ws-sec-h"><h2>${title}</h2>${note ? `<span class="note">${note}</span>` : ''}${link}</div>`;
function settingsList(org, ctx) { function settingsList(org, ctx) {
const { business, settings, instances, orgCount, types } = ctx; const { business, settings, instances, orgCount } = ctx;
const b = business?.business, s = settings; const b = business?.business, s = settings;
const unread = 'Could not be read'; const unread = 'Could not be read';
const names = instances ? Object.keys(instances) : null; const names = instances ? Object.keys(instances) : null;
@@ -170,7 +169,6 @@
const rows = [ const rows = [
['business', 'Identity', b ? `${label(org)} · ${org.id} · kind not recorded` : unread, 'org file', 'business'], ['business', 'Identity', b ? `${label(org)} · ${org.id} · kind not recorded` : unread, 'org file', 'business'],
['flag', 'Appearance', `${titleCase(theme)} theme · ${initials(org)} avatar`, b?.appearance?.theme || org.theme ? 'org file' : 'system', 'appearance'], ['flag', 'Appearance', `${titleCase(theme)} theme · ${initials(org)} avatar`, b?.appearance?.theme || org.theme ? 'org file' : 'system', 'appearance'],
['cube', 'System types', types ? types.summary(org.id) : 'Could not be read', 'org file', types ? '@system-types' : null],
['folder', 'Projects', b ? (Array.isArray(b.projects) && b.projects.length ? plural(b.projects.length, 'project') : 'No projects') : unread, 'org file', '#/projects'], ['folder', 'Projects', b ? (Array.isArray(b.projects) && b.projects.length ? plural(b.projects.length, 'project') : 'No projects') : unread, 'org file', '#/projects'],
['people', 'Agents and roles', names ? (names.length ? `${plural(names.length, 'agent')} · ${Object.values(instances).filter(running).length} running` : 'No agents') : unread, 'org file', 'agents'], ['people', 'Agents and roles', names ? (names.length ? `${plural(names.length, 'agent')} · ${Object.values(instances).filter(running).length} running` : 'No agents') : unread, 'org file', 'agents'],
['term', 'Models and routing', models ? (models.length ? models.join(' · ') : 'No models set') : unread, 'org file', 'routing'], ['term', 'Models and routing', models ? (models.length ? models.join(' · ') : 'No models set') : unread, 'org file', 'routing'],
@@ -189,10 +187,8 @@
['close', 'Archive or delete', "Archive or delete this organization's file", 'danger', 'organizations'], ['close', 'Archive or delete', "Archive or delete this organization's file", 'danger', 'organizations'],
]; ];
return `<ul class="ws-cfg one" data-org-settings>${rows.map(([ic, name, value, tag, page]) => { return `<ul class="ws-cfg one" data-org-settings>${rows.map(([ic, name, value, tag, page]) => {
const href = page === null || page.startsWith('@') ? null : page.startsWith('#') ? page : `#/settings/${page}`; const href = page === null ? null : page.startsWith('#') ? page : `#/settings/${page}`;
// '@system-types' is the System types section further down this page, not a route. const go = href ? `<a class="go" href="${esc(href)}" data-focus-key="${esc(`set:${name}`)}" aria-label="Open ${esc(name)}">Open</a>` : '<span class="go" aria-hidden="true"></span>';
const go = page === '@system-types' ? `<button type="button" class="go" data-system-types-open data-focus-key="${esc(`set:${name}`)}" aria-label="Open ${esc(name)}">Open</button>`
: href ? `<a class="go" href="${esc(href)}" data-focus-key="${esc(`set:${name}`)}" aria-label="Open ${esc(name)}">Open</a>` : '<span class="go" aria-hidden="true"></span>';
return `<li${tag === 'danger' ? ' class="danger"' : ''} data-org-setting="${esc(name)}">${icon(ic)}<span class="k">${esc(name)}</span><span class="v" title="${esc(value)}">${esc(value)}</span><span class="src-tag">${esc(tag)}</span>${go}</li>`; return `<li${tag === 'danger' ? ' class="danger"' : ''} data-org-setting="${esc(name)}">${icon(ic)}<span class="k">${esc(name)}</span><span class="v" title="${esc(value)}">${esc(value)}</span><span class="src-tag">${esc(tag)}</span>${go}</li>`;
}).join('')}</ul>`; }).join('')}</ul>`;
} }
@@ -206,8 +202,7 @@
const list = Array.isArray(listRead) ? listRead : null; const list = Array.isArray(listRead) ? listRead : null;
const org = list?.find(o => o.id === id && !o.invalid); const org = list?.find(o => o.id === id && !o.invalid);
if (!org) return `<article class="ws ws-dash" data-org-dashboard data-org-unavailable><div class="ws-vh"><h1 tabindex="-1">Organization unavailable</h1><p class="sub">${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}</p></div></article>`; if (!org) return `<article class="ws ws-dash" data-org-dashboard data-org-unavailable><div class="ws-vh"><h1 tabindex="-1">Organization unavailable</h1><p class="sub">${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}</p></div></article>`;
const types = window.MosaicSystemTypes || null; const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), window.MosaicAutonomy?.load(id), window.MosaicPacks?.load(id), window.MosaicWorkflows?.load(id)]);
const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), types ? types.load(id) : null, window.MosaicAutonomy?.load(id), window.MosaicPacks?.load(id), window.MosaicWorkflows?.load(id)]);
const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null); const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null);
const business = value(0), settings = value(1), instances = instancesOf(value(2)); const business = value(0), settings = value(1), instances = instancesOf(value(2));
const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5)); const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5));
@@ -228,7 +223,6 @@
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="${esc(why)}">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`; const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="${esc(why)}">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid; const projectName = pid => projectRows?.find(p => p.id === pid)?.name || pid;
const typechips = types ? types.chips(id) : '<div class="ws-typechips ws-chips"><span class="ws-label">System types</span><span class="note">System types unavailable</span></div>';
const docChips = !docRead ? '<span class="note">Docs unavailable</span>' : docRead.failed ? '<span class="note">Docs unavailable</span>' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `<a class="ws-doc" href="#/docs/${enc(d.project)}/${enc(d.path)}" data-focus-key="${esc(`doc:${d.project}:${d.path}`)}" title="${esc(`${d.project}/${d.path}`)}">${icon('doc')}${esc(docName(d.path))}<small>${esc(projectName(d.project))}</small></a>`).join('') : '<span class="note">No docs yet</span>'; const docChips = !docRead ? '<span class="note">Docs unavailable</span>' : docRead.failed ? '<span class="note">Docs unavailable</span>' : docRead.rows.length ? docRead.rows.slice(0, 6).map(d => `<a class="ws-doc" href="#/docs/${enc(d.project)}/${enc(d.path)}" data-focus-key="${esc(`doc:${d.project}:${d.path}`)}" title="${esc(`${d.project}/${d.path}`)}">${icon('doc')}${esc(docName(d.path))}<small>${esc(projectName(d.project))}</small></a>`).join('') : '<span class="note">No docs yet</span>';
const docrow = `<div class="ws-docrow ws-chips"><span class="ws-label">Docs</span>${docChips}<a class="ws-more" href="#/docs" data-focus-key="docs-all">All docs</a></div>`; const docrow = `<div class="ws-docrow ws-chips"><span class="ws-label">Docs</span>${docChips}<a class="ws-more" href="#/docs" data-focus-key="docs-all">All docs</a></div>`;
@@ -255,7 +249,7 @@
return `<article class="ws ws-dash" data-org-dashboard> return `<article class="ws ws-dash" data-org-dashboard>
<nav class="ws-crumb" aria-label="Breadcrumb"><a href="#/settings/organizations">Organizations</a><span aria-hidden="true">›</span><span>${name}</span></nav> <nav class="ws-crumb" aria-label="Breadcrumb"><a href="#/settings/organizations">Organizations</a><span aria-hidden="true">›</span><span>${name}</span></nav>
<div class="ws-orghead ws-o" style="${colours(org)}">${av(org, 'lg')}<h1 tabindex="-1">${name}</h1><div class="ws-right">${bus}${reload}</div>${meta}</div> <div class="ws-orghead ws-o" style="${colours(org)}">${av(org, 'lg')}<h1 tabindex="-1">${name}</h1><div class="ws-right">${bus}${reload}</div>${meta}</div>
${typechips}${docrow} ${docrow}
<div class="ws-split"> <div class="ws-split">
<section aria-label="Measures and sessions"> <section aria-label="Measures and sessions">
${tiles('KPIs', 'defined in the organization file', 'No KPIs defined in the organization file')} ${tiles('KPIs', 'defined in the organization file', 'No KPIs defined in the organization file')}
@@ -266,8 +260,7 @@
</section> </section>
<section aria-label="Settings"> <section aria-label="Settings">
${secH('Settings', 'every setting, with where its value comes from')} ${secH('Settings', 'every setting, with where its value comes from')}
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null, types })} ${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null })}
${types ? types.section(id, { pane, idPrefix: opts.idPrefix || '' }) : ''}
${window.MosaicAutonomy?.section(id)??''} ${window.MosaicAutonomy?.section(id)??''}
${window.MosaicPacks?.section(id) ?? ''} ${window.MosaicPacks?.section(id) ?? ''}
${window.MosaicWorkflows?.section(id) ?? ''} ${window.MosaicWorkflows?.section(id) ?? ''}
+9 -23
View File
@@ -21,7 +21,6 @@ import { themesHTTP } from './themes-http.mjs';
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs'; import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs'; import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs'; import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
import { SYSTEM_TYPES_ROUTE, systemTypesHTTP } from './system-types-http.mjs';
import { PACKS_ROUTE, packsHTTP, packsServes } from './packs-http.mjs'; import { PACKS_ROUTE, packsHTTP, packsServes } from './packs-http.mjs';
import { TEMPLATES_ROUTE, templatesHTTP } from './template-http.mjs'; import { TEMPLATES_ROUTE, templatesHTTP } from './template-http.mjs';
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs'; import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
@@ -82,14 +81,11 @@ const files = new Map([
['/editor.css', ['editor.css', 'text/css; charset=utf-8']], ['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
['/icons.svg', ['icons.svg', 'image/svg+xml']], ['/icons.svg', ['icons.svg', 'image/svg+xml']],
['/project-remote.css', ['project-remote.css', 'text/css; charset=utf-8']], ['/project-remote.css', ['project-remote.css', 'text/css; charset=utf-8']],
// Phase P2 (#1873): the owner's System types control (dashboard section and project selection).
['/autonomy.js', ['autonomy.js', 'text/javascript; charset=utf-8']], ['/autonomy.js', ['autonomy.js', 'text/javascript; charset=utf-8']],
['/autonomy.css', ['autonomy.css', 'text/css; charset=utf-8']], ['/autonomy.css', ['autonomy.css', 'text/css; charset=utf-8']],
['/packs.js', ['packs.js', 'text/javascript; charset=utf-8']], ['/packs.js', ['packs.js', 'text/javascript; charset=utf-8']],
['/workflows.js', ['workflows.js', 'text/javascript; charset=utf-8']], ['/workflows.js', ['workflows.js', 'text/javascript; charset=utf-8']],
['/packs.css', ['packs.css', 'text/css; charset=utf-8']], ['/packs.css', ['packs.css', 'text/css; charset=utf-8']],
['/system-types.js', ['system-types.js', 'text/javascript; charset=utf-8']],
['/system-types.css', ['system-types.css', 'text/css; charset=utf-8']],
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]), ...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]), ...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]),
]); ]);
@@ -533,8 +529,6 @@ const AGENT_ROUTES = {
// F3: the writes the owner makes on a selected organization's bus from Settings (dev org Console). // F3: the writes the owner makes on a selected organization's bus from Settings (dev org Console).
const ORG_OWNER_WRITE = path => AUTONOMY_ROUTE.test(path) || path === '/api/settings' || path === '/api/instances' || path === '/api/routes' const ORG_OWNER_WRITE = path => AUTONOMY_ROUTE.test(path) || path === '/api/settings' || path === '/api/instances' || path === '/api/routes'
|| Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/') || Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/')
// Phase P2 (#1873): the System types enable switch and a project's selection; the route keeps its own checks.
|| SYSTEM_TYPES_ROUTE.test(path)
// Starter packs SP4 (#1873): pack apply, board and workflow writes; the route keeps its own checks. // Starter packs SP4 (#1873): pack apply, board and workflow writes; the route keeps its own checks.
|| PACKS_ROUTE.test(path) || PACKS_ROUTE.test(path)
// Starter packs SP8 (#1873): saving a template; the route keeps its own checks. // Starter packs SP8 (#1873): saving a template; the route keeps its own checks.
@@ -811,7 +805,7 @@ async function libraryStatusRead(res, read, search) {
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for // `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json. // tests; the field table is otherwise the committed packages/tasks/fields.json.
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request. // `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, proxyAdmin = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined, agentLevels = null } = {}) { export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, proxyAdmin = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined, agentLevels = null } = {}) {
// org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host // org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host
// names (without a port) a remote browser may use; only the dev owner takes them, and only with // names (without a port) a remote browser may use; only the dev owner takes them, and only with
// them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback. // them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback.
@@ -837,8 +831,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// #797 / #1839 Task 3: the request intake reads need --sso, the writes also --send; never the tester. // #797 / #1839 Task 3: the request intake reads need --sso, the writes also --send; never the tester.
if ((requestRead !== null || requestWrite !== null) && (tester || sso === null || (requestRead !== null && typeof requestRead !== 'function') || (requestWrite !== null && (send === null || typeof requestWrite !== 'function')))) if ((requestRead !== null || requestWrite !== null) && (tester || sso === null || (requestRead !== null && typeof requestRead !== 'function') || (requestWrite !== null && (send === null || typeof requestWrite !== 'function'))))
throw new Error('Console requests need --sso (and --send for the writes) on the main listener'); throw new Error('Console requests need --sso (and --send for the writes) on the main listener');
// Phase P2 (#1873): the owner's System types writes need --sso and --send, never the tester.
if (systemTypesWrite !== null && (tester || sso === null || send === null || typeof systemTypesWrite !== 'function')) throw new Error('System types writes need --sso and --send on the main listener');
const confirmations = createConfirmations(); const confirmations = createConfirmations();
const registered = routeTable(); const registered = routeTable();
for (const registerRoutes of routes) registerRoutes(registered); for (const registerRoutes of routes) registerRoutes(registered);
@@ -861,7 +853,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session // `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions. // user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso'); if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso');
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files }; const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, upstream, files };
const hub = federationView(federation); const hub = federationView(federation);
// An object with none of the three is refused too: asking for the preview never serves without it. // An object with none of the three is refused too: asking for the preview never serves without it.
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) { if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
@@ -875,7 +867,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
let boundAddress = null, boundPort = null; let boundAddress = null, boundPort = null;
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
// A granted user's request rebinds these below; every other request keeps the owner's. // A granted user's request rebinds these below; every other request keeps the owner's.
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [], autonomyWrite = null, packsWrite = null, workflowsRead = null, templatesWrite = null; let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, upstream, files } = owner, granted = false, chatGrants = [], autonomyWrite = null, packsWrite = null, workflowsRead = null, templatesWrite = null;
res.setHeader('x-content-type-options', 'nosniff'); res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer'); res.setHeader('referrer-policy', 'no-referrer');
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and // Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
@@ -945,7 +937,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// alone stays open on a loopback Host for the status and release gates (Q-C). // alone stays open on a loopback Host for the status and release gates (Q-C).
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' }); if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
// No CORS. JSON content type and same-origin checks keep browser forms out. // No CORS. JSON content type and same-origin checks keep browser forms out.
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), systemTypesPath = SYSTEM_TYPES_ROUTE.test(path), autonomyPath = AUTONOMY_ROUTE.test(path), const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), autonomyPath = AUTONOMY_ROUTE.test(path),
// GET and HEAD /api/board stay the Board read; every other method on it is the pack route's (POST writes, the rest 405). // GET and HEAD /api/board stay the Board read; every other method on it is the pack route's (POST writes, the rest 405).
packsPath = packsServes(path, req.method), templatesPath = TEMPLATES_ROUTE.test(path); packsPath = packsServes(path, req.method), templatesPath = TEMPLATES_ROUTE.test(path);
if (req.headers.origin && req.headers.origin !== (named ? (sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`) : `http://${req.headers.host}`)) if (req.headers.origin && req.headers.origin !== (named ? (sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`) : `http://${req.headers.host}`))
@@ -959,7 +951,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!session && path !== '/healthz') { if (!session && path !== '/healthz') {
if (decisionPath) return decisionRefuse(res, 'sign-in-required'); if (decisionPath) return decisionRefuse(res, 'sign-in-required');
if (requestPath) return requestRefuse(res, 'sign-in-required'); if (requestPath) return requestRefuse(res, 'sign-in-required');
if (remotePath || systemTypesPath || autonomyPath || packsPath || templatesPath) return json(res, 401, { error: 'sign-in-required' }); if (remotePath || autonomyPath || packsPath || templatesPath) return json(res, 401, { error: 'sign-in-required' });
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' }); if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' }); res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
return res.end(); return res.end();
@@ -972,7 +964,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const instanceAction = originalPath === '/api/console/restart'; const instanceAction = originalPath === '/api/console/restart';
if (session && !session.rootOwner && session.user === undefined && orgId === null && !orgList && !hook?.publicRead && !instanceAction && path !== '/api/config' && path !== '/healthz' && !files.has(path)) return json(res, 404, { error: 'not-found' }); if (session && !session.rootOwner && session.user === undefined && orgId === null && !orgList && !hook?.publicRead && !instanceAction && path !== '/api/config' && path !== '/healthz' && !files.has(path)) return json(res, 404, { error: 'not-found' });
if (session && !session.rootOwner && session.user === undefined) { if (session && !session.rootOwner && session.user === undefined) {
bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = autonomyWrite = packsWrite = workflowsRead = templatesWrite = upstream = null; bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = autonomyWrite = packsWrite = workflowsRead = templatesWrite = upstream = null;
} }
if (session?.user !== undefined && !session.rootOwner && !instanceAction) { if (session?.user !== undefined && !session.rootOwner && !instanceAction) {
// #1873: proxy setup answers a grant holder the way the bus answers an agent. // #1873: proxy setup answers a grant holder the way the bus answers an agent.
@@ -980,7 +972,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' }); if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
if (!organizations && userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' }); if (!organizations && userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
if (!organizations) ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session)); if (!organizations) ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null; systemTypesWrite = null; packsWrite = null; workflowsRead = null; templatesWrite = null; reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null; packsWrite = null; workflowsRead = null; templatesWrite = null;
chatGrants = sso.chatGrants?.(session) ?? []; chatGrants = sso.chatGrants?.(session) ?? [];
} }
} }
@@ -1011,7 +1003,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
({ bus = null, reads = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, ({ bus = null, reads = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null,
projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null,
assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null,
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, autonomyWrite = null, packsWrite = null, workflowsRead = null, templatesWrite = null, upstream = null } = selected); processing = null, decisionWrite = null, requestRead = null, requestWrite = null, autonomyWrite = null, packsWrite = null, workflowsRead = null, templatesWrite = null, upstream = null } = selected);
} }
// F3: the owner's settings writes reach the selected organization's bus too (limits, routing, // F3: the owner's settings writes reach the selected organization's bus too (limits, routing,
// agents, assistant, instances, model proxy). The routes below keep their own checks. // agents, assistant, instances, model proxy). The routes below keep their own checks.
@@ -1074,7 +1066,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return projectRemoteHTTP(req, res, { path, read: requestRead, write: send ? requestWrite : null, return projectRemoteHTTP(req, res, { path, read: requestRead, write: send ? requestWrite : null,
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin }); session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin });
} }
// Phase P2 (#1873): the owner's System types read and writes on the selected organization.
if(autonomyPath) { if(autonomyPath) {
if(tester || granted || !named || sso===null)return json(res,403,{error:'owner-only'}); if(tester || granted || !named || sso===null)return json(res,403,{error:'owner-only'});
// D1 (#1873): an organization's own autonomy writer (`autonomyWrite`, the four autonomy verbs only); // D1 (#1873): an organization's own autonomy writer (`autonomyWrite`, the four autonomy verbs only);
@@ -1082,11 +1073,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return autonomyHTTP(req,res,{path,read:requestRead,write:send?(autonomyWrite??requestWrite):null,session:ssoSession, return autonomyHTTP(req,res,{path,read:requestRead,write:send?(autonomyWrite??requestWrite):null,session:ssoSession,
live:()=>{const live=sso.session(req);return live?.rootOwner===true?live:null;},origin:localOrigin??sso.publicOrigin}); live:()=>{const live=sso.session(req);return live?.rootOwner===true?live:null;},origin:localOrigin??sso.publicOrigin});
} }
if (systemTypesPath) {
if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' });
return systemTypesHTTP(req, res, { path, read: requestRead, write: send ? systemTypesWrite : null,
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin });
}
// Starter packs SP4 (#1873): the owner's pack, board and workflow routes on the selected organization. // Starter packs SP4 (#1873): the owner's pack, board and workflow routes on the selected organization.
if (packsPath) { if (packsPath) {
const owner = !(tester || granted || !named || sso === null); const owner = !(tester || granted || !named || sso === null);
@@ -1099,7 +1085,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return templatesHTTP(req, res, { path, owner, read: requestRead, write: send ? templatesWrite : null, return templatesHTTP(req, res, { path, owner, read: requestRead, write: send ? templatesWrite : null,
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso?.publicOrigin }); session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso?.publicOrigin });
} }
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/system-types.js', '/system-types.css', '/packs.js', '/packs.css', '/workflows.js'].includes(path)) return json(res, 403, { error: 'owner-only' }); if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/packs.js', '/packs.css', '/workflows.js'].includes(path)) return json(res, 403, { error: 'owner-only' });
// Row 194 (#1692, D4.1): the tester also launches and terminates. // Row 194 (#1692, D4.1): the tester also launches and terminates.
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path)) if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
return json(res, 403, { error: 'read-only', sent: false }); return json(res, 403, { error: 'read-only', sent: false });
-140
View File
@@ -1,140 +0,0 @@
// Phase P2 (#1873): the owner's System types routes on a selected organization. Owner Console
// transports only; no caller-supplied business, actor or subject reaches the bus.
//
// GET /api/system-types installed modules (label, description) and the enabled set,
// plus the plugin revision when writes are on
// POST /api/system-types {revision, enabled}: plugins.set {'system-type': enabled},
// the organization writer's owner fence, revision and receipt
// GET /api/system-types/projects/<id> the same, with the project's selection, active and inactive
// POST /api/system-types/projects/<id> {systemTypes}: project.system-types.set
//
// `read` is the owner's Console call (consoleCall): it reaches only `system-types.console`, which the
// broker answers to the business human's console cap. `write` is the owner's human transport limited
// to SYSTEM_TYPE_VERBS, present only for the owner's own session under --send.
export const SYSTEM_TYPES_ROUTE = /^\/api\/system-types(?:\/projects\/([^/]+))?$/;
export const SYSTEM_TYPE_VERBS = Object.freeze(['plugins', 'plugins.set', 'project.system-types.set']);
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
// A module name, as plugins/src/kinds.mjs NAME.
const NAME = /^[a-z][a-z0-9-]{0,63}$/;
const REVISION = /^[a-f0-9]{64}$/;
const TRANSACTION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const MAX_TYPES = 64;
const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null;
const codes = {
'invalid-request': 400, 'invalid-plugins': 400, 'unknown-plugin': 400, 'system-type-unknown': 400,
'project-not-found': 404, 'revision-conflict': 409, 'organization-busy': 409, 'system-type-not-enabled': 409,
'human-required': 403, 'organization-system-types-human-only': 403, 'read-only': 403,
'not-configured': 503, 'system-type-invalid': 503, 'no-bus-host': 503,
};
function json(res, status, value) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(value));
}
const names = list => Array.isArray(list) && list.length <= MAX_TYPES && list.every(x => typeof x === 'string' && NAME.test(x)) && new Set(list).size === list.length;
const text = value => typeof value === 'string' && value.trim() !== '' && value.length <= 400 && !/[\r\n]/.test(value);
function consoleShape(result, project) {
if (!result || !Array.isArray(result.installed) || !names(result.enabled)) return null;
const installed = [];
for (const m of result.installed) {
if (!m || !NAME.test(m.id ?? '') || !text(m.label) || !text(m.description)) return null;
installed.push({ id: m.id, label: m.label, description: m.description });
}
if (new Set(installed.map(m => m.id)).size !== installed.length) return null;
let selection = null;
if (project !== null) {
const p = result.project;
if (!p || p.project !== project || !names(p.selected) || !names(p.active) || !names(p.inactive)) return null;
selection = { project, selected: p.selected, active: p.active, inactive: p.inactive };
} else if (result.project !== null) return null;
return { installed, enabled: result.enabled, project: selection };
}
const selectionShape = (r, project) => r && r.project === project && names(r.selected) && names(r.active) && names(r.inactive)
? { project, selected: r.selected, active: r.active, inactive: r.inactive } : null;
async function body(req, keys) {
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return null;
const chunks = []; let size = 0;
for await (const chunk of req) { size += chunk.length; if (size > 4096) return 'too-large'; chunks.push(chunk); }
let value;
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { return null; }
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length || !keys.every(k => Object.hasOwn(value, k))) return null;
return value;
}
export async function systemTypesHTTP(req, res, { path, read, write, session, live, origin }) {
const refuse = (status, error) => json(res, status, { error });
const fail = error => { const code = codes[error?.code] ? error.code : 'outcome-unknown'; return refuse(codes[code] ?? 503, code); };
if (!['GET', 'POST'].includes(req.method)) return refuse(405, 'method-not-allowed');
if (!session) return refuse(401, 'sign-in-required');
if (!read) return refuse(503, 'not-configured');
if (req.method === 'POST' && !write) return refuse(403, 'read-only');
const site = one(req, 'sec-fetch-site'), from = one(req, 'origin');
if (req.method === 'POST' ? from !== origin || site !== 'same-origin'
: (req.headersDistinct.origin && from !== origin) || (req.headersDistinct['sec-fetch-site'] && !['same-origin', 'none'].includes(site)))
return refuse(403, 'cross-origin');
let project = null;
const match = SYSTEM_TYPES_ROUTE.exec(path);
if (match?.[1] !== undefined) {
try { project = decodeURIComponent(match[1]); } catch { return refuse(404, 'project-not-found'); }
if (!ID.test(project)) return refuse(404, 'project-not-found');
}
let args = null;
if (req.method === 'POST') {
const value = await body(req, project === null ? ['revision', 'enabled'] : ['systemTypes']);
if (value === 'too-large') return refuse(413, 'invalid-request');
if (!value) return refuse(400, 'invalid-request');
if (project === null ? typeof value.revision !== 'string' || !REVISION.test(value.revision) || !names(value.enabled) : !names(value.systemTypes))
return refuse(400, 'invalid-request');
args = value;
}
if (live() !== session) return refuse(401, 'sign-in-required');
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
// The read comes first on every request: a write names only modules this organization has installed.
let current;
try { current = consoleShape(await read('system-types.console', project === null ? {} : { project }, { subject }), project); }
catch (error) { return fail(error); }
if (!current) return refuse(503, 'outcome-unknown');
const installed = new Set(current.installed.map(m => m.id));
if (req.method === 'GET') {
let revision = null;
if (write && project === null) {
// The enabled set and its revision come from one plugins read. The page sends back a whole
// replacement list, so a list older than its revision would undo a change made in between.
let plugins;
try { plugins = await write('plugins', {}); } catch (error) { return fail(error); }
revision = plugins?.revision ?? null;
const enabled = plugins?.plugins?.['system-type'] ?? [];
if (!REVISION.test(revision ?? '') || !names(enabled)) return refuse(503, 'outcome-unknown');
current.enabled = enabled;
}
if (live() !== session) return refuse(401, 'sign-in-required');
return json(res, 200, { ...current, writable: Boolean(write), ...(project === null && write ? { revision } : {}) });
}
// The session is checked again after the read: a sign-out while it ran dispatches no write.
if (live() !== session) return refuse(401, 'sign-in-required');
if (project === null) {
if (args.enabled.some(id => !installed.has(id))) return refuse(400, 'system-type-unknown');
let result;
try { result = await write('plugins.set', { revision: args.revision, plugins: { 'system-type': args.enabled } }); }
catch (error) { return fail(error); }
// A write admitted at spawn may already have committed: its receipt is returned even if the
// session ended meanwhile, never reported as not done.
const enabled = result?.plugins?.['system-type'] ?? [];
if (!result || !REVISION.test(result.revision ?? '') || !TRANSACTION.test(result.transaction ?? '') || !names(enabled)
|| JSON.stringify(enabled) !== JSON.stringify(args.enabled))
return refuse(503, 'outcome-unknown');
return json(res, 200, { enabled, revision: result.revision, transaction: result.transaction });
}
// The broker refuses a type the organization has not enabled (system-type-not-enabled); a type that
// is not installed is refused here first.
if (args.systemTypes.some(id => !installed.has(id))) return refuse(400, 'system-type-unknown');
let result;
try { result = selectionShape(await write('project.system-types.set', { project, systemTypes: args.systemTypes }), project); }
catch (error) { return fail(error); }
if (!result || JSON.stringify(result.selected) !== JSON.stringify(args.systemTypes)) return refuse(503, 'outcome-unknown');
return json(res, 200, result);
}
@@ -16,7 +16,6 @@ for(const [width,height]of [[1440,900],[390,844]])for(const theme of ['light','d
if(verb==='autonomy.set.console'){assert.equal(args.revision,revision);writes++;setting=args.autonomy;revision='b'.repeat(64);return {revision,transaction:'10000000-0000-4000-8000-000000000001'};} if(verb==='autonomy.set.console'){assert.equal(args.revision,revision);writes++;setting=args.autonomy;revision='b'.repeat(64);return {revision,transaction:'10000000-0000-4000-8000-000000000001'};}
if(verb==='decision.confirm.console'){if(args.confirm)confirms++;else{declines++;assert.equal(args.reason,'Revise the plan');}return {op:args.confirm?'confirmed':'declined'};} if(verb==='decision.confirm.console'){if(args.confirm)confirms++;else{declines++;assert.equal(args.reason,'Revise the plan');}return {op:args.confirm?'confirmed':'declined'};}
if(verb==='decision.reverse.console'){reverses++;return {reversed:true};} if(verb==='decision.reverse.console'){reverses++;return {reversed:true};}
if(verb==='system-types.console')return {installed:[],enabled:[],project:null};
throw Object.assign(new Error('not-configured'),{code:'not-configured'}); throw Object.assign(new Error('not-configured'),{code:'not-configured'});
}; };
const ids=['10000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000002','10000000-0000-4000-8000-000000000003']; const ids=['10000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000002','10000000-0000-4000-8000-000000000003'];
+2 -2
View File
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4'); assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()'); await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4); assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it. P2 (#1873) adds system-types.js after project-remote.js. // Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/system-types.js', '/autonomy.js', '/packs.js', '/workflows.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/autonomy.js', '/packs.js', '/workflows.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false); assert.equal(await b.evaluate('!!window.injected'), false);
@@ -1,42 +0,0 @@
import { createHash, randomUUID } from 'node:crypto';
import { installedSystemTypes } from '../../../plugins/src/system-types.mjs';
// Phase P2 (#1873): a per-organization stand-in for the two transports the System types routes use,
// shaped as the broker answers them. `requestRead` answers system-types.console with the installed
// modules (the plug-in data itself, read from packages/plugins), the enabled set and a project's
// selection. `systemTypesWrite` answers plugins, plugins.set (revision checked, a transaction
// returned) and project.system-types.set (enabled types only). `owner: false` makes plugins.set
// refuse as the organization writer's owner fence does for anyone but the human.
export async function systemTypesFake({ enabled = [], projects = {}, owner = true } = {}) {
const installed = await installedSystemTypes();
const state = { enabled: [...enabled], projects: structuredClone(projects), n: 0, writes: [] };
const revision = () => createHash('sha256').update(`system-types-${state.n}`).digest('hex');
const fail = code => { throw Object.assign(new Error(code), { code }); };
const selection = project => {
const selected = state.projects[project];
return { project, selected: [...selected], active: selected.filter(x => state.enabled.includes(x)), inactive: selected.filter(x => !state.enabled.includes(x)) };
};
const requestRead = async (verb, args) => {
if (verb !== 'system-types.console') fail('invalid-request');
if (args.project !== undefined && !Object.hasOwn(state.projects, args.project)) fail('project-not-found');
return { installed: structuredClone(installed), enabled: [...state.enabled], project: args.project === undefined ? null : selection(args.project) };
};
const systemTypesWrite = async (verb, args) => {
state.writes.push(verb);
if (verb === 'plugins') return { business: 'fake', revision: revision(), plugins: { 'system-type': [...state.enabled] } };
if (verb === 'plugins.set') {
if (args.revision !== revision()) fail('revision-conflict');
if (!owner) fail('organization-system-types-human-only');
state.enabled = [...args.plugins['system-type']]; state.n++;
return { business: 'fake', revision: revision(), plugins: { 'system-type': [...state.enabled] }, transaction: randomUUID() };
}
if (verb === 'project.system-types.set') {
if (!Object.hasOwn(state.projects, args.project)) fail('project-not-found');
if (args.systemTypes.some(x => !state.enabled.includes(x))) fail('system-type-not-enabled');
state.projects[args.project] = [...args.systemTypes];
return selection(args.project);
}
fail('invalid-request');
};
return { requestRead, systemTypesWrite, state, installed };
}
@@ -14,7 +14,6 @@ import { busStopForDelete } from '../../cli/src/org-bus.mjs';
import { CliError } from '../../cli/src/errors.mjs'; import { CliError } from '../../cli/src/errors.mjs';
import { actionTrail } from '../src/action-trail.mjs'; import { actionTrail } from '../src/action-trail.mjs';
import { browser } from './browser.mjs'; import { browser } from './browser.mjs';
import { systemTypesFake } from './helpers/system-types-fake.mjs';
import { close } from './fixture.mjs'; import { close } from './fixture.mjs';
// E1: `bus` adds verbs to the admin's bus (busStart for a restore); `trail` gives the Console its action trail; // E1: `bus` adds verbs to the admin's bus (busStart for a restore); `trail` gives the Console its action trail;
@@ -146,10 +145,9 @@ test('E1 (P1-4): restoring the only organization reloads into it and still shows
assert.equal(await f.b.evaluate(`Object.keys(sessionStorage).filter(k=>k.startsWith('mosaic-org-restored:')).length`), 0); assert.equal(await f.b.evaluate(`Object.keys(sessionStorage).filter(k=>k.startsWith('mosaic-org-restored:')).length`), 0);
}); });
test('E1 (P1-5): after restoring another organization, its dashboard shows the bus running and its System types', { timeout: 45000 }, async t => { test('E1 (P1-5): after restoring another organization, its dashboard shows the bus running and its organization file', { timeout: 45000 }, async t => {
const health = { up: false }, bus = startingBus(health), st = await systemTypesFake({ enabled: ['coding'] }); const health = { up: false }, bus = startingBus(health);
const extra = id => ({ const extra = id => ({
requestRead: st.requestRead,
reads: { business: async () => ({ business: { id, human: 'local-dev-owner', arbiters: [], projects: [], vars: {}, instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, reads: { business: async () => ({ business: { id, human: 'local-dev-owner', arbiters: [], projects: [], vars: {}, instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { instances: async () => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }), messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] }, bus: { instances: async () => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }), messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] },
}); });
@@ -158,8 +156,8 @@ test('E1 (P1-5): after restoring another organization, its dashboard shows the b
await f.wait(`/Bus started\. It answers\./.test(document.querySelector('#org-archive-success')?.innerText || '')`); await f.wait(`/Bus started\. It answers\./.test(document.querySelector('#org-archive-success')?.innerText || '')`);
await f.wait(`!!document.querySelector('#workspace-nav [data-workspace-org=beta]')`); await f.wait(`!!document.querySelector('#workspace-nav [data-workspace-org=beta]')`);
await f.b.evaluate(`location.hash = '#/orgs/beta/organization'`); await f.b.evaluate(`location.hash = '#/orgs/beta/organization'`);
await f.wait(`/bus running, read/.test(document.querySelector('[data-org-dashboard]')?.innerText || '') && !!document.querySelector('[data-org-dashboard] [data-system-type-chips] .ws-chip')`); await f.wait(`/bus running, read/.test(document.querySelector('[data-org-dashboard]')?.innerText || '')`);
assert.doesNotMatch(await f.b.evaluate(`document.querySelector('[data-org-dashboard] [data-system-type-chips]').textContent`), /unavailable/); assert.match(await f.b.evaluate(`document.querySelector('[data-org-dashboard] [data-org-setting="Identity"] .v').textContent`), / · beta · /);
assert.deepEqual(bus.starts.map(([id]) => id), ['beta']); assert.deepEqual(bus.starts.map(([id]) => id), ['beta']);
}); });
@@ -1,5 +1,5 @@
// E1 (P1-5): pages follow a state change without a reload. Restart bus redraws the organization // E1 (P1-5): pages follow a state change without a reload. Restart bus redraws the organization
// dashboard's bus line and System types at once, and Quick Setup puts the new organization in the // dashboard's bus line and its settings at once, and Quick Setup puts the new organization in the
// sidebar. Neither waits for the 10 s repaint: each check runs well inside that window. // sidebar. Neither waits for the 10 s repaint: each check runs well inside that window.
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -13,16 +13,14 @@ import { actionTrail } from '../src/action-trail.mjs';
import * as service from '../../organization/src/org-service.mjs'; import * as service from '../../organization/src/org-service.mjs';
import { browser } from './browser.mjs'; import { browser } from './browser.mjs';
import { close } from './fixture.mjs'; import { close } from './fixture.mjs';
import { systemTypesFake } from './helpers/system-types-fake.mjs';
const waitFor = (b, tries = 150) => expression => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); const waitFor = (b, tries = 150) => expression => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
// One organization whose bus state the fake bus below owns. A stopped bus has no reads, as // One organization whose bus state the fake bus below owns. A stopped bus has no reads, as
// organizations.mjs resolves it; a running one answers the dashboard's reads and System types. // organizations.mjs resolves it; a running one answers the dashboard's reads.
async function westOrgs(state) { async function westOrgs(state) {
const st = await systemTypesFake({ enabled: ['coding'] });
const running = () => ({ const running = () => ({
busHealthy: true, requestRead: st.requestRead, systemTypesWrite: null, busHealthy: true,
reads: { business: async () => ({ business: { id: 'west', human: 'jason', arbiters: [], projects: [], vars: {}, instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) }, reads: { business: async () => ({ business: { id: 'west', human: 'jason', arbiters: [], projects: [], vars: {}, instances: [] } }), settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { instances: async () => ({ business: 'west', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }), messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] }, bus: { instances: async () => ({ business: 'west', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }), messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] },
chat: { projects: async () => [], threads: async () => [] }, chat: { projects: async () => [], threads: async () => [] },
@@ -33,7 +31,7 @@ async function westOrgs(state) {
}; };
} }
test('E1 (P1-5b): Restart bus redraws the dashboard bus line and System types without a reload or the 10 s repaint', { timeout: 45000 }, async t => { test('E1 (P1-5b): Restart bus redraws the dashboard bus line and settings without a reload or the 10 s repaint', { timeout: 45000 }, async t => {
const state = { running: false }; const starts = []; const state = { running: false }; const starts = [];
const bus = { const bus = {
busStatus: async (ctx, id) => ({ org: id, healthy: state.running, pid: state.running ? 4242 : null }), busStatus: async (ctx, id) => ({ org: id, healthy: state.running, pid: state.running ? 4242 : null }),
@@ -47,16 +45,16 @@ test('E1 (P1-5b): Restart bus redraws the dashboard bus line and System types wi
const wait = waitFor(b); const wait = waitFor(b);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/west/organization`); await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/west/organization`);
await wait('document.querySelector("[data-org-dashboard] [data-bus-restart]") && /bus unavailable, read/.test(document.querySelector("[data-org-dashboard]").innerText)'); await wait('document.querySelector("[data-org-dashboard] [data-bus-restart]") && /bus unavailable, read/.test(document.querySelector("[data-org-dashboard]").innerText)');
assert.match(await b.evaluate('document.querySelector("[data-org-dashboard] [data-system-type-chips]").textContent'), /System types unavailable/); assert.equal(await b.evaluate('document.querySelector("[data-org-dashboard] [data-org-setting=\\"Identity\\"] .v").textContent'), 'Could not be read');
// The page's own repaint is 10 s after its last draw; mark the draw this test starts from. // The page's own repaint is 10 s after its last draw; mark the draw this test starts from.
await b.evaluate('window.__drawn = Date.now(); window.__first = document.querySelector("[data-org-dashboard]")'); await b.evaluate('window.__drawn = Date.now(); window.__first = document.querySelector("[data-org-dashboard]")');
await b.evaluate('document.querySelector("[data-org-dashboard] [data-bus-restart]").click()'); await b.evaluate('document.querySelector("[data-org-dashboard] [data-bus-restart]").click()');
await wait('/It answers/.test(document.querySelector("[data-org-dashboard] [data-bus-restart-receipt]")?.textContent || "")'); await wait('/It answers/.test(document.querySelector("[data-org-dashboard] [data-bus-restart-receipt]")?.textContent || "")');
// No reload: the page keeps its globals, and the dashboard shows the running bus and the enabled type. // No reload: the page keeps its globals, and the dashboard shows the running bus and its organization file.
await wait('/bus running, read/.test(document.querySelector("[data-org-dashboard]")?.innerText || "") && !!document.querySelector("[data-org-dashboard] [data-system-type-chips] .ws-chip")'); await wait('/bus running, read/.test(document.querySelector("[data-org-dashboard]")?.innerText || "")');
assert.ok(await b.evaluate('Date.now() - window.__drawn') < 8000, 'the dashboard redrew before the 10 s repaint'); assert.ok(await b.evaluate('Date.now() - window.__drawn') < 8000, 'the dashboard redrew before the 10 s repaint');
assert.equal(await b.evaluate('typeof window.__first'), 'object', 'the page was not reloaded'); assert.equal(await b.evaluate('typeof window.__first'), 'object', 'the page was not reloaded');
assert.doesNotMatch(await b.evaluate('document.querySelector("[data-org-dashboard] [data-system-type-chips]").textContent'), /unavailable/); assert.match(await b.evaluate('document.querySelector("[data-org-dashboard] [data-org-setting=\\"Identity\\"] .v").textContent'), /^West · west · /);
assert.match(await b.evaluate('document.querySelector("[data-org-dashboard] [data-bus-restart-receipt]").textContent'), /^Bus started at .*It answers\./); assert.match(await b.evaluate('document.querySelector("[data-org-dashboard] [data-bus-restart-receipt]").textContent'), /^Bus started at .*It answers\./);
assert.deepEqual(starts.map(([id]) => id), ['west']); assert.deepEqual(starts.map(([id]) => id), ['west']);
}); });
@@ -52,7 +52,6 @@ async function fixture(t, { send = true } = {}) {
if (verb === 'packs.console') return { packs: PACKS }; if (verb === 'packs.console') return { packs: PACKS };
if (verb === 'pack.preview.console') return { ...PREVIEW, project: args.project ?? null }; if (verb === 'pack.preview.console') return { ...PREVIEW, project: args.project ?? null };
if (verb === 'autonomy.get.console') return { revision: REV('e'), autonomy: {}, levels: {} }; if (verb === 'autonomy.get.console') return { revision: REV('e'), autonomy: {}, levels: {} };
if (verb === 'system-types.console') return { installed: [], enabled: [], project: null };
throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); throw Object.assign(new Error('not-configured'), { code: 'not-configured' });
}; };
const write = async (verb, args) => { const write = async (verb, args) => {
@@ -407,7 +407,7 @@ test('the same project, dashboard and Agents page in both panes draw no duplicat
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`); await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
const both = route => `location.hash = '#/orgs/ddigital/side-by-side?a=ddigital${route}&b=ddigital${route}'`; const both = route => `location.hash = '#/orgs/ddigital/side-by-side?a=ddigital${route}&b=ddigital${route}'`;
const pages = [ const pages = [
['/organization', `${title('a')}.startsWith('Dashboard') && ${title('b')}.startsWith('Dashboard') && document.querySelectorAll('[data-pane-body] [data-system-types-heading]').length === 2`], ['/organization', `${title('a')}.startsWith('Dashboard') && ${title('b')}.startsWith('Dashboard') && document.querySelectorAll('[data-pane-body] [data-org-settings]').length === 2`],
['/agents', `${title('a')}.startsWith('Agents') && ${title('b')}.startsWith('Agents') && document.querySelectorAll('[data-pane-body] [data-agents-available] tr[data-agent]').length >= 2`], ['/agents', `${title('a')}.startsWith('Agents') && ${title('b')}.startsWith('Agents') && document.querySelectorAll('[data-pane-body] [data-agents-available] tr[data-agent]').length >= 2`],
[`/projects/${P}`, `${title('a')}.startsWith('Pomodoro Timer') && ${title('b')}.startsWith('Pomodoro Timer') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`], [`/projects/${P}`, `${title('a')}.startsWith('Pomodoro Timer') && ${title('b')}.startsWith('Pomodoro Timer') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`],
[`/projects/${P}/conversations`, `${title('a')}.includes('Conversations') && ${title('b')}.includes('Conversations')`], [`/projects/${P}/conversations`, `${title('a')}.includes('Conversations') && ${title('b')}.includes('Conversations')`],
@@ -425,8 +425,8 @@ test('the same project, dashboard and Agents page in both panes draw no duplicat
drawn[page] = await b.evaluate(`[...document.querySelectorAll('#pane-a [data-pane-body] [id]')].map(e => e.id)`); drawn[page] = await b.evaluate(`[...document.querySelectorAll('#pane-a [data-pane-body] [id]')].map(e => e.id)`);
} }
assert.deepEqual(found, Object.fromEntries(pages.map(([route]) => [route.replace(P, 'P'), { duplicates: [], stray: [] }]))); assert.deepEqual(found, Object.fromEntries(pages.map(([route]) => [route.replace(P, 'P'), { duplicates: [], stray: [] }])));
// The audit is not vacuous: the dashboard draws its system types heading and the requirements page // The audit is not vacuous: the dashboard draws its reload note and the requirements page its
// its request select, north star and PRD headings, each with the pane's prefix. // request select, north star and PRD headings, each with the pane's prefix.
assert.deepEqual(drawn['/organization'], ['pane-a-ws-reload-why', 'pane-a-ws-types-h']); assert.deepEqual(drawn['/organization'], ['pane-a-ws-reload-why']);
assert.deepEqual(drawn['/projects/P/requirements'], ['pane-a-project-request-select', 'pane-a-project-north-star', 'pane-a-project-prd']); assert.deepEqual(drawn['/projects/P/requirements'], ['pane-a-project-request-select', 'pane-a-project-north-star', 'pane-a-project-prd']);
}); });
@@ -1,244 +0,0 @@
// Phase P2 (#1873): System types on the organization dashboard (sample 03) and on a project's information
// page. The Console, its per-organization routes and the pages are real; only the two bus transports are
// the fake in helpers/system-types-fake.mjs, which reads the installed modules from packages/plugins. The
// server-side admission checks and the real organization writer are in system-types-http.test.mjs.
// MOSAIC_UX_SHOTS=<dir> also writes light and dark shots at 1440x900 and 390x844.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { systemTypesFake } from './helpers/system-types-fake.mjs';
const P = '10000000-0000-4000-8000-0000000000b1';
const RECEIPT = /Receipt ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\./;
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)}+' | '+(document.querySelector('main')?.innerText||'').slice(0,1500))})()`);
// One running organization with one project. `send` is the owner's signed-in send mode; without it the
// Console only reads and the route gets no writer.
function organizations(st, { send = true } = {}) {
const business = { business: { id: 'acme', human: 'jason', arbiters: [], projects: [P], vars: {}, instances: [], appearance: { theme: null } } };
return {
list: async () => [{ id: 'acme', label: 'Acme', theme: null, busHealthy: true }],
resolve: async id => id !== 'acme' ? null : ({
busHealthy: true, requestRead: st.requestRead, systemTypesWrite: send ? st.systemTypesWrite : null, ...(send ? { send: async () => ({}) } : {}),
reads: { business: async () => business, settings: async () => ({}), queue: async () => ({ rows: [] }), busStatus: async () => ({}) },
bus: { instances: async () => ({ business: 'acme', revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, instances: {} }),
messages: async () => ({ messages: [], unread: 0 }), agents: async () => [], 'thread.claims': async () => [], inbox: async () => [], tasks: async () => [] },
chat: { projects: async () => [{ id: P, name: 'Work', repo: null }], threads: async () => [] },
}),
};
}
async function page(t, st, { send = true, width = 1440, height = 900 } = {}) {
const server = await startServer({ port: 0, sso: createDevOwner(), ...(send ? { send: async () => ({}) } : {}), organizations: organizations(st, { send }), log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const errors = [];
b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'exception'));
await b.viewport(width, height);
const base = `http://127.0.0.1:${server.address().port}/`;
return { b, base, wait: waitFor(b), errors };
}
const dashboard = async ({ b, base, wait }) => {
await b.navigate(`${base}#/orgs/acme/organization`);
await wait('document.querySelector("[data-org-dashboard] [data-system-types]") && !/Reading/.test(document.querySelector("[data-system-types]").textContent)');
};
const settings = async ({ b, base, wait }) => {
await b.navigate(`${base}#/orgs/acme/projects/${P}/settings`);
await wait('document.querySelector("[data-project-system-types]") && !/Reading system types/.test(document.querySelector("[data-project-system-types]").textContent)');
};
const text = (b, selector) => b.evaluate(`(document.querySelector(${JSON.stringify(selector)})?.innerText ?? '').replace(/\\s+/g, ' ').trim()`);
const chips = b => b.evaluate(`[...document.querySelectorAll('[data-system-type-chips] .ws-chip')].map(c => c.textContent)`);
const click = (b, selector) => b.evaluate(`(document.querySelector(${JSON.stringify(selector)}).click(), true)`);
test('the dashboard lists the installed modules from the plug-in data and the owner switches one with a receipt', { timeout: 60000 }, async t => {
const st = await systemTypesFake({ enabled: ['coding'], projects: { [P]: [] } });
const p = await page(t, st); const { b, wait } = p;
await dashboard(p);
// Every row is a module the plug-ins declare, in their order, with their label and description.
const rows = await b.evaluate(`[...document.querySelectorAll('[data-system-types] li')].map(li => [li.dataset.systemType, li.querySelector('.t b').textContent, li.querySelector('.t .d').textContent, li.querySelector('.ws-dot').textContent.replace(/[●–]/, '')])`);
assert.deepEqual(rows, st.installed.map(m => [m.id, m.label, m.description, m.id === 'coding' ? 'enabled' : 'off']));
assert.ok(st.installed.length >= 2, 'the tree installs more than one system type module');
assert.deepEqual(await chips(b), ['Coding']);
assert.match(await text(b, '[data-system-type-chips]'), /Each project picks from these; none is required\.$/);
assert.match(await text(b, '[data-org-setting="System types"]'), new RegExp(`1 of ${st.installed.length} installed enabled`));
// The Settings row's Open moves focus to the section heading.
await click(b, '[data-org-setting="System types"] [data-system-types-open]');
assert.equal(await b.evaluate('document.activeElement?.dataset.systemTypesHeading'), '');
// Enable Marketing from the keyboard; the receipt is the organization writer's transaction.
await b.evaluate(`document.querySelector('[data-system-type-toggle="marketing"]').focus(), true`);
assert.equal(await b.evaluate(`document.activeElement.getAttribute('aria-label')`), 'Enable Marketing');
await b.key('Enter');
await wait(`/Receipt/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
assert.match(await text(b, '[data-system-types-status]'), new RegExp(`^Marketing enabled\\. ${RECEIPT.source}$`));
assert.deepEqual(st.state.enabled, ['coding', 'marketing']);
assert.deepEqual(await chips(b), ['Coding', 'Marketing']);
assert.match(await text(b, '[data-org-setting="System types"]'), new RegExp(`2 of ${st.installed.length} installed enabled`));
// Focus stays on the switch it pressed, which now says Disable.
assert.deepEqual(await b.evaluate(`[document.activeElement.dataset.focusKey, document.activeElement.getAttribute('aria-label')]`), ['st:marketing', 'Disable Marketing']);
// Disable Coding; another write changed the organization file meanwhile, so the first try is refused.
st.state.n++;
await click(b, '[data-system-type-toggle="coding"]');
await wait(`/changed after this page read it/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
assert.deepEqual(st.state.enabled, ['coding', 'marketing']);
await click(b, '[data-system-type-toggle="coding"]');
await wait(`/Coding disabled/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
assert.match(await text(b, '[data-system-types-status]'), RECEIPT);
assert.deepEqual(st.state.enabled, ['marketing']);
assert.equal(await b.evaluate(`document.querySelector('[data-system-type="coding"] .ws-dot').textContent.replace(/[●–]/, '')`), 'off');
assert.deepEqual(p.errors, []);
});
test('without the owner proof the switch is refused, and a read-only Console offers none', { timeout: 60000 }, async t => {
// The writer's owner fence refuses: the page says so and nothing changes.
const fenced = await systemTypesFake({ enabled: ['coding'], projects: { [P]: [] }, owner: false });
const p = await page(t, fenced); const { b, wait } = p;
await dashboard(p);
await click(b, '[data-system-type-toggle="marketing"]');
await wait(`/owner can change/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
assert.equal(await text(b, '[data-system-types-status]'), "Only the organization's owner can change system types.");
assert.deepEqual(fenced.state.enabled, ['coding']);
assert.doesNotMatch(await text(b, '[data-system-types]'), /Receipt/);
// A Console without send mode reads the list and shows no switch; no write reaches the transport.
const readOnly = await systemTypesFake({ enabled: ['coding'], projects: { [P]: ['coding'] } });
const q = await page(t, readOnly, { send: false });
await dashboard(q);
assert.equal(await q.b.evaluate(`document.querySelectorAll('[data-system-types] li').length`), readOnly.installed.length);
assert.equal(await q.b.evaluate(`document.querySelectorAll('[data-system-type-toggle]').length`), 0);
assert.match(await text(q.b, '[data-system-types]'), /This Console only reads\. Changing system types needs signed-in send mode\./);
// The project page shows the selection read only too.
await settings(q);
assert.equal(await q.b.evaluate(`[...document.querySelectorAll('[data-project-system-types] input')].every(i => i.disabled)`), true);
assert.equal(await q.b.evaluate(`document.querySelector('[data-project-system-types] button[type=submit]')`), null);
assert.deepEqual(readOnly.state.writes, []);
});
test('a project selects only enabled modules and a disabled selection shows as inactive', { timeout: 60000 }, async t => {
// Montage was selected, then disabled in the organization.
const st = await systemTypesFake({ enabled: ['coding', 'marketing'], projects: { [P]: ['montage'] } });
const p = await page(t, st); const { b, wait } = p;
await settings(p);
const label = id => st.installed.find(m => m.id === id).label;
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-project-system-types] input[name=system-type]')].map(i => [i.value, i.checked, i.closest('label').querySelector('strong').textContent])`),
[['coding', false, label('coding')], ['marketing', false, label('marketing')]]);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-inactive-type]')].map(li => [li.dataset.inactiveType, li.innerText.replace(/\\s+/g, ' ').trim()])`), [['montage', `${label('montage')} inactive`]]);
assert.match(await text(b, '[data-project-system-types-inactive]'), /Saving keeps only enabled types/);
// Pick Marketing and save: the inactive one goes, the saved selection is the bus's answer.
await b.evaluate(`document.querySelector('[data-project-system-types] input[value=marketing]').click(), true`);
await click(b, '[data-project-system-types] button[type=submit]');
await wait(`/Saved/.test(document.querySelector('[data-project-system-types-status]')?.textContent)`);
assert.equal(await text(b, '[data-project-system-types-status]'), `Saved. Selected: ${label('marketing')}.`);
assert.equal(await b.evaluate(`document.activeElement?.hasAttribute('data-project-system-types-status')`), true);
assert.deepEqual(st.state.projects[P], ['marketing']);
assert.equal(await b.evaluate(`document.querySelector('[data-inactive-type]')`), null);
// The organization disables Coding after the page read it; saving it is refused and the list is read again.
await b.evaluate(`document.querySelector('[data-project-system-types] input[value=coding]').click(), true`);
st.state.enabled = ['marketing'];
await click(b, '[data-project-system-types] button[type=submit]');
await wait(`/not enabled in the organization/.test(document.querySelector('[data-project-system-types-status]')?.textContent)`);
assert.deepEqual(st.state.projects[P], ['marketing']);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-project-system-types] input[name=system-type]')].map(i => i.value)`), ['marketing']);
// The owner disables Marketing on the dashboard; back on the project it stays selected, shown inactive.
await dashboard(p);
await click(b, '[data-system-type-toggle="marketing"]');
await wait(`/Marketing disabled/.test(document.querySelector('[data-system-types-status]')?.textContent)`);
assert.deepEqual(st.state.enabled, []);
await settings(p);
assert.match(await text(b, '[data-project-system-types]'), /The organization has no system types enabled\./);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-inactive-type]')].map(li => li.dataset.inactiveType)`), ['marketing']);
assert.equal(await b.evaluate(`document.querySelectorAll('[data-project-system-types] input').length`), 0);
// With nothing enabled the owner can still clear the inactive selection, without enabling a type first.
assert.equal(await text(b, '[data-project-system-types] button[type=submit]'), 'Clear the inactive types');
await click(b, '[data-project-system-types] button[type=submit]');
await wait(`/Saved/.test(document.querySelector('[data-project-system-types-status]')?.textContent)`);
assert.equal(await text(b, '[data-project-system-types-status]'), 'Saved. No system types selected.');
assert.deepEqual(st.state.projects[P], []);
assert.deepEqual(st.state.enabled, []);
assert.equal(await b.evaluate(`document.querySelector('[data-inactive-type]')`), null);
assert.equal(await b.evaluate(`document.querySelector('[data-project-system-types] button[type=submit]')`), null);
assert.deepEqual(p.errors, []);
// A read-only Console shows the same inactive selection and offers no clear button.
const readOnly = await systemTypesFake({ enabled: [], projects: { [P]: ['coding'] } });
const q = await page(t, readOnly, { send: false });
await settings(q);
assert.deepEqual(await q.b.evaluate(`[...document.querySelectorAll('[data-inactive-type]')].map(li => li.dataset.inactiveType)`), ['coding']);
assert.equal(await q.b.evaluate(`document.querySelector('[data-project-system-types] button[type=submit]')`), null);
assert.deepEqual(readOnly.state.writes, []);
});
test('the dashboard section and the project page fit a phone screen in light and dark', { timeout: 90000 }, async t => {
const st = await systemTypesFake({ enabled: ['coding', 'marketing'], projects: { [P]: ['coding', 'montage'] } });
const shots = process.env.MOSAIC_UX_SHOTS;
if (shots) mkdirSync(shots, { recursive: true });
for (const [width, height] of [[1440, 900], [390, 844]]) {
const p = await page(t, st, { width, height }); const { b } = p;
for (const mode of ['light', 'dark']) {
await b.call('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: mode }] });
await dashboard(p);
// The default appearance is Harbor in the dark whatever the OS says; the header switch picks the mode.
if (await b.evaluate(`document.getElementById('theme-switch').getAttribute('aria-checked')`) !== String(mode === 'dark')) {
await b.evaluate(`document.getElementById('theme-switch').click(), true`);
await p.wait(`document.getElementById('theme-switch').getAttribute('aria-checked') === '${mode === 'dark'}'`);
}
await b.evaluate(`document.querySelector('[data-system-types-heading]').scrollIntoView({ block: 'start' }), true`);
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, `dashboard ${width} ${mode}`);
assert.equal(await b.evaluate(`[...document.querySelectorAll('[data-system-types] li')].every(li => li.scrollWidth <= li.clientWidth)`), true, `dashboard rows ${width} ${mode}`);
if (shots) await b.screenshot(join(shots, `p2-dashboard-types-${width}-${mode}.png`));
await settings(p);
await b.evaluate(`document.querySelector('[data-project-system-types]').scrollIntoView({ block: 'start' }), true`);
// At 1440 a project page taller than the window already overflows by its header's primary action
// (projects.css, before P2), so the wide check is the section's own box; the phone check is the page.
if (width === 390) assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'), true, `project ${width} ${mode}`);
assert.equal(await b.evaluate(`(s => s.scrollWidth <= s.clientWidth && s.getBoundingClientRect().right <= document.documentElement.clientWidth)(document.querySelector('[data-project-system-types]'))`), true, `project section ${width} ${mode}`);
assert.equal(await b.evaluate(`[...document.querySelectorAll('[data-project-system-types] .project-types-option')].every(o => o.getBoundingClientRect().height >= 44)`), true, `targets ${width} ${mode}`);
if (shots) await b.screenshot(join(shots, `p2-project-types-${width}-${mode}.png`));
}
assert.deepEqual(p.errors, []);
}
});
// U5's Side by side draws an organization dashboard into a pane. Each pane reads its own organization's
// system types; a pane only shows them (the switch stays in the organization's full view, as the bus
// restart does), and its Settings row opens the section in the same pane.
test('a side-by-side pane shows its own organization\'s system types and offers no switch', { timeout: 60000 }, async t => {
const acme = await systemTypesFake({ enabled: ['coding'], projects: { [P]: [] } });
const beta = await systemTypesFake({ enabled: ['marketing'], projects: {} });
const one = organizations(acme);
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), log: () => {}, organizations: {
list: async () => [{ id: 'acme', label: 'Acme', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: true }],
resolve: async id => id === 'acme' ? one.resolve('acme') : id === 'beta' ? { ...(await one.resolve('acme')), requestRead: beta.requestRead, systemTypesWrite: beta.systemTypesWrite,
chat: { projects: async () => [], threads: async () => [] } } : null,
} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const errors = [];
b.on('Runtime.exceptionThrown', p => errors.push(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'exception'));
await b.viewport(1440, 900);
const wait = waitFor(b);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/side-by-side?a=acme/organization&b=beta/organization`);
const ready = key => `document.querySelector('#pane-${key} [data-system-types]') && !/Reading/.test(document.querySelector('#pane-${key} [data-system-types]').textContent)`;
await wait(`${ready('a')} && ${ready('b')}`);
const paneChips = key => b.evaluate(`[...document.querySelectorAll('#pane-${key} [data-system-type-chips] .ws-chip')].map(c => c.textContent)`);
assert.deepEqual(await paneChips('a'), ['Coding']);
assert.deepEqual(await paneChips('b'), ['Marketing']);
assert.equal(await b.evaluate(`document.querySelectorAll('.panes [data-system-type-toggle]').length`), 0);
for (const key of ['a', 'b'])
assert.match(await text(b, `#pane-${key} [data-system-types]`), /Change system types from this organization's full view\./);
const ids = await b.evaluate(`[...document.querySelectorAll('[data-system-types-heading]')].map(h => h.id)`);
assert.equal(ids.length, 2); assert.equal(new Set(ids).size, 2);
await click(b, '#pane-b [data-org-setting="System types"] [data-system-types-open]');
assert.equal(await b.evaluate(`document.activeElement === document.querySelector('#pane-b [data-system-types-heading]')`), true);
assert.deepEqual([acme.state.writes.includes('plugins.set'), beta.state.writes.includes('plugins.set')], [false, false]);
assert.deepEqual(errors, []);
});
@@ -1,250 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { request } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { busOrganization } from '../../bus/src/organization-context.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { serve } from '../../bus/src/server.mjs';
import { Client } from '../../bus/src/client.mjs';
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
import { startServer } from '../src/serve.mjs';
import { close } from './fixture.mjs';
// Phase P2 (#1873): the owner's System types routes on a selected organization, end to end. An
// ephemeral organization with the real runtime writer (plugins.set, the system types owner fence,
// changes.jsonl) and the broker's console socket for the read; the SSO session is a fixture
// authority. `writer` picks the cap the write transport binds: the owner's human cap, or the tester.
async function fixture(t, { writer = 'owner', send = true, afterRead = null, beforeWrite = null } = {}) {
// Cleanups run last-made first, registered as soon as each resource exists.
const undo = []; t.after(async () => { for (const step of undo.reverse()) await step(); });
const root = mkdtempSync(join(tmpdir(), 'st-'));
const rolesDir = rolesCopy(root), system = systemFor(root);
const doc = businessDoc(root, 'demo');
doc.plugins = { ...(doc.plugins ?? {}), 'system-type': ['coding'], harness: ['pi'] };
doc.vars = { ...doc.vars, 'bus.tester': true };
const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const business = loadBusiness('demo', { dir: root, rolesDir });
const resolved = Object.fromEntries(Object.keys(business.roles).map(instance => [instance, resolveInstance({ system, business, instance })]));
undo.push(() => rmSync(root, { recursive: true, force: true }));
const runtime = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busOrganization(business, resolved) }, organization: { dir: root, rolesDir, system } });
undo.push(() => runtime.close());
const path = join(root, 'b.sock');
const socketServer = await serve({ broker: runtime.broker, path, peerReader: { read: async () => ({}), close: () => {} },
authenticateConsole: proof => runtime.broker.bindConsole({ business: 'demo', human: 'jason', subject: proof.subject }) });
undo.push(() => socketServer.close());
const owner = runtime.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const role = Object.keys(business.roles)[0];
const cap = writer === 'tester' ? runtime.broker.bindTester({ business: 'demo' })
: writer === 'agent' ? runtime.broker.bindLaunch({ business: 'demo', role, run: 'agent-run', harness: 'pi' }) : owner;
const calls = [];
// afterRead and beforeWrite let a test act between the route's read and its write.
const requestRead = async (verb, args, ctx) => { calls.push(['read', verb]); const r = await new Client({ path, console: { subject: ctx.subject } }).call(verb, args); await afterRead?.(); return r; };
const systemTypesWrite = async (verb, args) => { await beforeWrite?.(verb); calls.push(['write', verb]); return runtime.broker.request(cap, { verb, args }); };
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason', rootOwner: true };
let active = true;
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
const organizations = {
list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
resolve: async id => id !== 'demo' ? null : { busHealthy: true, send: send ? async () => ({}) : null, requestRead, systemTypesWrite: send ? systemTypesWrite : null,
reads: { business: async () => ({ business: { id: 'demo' } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } },
};
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), organizations, log: () => {} });
const base = `http://127.0.0.1:${web.address().port}`;
sso.publicOrigin = base; sso.publicHost = new URL(base).host;
undo.push(() => close(web));
const project = runtime.broker.request(owner, { verb: 'project.create', args: { name: 'example' } }).id;
const onDisk = () => JSON.parse(readFileSync(file)).plugins['system-type'];
const ownerRequest = (verb, args) => runtime.broker.request(owner, { verb, args });
return { base, file, project, calls, onDisk, ownerRequest, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } };
}
function call(f, path, method = 'GET', body, headers = {}) {
return new Promise((resolve, reject) => {
const data = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body);
const req = request(f.base + path, { method, headers: { ...(data !== undefined ? { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...headers } }, res => {
let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)]));
});
req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(data);
});
}
const ORG = '/api/orgs/demo/system-types';
test('the owner reads the installed modules and enables one with the revision, getting a receipt', async t => {
const f = await fixture(t);
const [status, before] = await call(f, ORG);
assert.equal(status, 200, JSON.stringify(before));
// Labels and descriptions are the modules' own, never a list in the WebUI.
const modules = (await installedSystemTypes()).map(({ id, label, description }) => ({ id, label, description }));
assert.deepEqual(before.installed, modules);
assert.deepEqual(before.enabled, ['coding']);
assert.equal(before.project, null); assert.equal(before.writable, true); assert.match(before.revision, /^[a-f0-9]{64}$/);
const [saved, receipt] = await call(f, ORG, 'POST', { revision: before.revision, enabled: ['coding', 'montage'] });
assert.equal(saved, 200, JSON.stringify(receipt));
assert.deepEqual(receipt.enabled, ['coding', 'montage']);
assert.match(receipt.transaction, /^[0-9a-f-]{36}$/); assert.notEqual(receipt.revision, before.revision);
assert.deepEqual(f.onDisk(), ['coding', 'montage']);
const [, after] = await call(f, ORG);
assert.deepEqual(after.enabled, ['coding', 'montage']); assert.equal(after.revision, receipt.revision);
// The revision is the proof: the old one is refused and nothing is written.
const [stale, conflict] = await call(f, ORG, 'POST', { revision: before.revision, enabled: [] });
assert.deepEqual([stale, conflict.error], [409, 'revision-conflict']);
assert.deepEqual(f.onDisk(), ['coding', 'montage']);
});
test('the admission checks refuse before any write', async t => {
const f = await fixture(t);
const [, { revision }] = await call(f, ORG);
const good = { revision, enabled: [] };
for (const [label, body, headers, expected] of [
['unknown module', { revision, enabled: ['nope'] }, {}, [400, 'system-type-unknown']],
['duplicate', { revision, enabled: ['coding', 'coding'] }, {}, [400, 'invalid-request']],
['extra field', { ...good, actor: 'jason' }, {}, [400, 'invalid-request']],
['business field', { ...good, business: 'other' }, {}, [400, 'invalid-request']],
['no revision', { enabled: [] }, {}, [400, 'invalid-request']],
['bad revision', { revision: 'x', enabled: [] }, {}, [400, 'invalid-request']],
['not JSON', '{', {}, [400, 'invalid-request']],
['too large', JSON.stringify({ revision, enabled: [], pad: 'x'.repeat(5000) }), {}, [413, 'invalid-request']],
['form post', good, { 'content-type': 'text/plain' }, [400, 'invalid-request']],
// The Console's own origin check answers a foreign Origin first; the route's own check answers Sec-Fetch-Site.
['cross origin', good, { origin: 'https://other.example' }, [403, 'cross-origin request refused']],
['cross site', good, { 'sec-fetch-site': 'cross-site' }, [403, 'cross-origin']],
]) {
const writes = f.calls.filter(c => c[0] === 'write' && c[1] === 'plugins.set').length;
const [status, result] = await call(f, ORG, 'POST', body, headers);
assert.deepEqual([status, result.error], expected, label);
assert.equal(f.calls.filter(c => c[0] === 'write' && c[1] === 'plugins.set').length, writes, label);
assert.deepEqual(f.onDisk(), ['coding'], label);
}
const [method] = await call(f, ORG, 'PUT');
assert.equal(method, 405);
const [cross, crossBody] = await call(f, ORG, 'GET', undefined, { origin: 'https://other.example' });
assert.equal(cross, 403); assert.match(crossBody.error, /^cross-origin/);
const [badProject, badProjectBody] = await call(f, `${ORG}/projects/${encodeURIComponent('../x')}`);
assert.deepEqual([badProject, badProjectBody.error], [404, 'project-not-found']);
f.signOut();
const [signedOut, signedOutBody] = await call(f, ORG, 'POST', good);
assert.deepEqual([signedOut, signedOutBody.error], [401, 'sign-in-required']);
assert.deepEqual(f.onDisk(), ['coding']);
});
test('a sign-out while the read runs dispatches no write on either route', async t => {
let signOutOnRead = false;
const f = await fixture(t, { afterRead: () => { if (signOutOnRead) f.signOut(); } });
const [, { revision }] = await call(f, ORG);
signOutOnRead = true;
const writes = () => f.calls.filter(c => c[0] === 'write' && c[1] !== 'plugins').length;
for (const [path, body] of [[ORG, { revision, enabled: [] }], [`${ORG}/projects/${f.project}`, { systemTypes: ['coding'] }]]) {
const [status, result] = await call(f, path, 'POST', body);
assert.deepEqual([status, result.error], [401, 'sign-in-required'], path);
}
assert.equal(writes(), 0);
assert.deepEqual(f.onDisk(), ['coding']);
});
test('root authority revoked during a read cannot write through a surviving user session', async t => {
for (const projectRoute of [false, true]) {
let revokeOnRead = false;
const f = await fixture(t, { afterRead: () => { if (revokeOnRead) f.revokeOwner(); } });
const [, { revision }] = await call(f, ORG);
revokeOnRead = true;
const path = projectRoute ? `${ORG}/projects/${f.project}` : ORG;
const body = projectRoute ? { systemTypes: ['coding'] } : { revision, enabled: [] };
const [status, result] = await call(f, path, 'POST', body);
assert.deepEqual([status, result.error], [401, 'sign-in-required'], path);
assert.equal(f.calls.filter(c => c[0] === 'write' && c[1] !== 'plugins').length, 0);
assert.deepEqual(f.onDisk(), ['coding']);
}
});
test('the enabled set and its revision come from one read, so a change in between is not undone', async t => {
let raced = false;
const f = await fixture(t, { beforeWrite: async verb => {
// Another owner action enables Marketing after the route's Console read and before its plugins read.
if (verb !== 'plugins' || raced) return;
raced = true;
const { revision } = await f.ownerRequest('plugins', {});
await f.ownerRequest('plugins.set', { revision, plugins: { 'system-type': ['coding', 'marketing'] } });
} });
const [, view] = await call(f, ORG);
assert.equal(raced, true);
assert.deepEqual(view.enabled, ['coding', 'marketing']);
const [saved, receipt] = await call(f, ORG, 'POST', { revision: view.revision, enabled: view.enabled.filter(x => x !== 'coding') });
assert.equal(saved, 200, JSON.stringify(receipt));
assert.deepEqual(f.onDisk(), ['marketing']);
});
test('without the owner human proof the enable switch is refused', async t => {
// A Console without --send has no write transport: the read works, the write is read-only.
const readOnly = await fixture(t, { send: false });
const [status, view] = await call(readOnly, ORG);
assert.equal(status, 200); assert.equal(view.writable, false); assert.equal(view.revision, undefined);
const [refused, body] = await call(readOnly, ORG, 'POST', { revision: 'a'.repeat(64), enabled: [] });
assert.deepEqual([refused, body.error], [403, 'read-only']);
assert.deepEqual(readOnly.onDisk(), ['coding']);
// A write transport bound as the tester or as an agent, not the owner's human cap, is refused by the
// broker, and the receipt-bearing path never runs.
for (const writer of ['tester', 'agent']) {
const f = await fixture(t, { writer });
const [fenced, fence] = await call(f, ORG, 'POST', { revision: 'b'.repeat(64), enabled: [] });
assert.deepEqual([fenced, fence.error], [403, 'human-required'], writer);
assert.deepEqual(f.onDisk(), ['coding'], writer);
}
});
test('a project selects only enabled modules and a disabled selection reads as inactive', async t => {
const f = await fixture(t);
const path = `${ORG}/projects/${f.project}`;
const [status, empty] = await call(f, path);
assert.equal(status, 200, JSON.stringify(empty));
assert.deepEqual(empty.project, { project: f.project, selected: [], active: [], inactive: [] });
assert.equal(empty.revision, undefined);
const [notEnabled, notEnabledBody] = await call(f, path, 'POST', { systemTypes: ['montage'] });
assert.deepEqual([notEnabled, notEnabledBody.error], [409, 'system-type-not-enabled']);
const [unknown, unknownBody] = await call(f, path, 'POST', { systemTypes: ['nope'] });
assert.deepEqual([unknown, unknownBody.error], [400, 'system-type-unknown']);
const [saved, selection] = await call(f, path, 'POST', { systemTypes: ['coding'] });
assert.equal(saved, 200, JSON.stringify(selection));
assert.deepEqual(selection, { project: f.project, selected: ['coding'], active: ['coding'], inactive: [] });
// The owner disables coding for the organization; the project keeps its selection, now inactive.
const [, { revision }] = await call(f, ORG);
assert.equal((await call(f, ORG, 'POST', { revision, enabled: [] }))[0], 200);
const [, after] = await call(f, path);
assert.deepEqual(after.enabled, []);
assert.deepEqual(after.project, { project: f.project, selected: ['coding'], active: [], inactive: ['coding'] });
const [missing, missingBody] = await call(f, `${ORG}/projects/no-such-project`);
assert.deepEqual([missing, missingBody.error], [404, 'project-not-found']);
const [missingWrite, missingWriteBody] = await call(f, `${ORG}/projects/no-such-project`, 'POST', { systemTypes: [] });
assert.deepEqual([missingWrite, missingWriteBody.error], [404, 'project-not-found']);
});
test('the tester listener and a Console without SSO never reach the routes or the files', async t => {
const { createDevOwner } = await import('../src/dev-owner.mjs');
let reached = 0;
const hit = async () => { reached++; return {}; };
const organizations = { list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }],
resolve: async () => ({ busHealthy: true, send: hit, requestRead: hit, systemTypesWrite: hit, reads: { business: async () => ({ business: { id: 'demo' } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } }) };
const root = mkdtempSync(join(tmpdir(), 'stt-')), socket = join(root, 't.sock');
const tester = await startServer({ tester: true, testerSocket: socket, board: 'http://127.0.0.1:9', organizations, log: () => {} });
t.after(async () => { await close(tester); rmSync(root, { recursive: true, force: true }); });
const viaTester = (path, method = 'GET') => new Promise((resolve, reject) => {
const req = request({ socketPath: socket, path, method, headers: { host: 'mosaic-tester.invalid' } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); });
req.on('error', reject); req.end();
});
for (const path of ['/system-types.js', '/system-types.css']) assert.equal(await viaTester(path), 403, path);
// The tester has no organization session, so the per-organization routes answer org-unavailable.
for (const [path, method] of [[ORG, 'GET'], [ORG, 'POST'], [`${ORG}/projects/p1`, 'GET']]) assert.ok([403, 404].includes(await viaTester(path, method)), path);
const plain = await startServer({ port: 0, organizations, log: () => {} }); t.after(() => close(plain));
const plainBase = `http://127.0.0.1:${plain.address().port}`;
assert.ok((await fetch(`${plainBase}${ORG}`)).status >= 400);
// The owner's dev session on a Console with the routes: the file is served.
const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, log: () => {} }); t.after(() => close(owner));
assert.equal((await fetch(`http://127.0.0.1:${owner.address().port}/system-types.js`)).status, 200);
assert.equal(reached, 0);
await assert.rejects(startServer({ port: 0, sso: createDevOwner(), systemTypesWrite: hit, log: () => {} }), /System types writes need/);
});
@@ -10,13 +10,12 @@ import { join } from 'node:path';
import { browser } from './browser.mjs'; import { browser } from './browser.mjs';
import { close } from './fixture.mjs'; import { close } from './fixture.mjs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { systemTypesFake } from './helpers/system-types-fake.mjs';
const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); const waitFor = b => expression => b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
// Three organizations as sample 01 and 03 show them: two running buses and one stopped. P2: each running // Three organizations as sample 01 and 03 show them: two running buses and one stopped. `send` adds the
// organization's System types read comes from the installed modules; `send` adds the owner's writes. // owner's writes.
function workspaceOrgs({ failThreads = false, seen = null, send = false, types = {}, copies = {}, buses = {} } = {}) { function workspaceOrgs({ failThreads = false, seen = null, send = false, copies = {}, buses = {} } = {}) {
const ORGS = [ const ORGS = [
{ id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true }, { id: 'ddigital', label: 'Distinctly Digital', theme: 'rosewood', busHealthy: true },
{ id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true }, { id: 'forge', label: 'Forge', theme: 'cobalt', busHealthy: true },
@@ -30,8 +29,6 @@ function workspaceOrgs({ failThreads = false, seen = null, send = false, types =
const AGENTS = { ddigital: [{ role: 'chief', holder_run: 'r1', state: 'waiting' }, { role: 'copywriter', holder_run: 'r2', state: 'working' }], forge: [] }; const AGENTS = { ddigital: [{ role: 'chief', holder_run: 'r1', state: 'waiting' }, { role: 'copywriter', holder_run: 'r2', state: 'working' }], forge: [] };
const claim = (thread, holder_run) => ({ thread, holder_run, harness: 'claude', at: '2026-11-09T12:00:00.000Z' }); const claim = (thread, holder_run) => ({ thread, holder_run, harness: 'claude', at: '2026-11-09T12:00:00.000Z' });
const CLAIMS = { ddigital: [claim('t3', 'r1'), claim('t1', 'r2'), claim('t2', 'r3')], forge: [] }; const CLAIMS = { ddigital: [claim('t3', 'r1'), claim('t1', 'r2'), claim('t2', 'r3')], forge: [] };
const TYPES = { ddigital: { enabled: ['coding', 'marketing'], projects: { 'spring-launch': ['coding'], 'brand-refresh': ['marketing'] } }, forge: { enabled: [], projects: { 'api-gateway': [] } }, ...types };
const fakes = {};
const FILES = { 'spring-launch': ['docs/north-star.md', 'docs/launch-plan.md', 'src/index.js'], 'brand-refresh': ['docs/brand-voice-guide.md'], 'api-gateway': [] }; const FILES = { 'spring-launch': ['docs/north-star.md', 'docs/launch-plan.md', 'src/index.js'], 'brand-refresh': ['docs/brand-voice-guide.md'], 'api-gateway': [] };
const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {}, const instances = id => ({ business: id, revision: 'a'.repeat(64), launch: true, noHire: [], retired: [], definitions: {},
instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])), instances: Object.fromEntries(Object.entries(INSTANCES[id]).map(([n, [definition, running]]) => [n, { definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running, alive: [] }])),
@@ -45,10 +42,8 @@ function workspaceOrgs({ failThreads = false, seen = null, send = false, types =
seen?.push(id); seen?.push(id);
const org = ORGS.find(o => o.id === id); if (!org) return null; const org = ORGS.find(o => o.id === id); if (!org) return null;
if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } }; if (!org.busHealthy) return { busHealthy: false, reads: null, bus: {}, chat: { projects: async () => [], threads: async () => [] } };
fakes[id] ??= systemTypesFake(TYPES[id]);
const st = await fakes[id];
return { return {
busHealthy: true, requestRead: st.requestRead, systemTypesWrite: send ? st.systemTypesWrite : null, ...(send ? { send: async () => ({}) } : {}), busHealthy: true, ...(send ? { send: async () => ({}) } : {}),
reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', notifier: { binding: 'discord' }, launch: { limits: { total: 4 } } }), reads: { business: async () => business(id), settings: async () => ({ release: '0.0.42', notifier: { binding: 'discord' }, launch: { limits: { total: 4 } } }),
queue: async () => ({ rows: id === 'ddigital' ? [{ state: 'done' }, { state: 'in-progress' }, { state: 'queued' }] : [] }), busStatus: async () => ({}) }, queue: async () => ({ rows: id === 'ddigital' ? [{ state: 'done' }, { state: 'in-progress' }, { state: 'queued' }] : [] }), busStatus: async () => ({}) },
// `buses` hands an organization's instance read to a real booted bus instead. // `buses` hands an organization's instance read to a real booted bus instead.
@@ -61,7 +56,7 @@ function workspaceOrgs({ failThreads = false, seen = null, send = false, types =
} }
// No bus or setup work runs in these tests: every restart below is refused before the admin is used. // No bus or setup work runs in these tests: every restart below is refused before the admin is used.
const refusingAdmin = () => createOrgAdmin({ service: {}, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) }); const refusingAdmin = () => createOrgAdmin({ service: {}, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
const SETTINGS = ['Identity', 'Appearance', 'System types', 'Projects', 'Agents and roles', 'Models and routing', 'Arbiters', 'Task tracker', 'Channels', 'Launch limits', 'Assistant', 'Token use', 'Model proxy', 'Console', 'Organizations', 'About', 'Members and invites', 'Bus', 'Archive or delete']; const SETTINGS = ['Identity', 'Appearance', 'Projects', 'Agents and roles', 'Models and routing', 'Arbiters', 'Task tracker', 'Channels', 'Launch limits', 'Assistant', 'Token use', 'Model proxy', 'Console', 'Organizations', 'About', 'Members and invites', 'Bus', 'Archive or delete'];
const TAGS = ['org file', 'override', 'system', 'root', 'not built', 'danger']; const TAGS = ['org file', 'override', 'system', 'root', 'not built', 'danger'];
// Every page the Settings view has for the owner (bus.js settingsPages). // Every page the Settings view has for the owner (bus.js settingsPages).
const SETTINGS_PAGES = ['appearance', 'business', 'limits', 'organizations', 'routing', 'agents', 'assistant', 'tokens', 'model-proxy', 'console', 'about']; const SETTINGS_PAGES = ['appearance', 'business', 'limits', 'organizations', 'routing', 'agents', 'assistant', 'tokens', 'model-proxy', 'console', 'about'];
@@ -138,8 +133,6 @@ test('organization dashboard renders every section from the per-organization rea
assert.equal(head.avatar, 'DD'); assert.equal(head.title, 'Distinctly Digital'); assert.equal(head.avatar, 'DD'); assert.equal(head.title, 'Distinctly Digital');
assert.match(head.meta, /Kind not recorded/); assert.match(head.meta, /id ddigital/); assert.match(head.meta, /Tracker: Vikunja/); assert.match(head.meta, /bus running, read \d/); assert.match(head.meta, /Kind not recorded/); assert.match(head.meta, /id ddigital/); assert.match(head.meta, /Tracker: Vikunja/); assert.match(head.meta, /bus running, read \d/);
assert.match(head.colour, /^#[0-9a-f]{6}$/i); assert.match(head.colour, /^#[0-9a-f]{6}$/i);
// System types: the enabled modules by their own labels (P2).
assert.match(await b.evaluate('document.querySelector(".ws-typechips").innerText'), /System types\s+Coding\s*Marketing\s+Each project picks from these/i);
// Docs come from the project files reads: only documents, named by project. // Docs come from the project files reads: only documents, named by project.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-doc")].map(a => a.innerText.replace(/\\s+/g, " ").trim())'), ['North star Spring launch', 'Launch plan Spring launch', 'Brand voice guide Brand refresh']); assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".ws-doc")].map(a => a.innerText.replace(/\\s+/g, " ").trim())'), ['North star Spring launch', 'Launch plan Spring launch', 'Brand voice guide Brand refresh']);
// KPIs and metrics: one honest empty tile each, with no invented number. // KPIs and metrics: one honest empty tile each, with no invented number.
@@ -164,8 +157,6 @@ test('organization dashboard renders every section from the per-organization rea
for (const s of settings) { assert.ok(s.icon, s.name); assert.ok(s.value, s.name); assert.ok(TAGS.includes(s.tag), `${s.name}: ${s.tag}`); } for (const s of settings) { assert.ok(s.icon, s.name); assert.ok(s.value, s.name); assert.ok(TAGS.includes(s.tag), `${s.name}: ${s.tag}`); }
const by = Object.fromEntries(settings.map(s => [s.name, s])); const by = Object.fromEntries(settings.map(s => [s.name, s]));
assert.deepEqual([by['Members and invites'].tag, by['Members and invites'].href], ['not built', null]); assert.deepEqual([by['Members and invites'].tag, by['Members and invites'].href], ['not built', null]);
assert.deepEqual([by['System types'].tag, by['System types'].value], ['org file', '2 of 5 installed enabled']);
assert.equal(await b.evaluate('document.querySelector(\'[data-org-setting="System types"] button.go\').textContent'), 'Open');
assert.deepEqual([by['Archive or delete'].tag, by['Archive or delete'].danger, by['Archive or delete'].href], ['danger', true, '#/settings/organizations']); assert.deepEqual([by['Archive or delete'].tag, by['Archive or delete'].danger, by['Archive or delete'].href], ['danger', true, '#/settings/organizations']);
assert.equal(by.Identity.value, 'Distinctly Digital · ddigital · kind not recorded'); assert.equal(by.Identity.value, 'Distinctly Digital · ddigital · kind not recorded');
assert.equal(by['Task tracker'].value, 'Vikunja'); assert.equal(by['Task tracker'].value, 'Vikunja');
@@ -185,8 +176,7 @@ test('organization dashboard empty states say so in one short line', async t =>
await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")'); await waitFor(b)('document.querySelector("[data-org-dashboard] [data-org-settings]")');
const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText'); const text = await b.evaluate('document.querySelector("[data-org-dashboard]").innerText');
// Section labels are uppercased by CSS, so innerText carries the uppercase form. // Section labels are uppercased by CSS, so innerText carries the uppercase form.
// The empty organization has no System types read, so it says unavailable, never that none exist. for (const line of [/Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
for (const line of [/System types\s+System types unavailable/i, /Docs\s+No docs yet/i, /No KPIs defined in the organization file/, /No metrics recorded yet/, /Active agent sessions\s+none running/i, /No active agent sessions/, /Queue\s+no rows/i, /Tracker: not set/])
assert.match(text, line); assert.match(text, line);
assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length); assert.equal(await b.evaluate('document.querySelectorAll("[data-org-settings] > li").length'), SETTINGS.length);
}); });
@@ -8,7 +8,6 @@ test('integrated project settings and conversation mounts use authorized context
[`GET /api/chat/rooms/${ids.project}/messages?limit=100`]: {status:503,body:{error:'fixture-unavailable'}}, [`GET /api/chat/rooms/${ids.project}/messages?limit=100`]: {status:503,body:{error:'fixture-unavailable'}},
'GET /api/project-files?project=fixture-repository': {status:503,body:{error:'fixture-unavailable'}}, 'GET /api/project-files?project=fixture-repository': {status:503,body:{error:'fixture-unavailable'}},
'GET /api/project-file?project=fixture-repository&path=spec%2Fnorth-star.md': {status:503,body:{error:'fixture-unavailable'}}, 'GET /api/project-file?project=fixture-repository&path=spec%2Fnorth-star.md': {status:503,body:{error:'fixture-unavailable'}},
[`GET /api/system-types/projects/${ids.project}`]: {status:503,body:{error:'fixture-unavailable'}},
'/api/project-remotes/fixture-repository': { project: ids.repository, remote: null, revision: 'a'.repeat(64), firstSaveAllowed: true }, '/api/project-remotes/fixture-repository': { project: ids.repository, remote: null, revision: 'a'.repeat(64), firstSaveAllowed: true },
}; };
const { f, b, wait } = await w15Fixture(t, { scenario }); const { f, b, wait } = await w15Fixture(t, { scenario });
+1 -1
View File
@@ -440,7 +440,7 @@ plugin_checks() {
# Row 181: the tables a bus writer fills without an event in the same transaction, in schema # 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. # 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. # BUILD.md for row 181 names each writer; the T46 guard classifies every table in the schema.
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects project_archives threads sessions session_defaults thread_opens thread_shares instance_names usage_records session_ops message_cancels request_events request_effects request_snapshots request_snapshot_events request_project_events project_system_types" COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects project_archives threads sessions session_defaults thread_opens thread_shares instance_names usage_records session_ops message_cancels request_events request_effects request_snapshots request_snapshot_events request_project_events"
STORE_JS=' STORE_JS='
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url"); const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");