Files
jason.woltje e6ab8bc1fd 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.
2026-11-12 21:02:46 -06:00

457 lines
26 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
// Row 264a (#1769, T1 item 7): sessions below a thread, in the bus. T1's bus half, T2 and T7 of
// docs/plans/2026-10-21_t1-sessions.md. The launcher, seat and Console halves are 264b-264e's.
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const roles = {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
};
const businesses = {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'chief' },
roles,
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, pm: {} },
projects: { stack: { root: '/nonexistent' } },
},
};
const AT = '2026-10-21T00:00:00.000Z';
const now = () => new Date().toISOString();
const here = (p) => new URL(p, import.meta.url);
const ddl3k = readFileSync(here('./fixtures/schema-3k.sql'), 'utf8');
function broker(t, store) {
const b = new Broker({ store, businesses });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const caps = { human };
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const launch = (name, role, extra = {}) => {
caps[name] = b.bindLaunch({ business: 'demo', role, run: `${name}-run`, harness: 'pi', ...extra });
return caps[name];
};
const claim = (name) => call(name, 'role.claim');
const sessionOf = (message) => store.get('SELECT session FROM message_threads WHERE message=?', message)?.session ?? null;
const claims = (session) => store.all('SELECT op, holder_run, generation FROM thread_claims WHERE session=? ORDER BY seq', session).map((r) => ({ ...r }));
return { b, store, caps, call, launch, claim, sessionOf, claims };
}
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-sessions-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const ctx = broker(t, store);
const project = ctx.call('human', 'project.create', { name: 'stack work', repo: 'stack' }).id;
const A = ctx.call('human', 'thread.create', { project, name: 'alpha' }).id;
for (const r of ['chief', 'pm']) {
ctx.launch(r, r);
ctx.claim(r);
}
const [first] = ctx.call('human', 'session.list', { thread: A });
return { ...ctx, root, project, A, D: first.id };
}
test('T1 REQ-ROLE-2: two sessions of one thread keep their own history, delivery and replies', (t) => {
const { call, launch, claim, sessionOf, store, project, A, D } = setup(t);
// thread.create made one default session, with a random id that isn't the thread's.
assert.deepEqual(call('human', 'session.list', { thread: A }).map((r) => [r.name, r.predecessor, r.default]), [['default', null, true]]);
assert.notEqual(D, A);
assert.match(D, /^[a-f0-9-]{36}$/);
const S = call('human', 'session.create', { thread: A, name: 'second', op: 'op-second' }).id;
assert.deepEqual(call('human', 'session.list', { thread: A }).map((r) => [r.id, r.name, r.default]), [
[D, 'default', true],
[S, 'second', false],
]);
// A run with no session named works the default (D3). Each session has its own holder.
launch('a1', 'assistant', { thread: A });
launch('a2', 'assistant', { thread: A, session: S });
assert.deepEqual(claim('a1'), { role: 'assistant', run: 'a1-run', thread: A });
assert.deepEqual(claim('a2'), { role: 'assistant', run: 'a2-run', thread: A });
const toDefault = call('human', 'thread.send', { thread: A, body: 'to the default' });
const toSecond = call('human', 'thread.send', { thread: A, session: S, body: 'to the second' });
assert.equal(sessionOf(toDefault.id), D);
assert.equal(sessionOf(toSecond.id), S);
assert.deepEqual(call('a1', 'message.receive').map((m) => m.body), ['to the default']);
assert.deepEqual(call('a2', 'message.receive').map((m) => m.body), ['to the second']);
assert.deepEqual(call('a1', 'message.receive'), []);
// A reply stays in its run's session, and each history reads only its own.
const reply = call('a2', 'message.send', { to: 'human', body: 'second answers', in_reply_to: toSecond.id });
assert.equal(sessionOf(reply.id), S);
assert.deepEqual(call('human', 'thread.messages', { thread: A }).map((m) => m.body), ['to the default']);
assert.deepEqual(call('human', 'thread.messages', { thread: A, session: S }).map((m) => m.body), ['to the second', 'second answers']);
// The chief's reply to the second session's work comes back to that session.
const ask = call('a2', 'message.send', { to: 'chief', body: 'need help', class: 'REQUEST' });
const back = call('chief', 'message.send', { to: 'assistant', body: 'here', in_reply_to: ask.id });
assert.equal(sessionOf(back.id), S);
assert.deepEqual(call('a2', 'message.receive').map((m) => m.body), ['here']);
assert.deepEqual(call('a1', 'message.receive'), []);
// An open names its session too.
call('human', 'thread.open', { thread: A, session: S });
assert.equal(store.get('SELECT session FROM thread_opens ORDER BY seq DESC LIMIT 1').session, S);
// A non-thread role names no session, and a session of another thread is refused.
assert.throws(() => launch('p2', 'pm', { session: S }), { code: 'thread-not-allowed' });
const B = call('human', 'thread.create', { project, name: 'beta' }).id;
assert.throws(() => launch('a3', 'assistant', { thread: B, session: S }), { code: 'session-not-found' });
assert.throws(() => call('human', 'thread.send', { thread: B, session: S, body: 'x' }), { code: 'session-not-found' });
assert.throws(() => call('human', 'thread.messages', { thread: B, session: 'no-such-session' }), { code: 'session-not-found' });
});
test('T2 REQ-ROLE-2: one holder per session, generations count its claims, and a sibling session\'s message never reaches the other holder', (t) => {
const { b, call, launch, claim, claims, store, project, A, D } = setup(t);
const S = call('human', 'session.create', { thread: A, name: 'second', op: 'op-second' }).id;
launch('a1', 'assistant', { thread: A });
claim('a1');
launch('a1b', 'assistant', { thread: A });
assert.throws(() => claim('a1b'), { code: 'thread-already-held' });
// A sibling session is free to claim while the default is held.
launch('a2', 'assistant', { thread: A, session: S });
claim('a2');
// Release and claim again: the second claim is generation 2, and its release carries it.
call('a2', 'role.release');
launch('a3', 'assistant', { thread: A, session: S });
claim('a3');
b.endLaunch({ business: 'demo', run: 'a3-run', reason: 'idle' });
assert.deepEqual(claims(S), [
{ op: 'claim', holder_run: 'a2-run', generation: 1 },
{ op: 'release', holder_run: 'a2-run', generation: 1 },
{ op: 'claim', holder_run: 'a3-run', generation: 2 },
{ op: 'release', holder_run: 'a3-run', generation: 2 },
]);
// The human's stop revokes each held session, with that claim's generation.
launch('a4', 'assistant', { thread: A, session: S });
claim('a4');
assert.deepEqual(call('human', 'assistant.stop', { instance: 'assistant' }), {
stopped: [
{ thread: A, run: 'a1-run' },
{ thread: A, run: 'a4-run' },
],
});
assert.deepEqual(claims(D).at(-1), { op: 'revoke', holder_run: 'a1-run', generation: 1 });
assert.deepEqual(claims(S).at(-1), { op: 'revoke', holder_run: 'a4-run', generation: 3 });
// The triggers hold these for a write that skips the broker.
const raw = (thread, session, generation, run) =>
store.run(
"INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,'demo',?,?,?,'claim',?,'pi',?)",
now(),
thread,
session,
generation,
run,
run,
);
assert.throws(() => raw(A, S, 5, 'x-run'), /a claim row names its session's generation/);
assert.throws(() => raw(A, S, 3, 'x-run'), /a claim row names its session's generation/);
const B = call('human', 'thread.create', { project, name: 'beta' }).id;
const [{ id: BD }] = call('human', 'session.list', { thread: B });
assert.throws(() => raw(A, BD, 1, 'x-run'), /a claim's session is in its thread/);
raw(A, S, 4, 'x-run');
assert.throws(() => raw(B, BD, 1, 'x-run'), /run already holds a session/);
// A threaded message goes only to its own session's holder.
launch('a5', 'assistant', { thread: A });
claim('a5');
const m = call('human', 'thread.send', { thread: A, session: S, body: 'for x' });
assert.throws(
() => store.run("INSERT INTO deliveries(message,at,op,holder_run) VALUES(?,?,'delivered',?)", m.id, now(), 'a5-run'),
/a threaded message goes only to its session's holder/,
);
store.run("INSERT INTO deliveries(message,at,op,holder_run) VALUES(?,?,'delivered',?)", m.id, now(), 'x-run');
});
test('T2 sage-2257: a released or revoked run\'s retired generation refuses its approval, delivery and read, and writes nothing', (t) => {
const { call, launch, claim, claims, store, A, D } = setup(t);
const tables = store.all("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").map((r) => r.name);
const counts = () => Object.fromEntries(tables.map((n) => [n, store.get(`SELECT count(*) AS n FROM "${n}"`).n]));
// The run raises a decision, takes one message and leaves a second waiting.
const prepare = (name) => {
launch(name, 'assistant', { thread: A });
claim(name);
const d = call(name, 'decision.raise', {
action: 'spend',
target: 'demo',
question: 'spend?',
options: [
{ key: 'yes', text: 'Yes' },
{ key: 'no', text: 'No' },
],
recommendation: 'no',
blocking: false,
});
call('human', 'thread.send', { thread: A, body: `for ${name}` });
const [taken] = call(name, 'message.receive');
call('human', 'thread.send', { thread: A, body: `waiting after ${name}` });
return { d, taken };
};
// Each refusal leaves its one action.refused event (request()'s catch, #refused); nothing else is
// written: no approval, delivery, read or claim state (mos-1277).
const lastSeq = () => store.get('SELECT max(seq) AS s FROM events').s;
const refusedOnce = (fn, label, run) => {
const from = lastSeq();
assert.throws(fn, { code: 'not-holder' }, label);
const added = store.all('SELECT kind, actor_run, body FROM events WHERE seq > ? ORDER BY seq', from);
assert.deepEqual(
added.map((e) => [e.kind, e.actor_run, JSON.parse(e.body)]),
[['action.refused', run, { code: 'not-holder' }]],
label,
);
};
const refused = (name, { d, taken }) => {
const { events, ...before } = counts();
refusedOnce(() => call(name, 'decision.withdraw', { id: d.id }), 'approval', `${name}-run`);
refusedOnce(() => call(name, 'message.receive'), 'delivery', `${name}-run`);
refusedOnce(() => call(name, 'message.read', { id: taken.id }), 'read', `${name}-run`);
const { events: after, ...rest } = counts();
assert.deepEqual(rest, before);
assert.equal(after, events + 3);
};
// Released: generation 1 is retired once a2 claims generation 2.
const one = prepare('a1');
call('a1', 'role.release');
const two = prepare('a2');
refused('a1', one);
// Revoked by the human's stop: generation 2 is retired once a3 claims generation 3.
call('human', 'assistant.stop', { instance: 'assistant' });
prepare('a3');
refused('a2', two);
assert.deepEqual(claims(D).map((r) => [r.op, r.holder_run, r.generation]), [
['claim', 'a1-run', 1],
['release', 'a1-run', 1],
['claim', 'a2-run', 2],
['revoke', 'a2-run', 2],
['claim', 'a3-run', 3],
]);
});
test('session.create is idempotent on its op, refuses a reused op or a taken name, and reaches only the owner\'s threads', (t) => {
const { call, store, project, A, D } = setup(t);
const first = call('human', 'session.create', { thread: A, name: 'second', op: 'op-1' });
assert.deepEqual(call('human', 'session.create', { thread: A, name: 'second', op: 'op-1' }), first);
assert.throws(() => call('human', 'session.create', { thread: A, name: 'third', op: 'op-1' }), { code: 'session-op-conflict' });
assert.throws(() => call('human', 'session.create', { thread: A, name: 'second', op: 'op-2' }), { code: 'session-exists' });
assert.throws(() => call('human', 'session.create', { thread: A, name: 'default', op: 'op-3' }), { code: 'session-exists' });
// thread.create's op answers a retry that names the default.
assert.deepEqual(call('human', 'session.create', { thread: A, name: 'default', op: 'default' }), { id: D, thread: A, name: 'default' });
for (const args of [{ thread: A, name: 'x\ny', op: 'op-4' }, { thread: A, name: 'x' }, { thread: A, name: 'x', op: 'op-5', extra: 1 }])
assert.throws(() => call('human', 'session.create', args), { code: 'invalid-request' });
assert.equal(store.get('SELECT count(*) n FROM sessions WHERE thread=?', A).n, 2);
// Another owner's thread, made by hand with the session the broker would write.
const other = 'thread-of-someone-else';
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', other, now(), 'demo', project, 'assistant', 'someone-else', 'theirs');
store.run("INSERT INTO sessions(id,at,business,thread,instance,owner,name,op) VALUES('s-theirs',?,'demo',?,'assistant','someone-else','default','default')", now(), other);
store.run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,'s-theirs','someone-else')", now(), other);
for (const [verb, args] of [
['session.list', { thread: other }],
['session.create', { thread: other, name: 'mine', op: 'op-6' }],
['thread.send', { thread: other, session: 's-theirs', body: 'hi' }],
])
assert.throws(() => call('human', verb, args), { code: 'thread-not-owned' }, verb);
assert.throws(() => call('human', 'session.list', { thread: 'nope' }), { code: 'thread-not-found' });
// Agents don't call the session verbs.
assert.throws(() => call('pm', 'session.list', { thread: A }), { code: 'human-required' });
});
test('the 3l triggers keep sessions in their thread and session ids apart from thread ids', (t) => {
// A bare database from schema.sql, so only the triggers stand between a write and the tables.
const root = mkdtempSync(join(tmpdir(), 'bus-sessions-sql-'));
const db = new DatabaseSync(join(root, 'bus.sqlite'));
t.after(() => {
db.close();
rmSync(root, { recursive: true, force: true });
});
db.exec(readFileSync(here('../schema.sql'), 'utf8'));
const run = (sql, ...args) => db.prepare(sql).run(...args);
const session = (id, thread, extra = {}) =>
run(
'INSERT INTO sessions(id,at,business,thread,instance,owner,name,op,predecessor) VALUES(?,?,?,?,?,?,?,?,?)',
id,
now(),
extra.business ?? 'demo',
thread,
extra.instance ?? 'assistant',
extra.owner ?? 'jason',
extra.name ?? id,
extra.op ?? id,
extra.predecessor ?? null,
);
run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES('p-1',?,'demo','work',NULL,'jason')", now());
for (const id of ['th-1', 'th-2'])
run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,'demo','p-1','assistant','jason',?)", id, now(), id);
session('s-1', 'th-1');
// The legacy shape: a session whose id is its own thread's.
session('th-1', 'th-1', { name: 'default', op: 'legacy-3l' });
// A session id that names another thread, and a thread id that names a session, are refused.
assert.throws(() => session('th-2', 'th-1'), /a session id names another thread/);
assert.throws(
() => run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES('s-1',?,'demo','p-1','assistant','jason','x')", now()),
/a thread id names a session/,
);
// A session has its thread's business, instance and owner, and its predecessor is in its thread.
for (const extra of [{ business: 'other' }, { instance: 'pm' }, { owner: 'someone-else' }])
assert.throws(() => session('s-x', 'th-1', extra), /a session has its thread's business, instance and owner/, JSON.stringify(extra));
assert.throws(() => session('s-x', 'th-2', { predecessor: 's-1' }), /a session's predecessor is in its thread/);
session('s-2', 'th-1', { predecessor: 's-1' });
// The names and op keys are unique in a thread.
assert.throws(() => session('s-3', 'th-1', { name: 's-1' }), /UNIQUE/);
assert.throws(() => session('s-3', 'th-1', { op: 's-1' }), /UNIQUE/);
// A default is a session of its thread.
assert.throws(() => run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,'th-2','s-1','jason')", now()), /a default session is in its thread/);
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,'th-1','s-1','jason')", now());
// A message's and an open's session is in their thread.
run("INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES('m-1',?,'demo','human','human-cli','assistant','INFO','hi')", now());
assert.throws(() => run("INSERT INTO message_threads(message,thread,session) VALUES('m-1','th-2','s-1')"), /a threaded message's session is in its thread/);
assert.throws(() => run("INSERT INTO thread_opens(at,thread,session,by) VALUES(?,'th-2','s-1','jason')", now()), /an open's session is in its thread/);
});
// T7: a 3k store from the pinned 3k schema (8f51900b5's, the parent of this change's schema.sql)
// and fixture rows. No live store is read or copied.
function source3k(t) {
const root = mkdtempSync(join(tmpdir(), 'sessions-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
assert.equal(createHash('sha256').update(ddl3k).digest('hex'), 'aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0');
db.exec(ddl3k);
const run = (sql, ...args) => db.prepare(sql).run(...args);
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3k');
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', AT, 'demo', 'stack work', 'stack', 'jason');
for (const id of ['t1', 't2'])
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', 'p-1', 'assistant', 'jason', id);
// Duckworth's fixture: claim, release, claim, revoke on t1, with t2's claim between, at seqs
// with gaps so a renumbered copy would show.
const claimRow = (seq, thread, op, holder, extra = {}) =>
run(
'INSERT INTO thread_claims(seq,at,business,thread,op,holder_run,harness,by,reason) VALUES(?,?,?,?,?,?,?,?,?)',
seq, AT, 'demo', thread, op, holder, 'pi', extra.by ?? holder, extra.reason ?? null,
);
claimRow(10, 't1', 'claim', 'a-run');
claimRow(11, 't2', 'claim', 'b-run');
claimRow(12, 't1', 'release', 'a-run');
claimRow(20, 't1', 'claim', 'c-run');
claimRow(40, 't1', 'revoke', 'c-run', { by: 'jason', reason: 'stuck' });
for (const [id, thread] of [['m-1', 't1'], ['m-2', 't2'], ['m-3', 't1']]) {
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', id, AT, 'demo', 'human', 'human-cli', 'assistant', 'INFO', id);
run('INSERT INTO message_threads VALUES(?,?)', id, thread);
}
run('INSERT INTO thread_opens(seq,at,thread,by) VALUES(?,?,?,?)', 7, AT, 't1', 'jason');
db.close(); chmodSync(path, 0o600);
return { root, path };
}
const master = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r }));
} finally { db.close(); }
};
const rows = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens', 'meta']
.map((name) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all().map((r) => ({ ...r }))]));
} finally { db.close(); }
};
const version = (path) => {
const db = new DatabaseSync(path, { readOnly: true });
try { return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value; } finally { db.close(); }
};
test('T7: a 3k store migrates to 3l, each thread\'s default session has the thread\'s id, and old rows keep their seqs', (t) => {
const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3w');
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
new Store(freshRoot).close();
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
const store = new Store(root);
t.after(() => store.close());
assert.deepEqual(store.all('SELECT id,at,business,thread,instance,owner,name,op,predecessor FROM sessions ORDER BY seq').map((r) => ({ ...r })), ['t1', 't2'].map((id) => (
{ id, at: AT, business: 'demo', thread: id, instance: 'assistant', owner: 'jason', name: 'default', op: 'legacy-3l', predecessor: null })));
assert.deepEqual(store.all('SELECT at,thread,session,by FROM session_defaults ORDER BY seq').map((r) => ({ ...r })), ['t1', 't2'].map((id) => (
{ at: AT, thread: id, session: id, by: 'migration-3l' })));
// Generation counts claim rows only: claim, release, claim, revoke on t1 is 1, 1, 2, 2.
assert.deepEqual(store.all('SELECT seq,thread,session,op,generation FROM thread_claims ORDER BY seq').map((r) => ({ ...r })), [
{ seq: 10, thread: 't1', session: 't1', op: 'claim', generation: 1 },
{ seq: 11, thread: 't2', session: 't2', op: 'claim', generation: 1 },
{ seq: 12, thread: 't1', session: 't1', op: 'release', generation: 1 },
{ seq: 20, thread: 't1', session: 't1', op: 'claim', generation: 2 },
{ seq: 40, thread: 't1', session: 't1', op: 'revoke', generation: 2 },
]);
assert.deepEqual(store.all('SELECT message,thread,session FROM message_threads ORDER BY rowid').map((r) => ({ ...r })), [
{ message: 'm-1', thread: 't1', session: 't1' },
{ message: 'm-2', thread: 't2', session: 't2' },
{ message: 'm-3', thread: 't1', session: 't1' },
]);
assert.deepEqual({ ...store.get('SELECT seq,thread,session,by FROM thread_opens') }, { seq: 7, thread: 't1', session: 't1', by: 'jason' });
for (const [name, seq] of [['thread_claims', 40], ['thread_opens', 7]])
assert.equal(store.get('SELECT seq FROM sqlite_sequence WHERE name=?', name).seq, seq, name);
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
// A legacy client names no session and gets the default: t2's holder releases at generation 1,
// and t1's next claim is its third.
const { call, launch, claim, claims, sessionOf } = broker(t, store);
for (const r of ['chief', 'pm']) {
launch(r, r);
claim(r);
}
assert.throws(() => call('human', 'session.create', { thread: 't1', name: 'default', op: 'op-new' }), { code: 'session-exists' });
store.run("INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,'demo','t2','t2',1,'release','b-run','pi','b-run')", now());
assert.equal(store.get("SELECT seq FROM thread_claims WHERE thread='t2' ORDER BY seq DESC LIMIT 1").seq, 41);
launch('d', 'assistant', { thread: 't1' });
claim('d');
assert.deepEqual(claims('t1').at(-1), { op: 'claim', holder_run: 'd-run', generation: 3 });
const sent = call('human', 'thread.send', { thread: 't1', body: 'after the step' });
assert.equal(sessionOf(sent.id), 't1');
assert.deepEqual(call('d', 'message.receive').map((m) => m.body), ['m-1', 'm-3', 'after the step']);
assert.deepEqual(call('human', 'thread.messages', { thread: 't1' }).map((m) => m.body), ['m-1', 'm-3', 'after the step']);
});
// The 3k engine is store.mjs at this change with the 3k schema and version and no 3k→3l step.
async function engine3k(root) {
const moduleRoot = join(root, 'engine-3k');
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3w';", "export const SCHEMA_VERSION = '3k';")
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3k);
for (const file 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'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it as it was', async (t) => {
const { root, path } = source3k(t);
new Store(root, { migrateSchema: true }).close();
const was = master(path), before = rows(path);
const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3w');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
const { root: old } = source3k(t);
new Store3k(old).close();
});