Adds the system-type kind with five modules (coding, creative-writing, marketing, montage, business-management), an org-level enabled list validated fail-closed, project selections limited to the org's list, read verbs, and bus migration 3t-3u. REQ-SYS-2 toolsets are designed and deferred: they need a role authority contract.
457 lines
26 KiB
JavaScript
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), '3u');
|
|
// The migrated schema is the fresh one, object for object.
|
|
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
|
|
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
|
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 = '3u';", "export const SCHEMA_VERSION = '3k';")
|
|
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
|
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
|
|
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
|
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), '3u');
|
|
assert.deepEqual(master(path), was);
|
|
assert.deepEqual(rows(path), before);
|
|
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
|
|
const { root: old } = source3k(t);
|
|
new Store3k(old).close();
|
|
});
|