137 lines
7.2 KiB
JavaScript
137 lines
7.2 KiB
JavaScript
// Row 277 (#1782, design D1, duckworth-0118): `thread.claims` gives human and reader sessions the
|
|
// thread claims held in the business. A thread run's role.claim writes thread_claims, which `agents`
|
|
// doesn't read, so this is where the owner sees it. `agents` is unchanged for every session.
|
|
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 { randomUUID } from 'node:crypto';
|
|
import { Store } from '../src/store.mjs';
|
|
import { Broker } from '../src/broker.mjs';
|
|
|
|
const AT = '2026-10-22T00:00:00.000Z';
|
|
const within = (grants) => ({ authority: { withinRole: grants, crossRole: [] } });
|
|
const ROW = ['thread', 'instance', 'holder_run', 'harness', 'at'];
|
|
// Row 264a (#1769): a hand-made thread gets the default session the 3k-3l migration gives an old one.
|
|
const DEFAULT_SESSION = [
|
|
'INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,\'default\',\'legacy-3l\' FROM threads WHERE id=?',
|
|
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
|
|
];
|
|
|
|
function setup(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-thread-claims-'));
|
|
const store = new Store(root);
|
|
t.after(() => {
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const b = new Broker({
|
|
store,
|
|
businesses: {
|
|
demo: {
|
|
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
|
roles: { chief: { definition: 'chief-of-staff', ...within(['message.send']) }, assistant: { definition: 'assistant', ...within(['message.send']) } },
|
|
},
|
|
},
|
|
});
|
|
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
|
|
b.request(chief, { verb: 'role.claim' });
|
|
const caps = {
|
|
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
|
reader: b.bindReader({ business: 'demo' }),
|
|
tester: b.bindTester({ business: 'demo' }),
|
|
chief,
|
|
};
|
|
// `who` is a session name from caps, or a run's capability.
|
|
const call = (who, verb, args = {}) => b.request(Object.hasOwn(caps, who) ? caps[who] : who, { verb, args });
|
|
const project = call('human', 'project.create', { name: 'shared work' }).id;
|
|
// The human's own thread, so the human's assistant.stop reaches its run.
|
|
const thread = (name) => {
|
|
const id = randomUUID();
|
|
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', project, 'assistant', 'jason', name);
|
|
for (const sql of DEFAULT_SESSION) store.run(sql, id);
|
|
return id;
|
|
};
|
|
// A run on the thread's default session, or on `session` when one is named.
|
|
const claim = (H, run, session) => {
|
|
const cap = b.bindLaunch({ business: 'demo', role: 'assistant', run, harness: 'pi', thread: H, ...(session ? { session } : {}) });
|
|
call(cap, 'role.claim');
|
|
return cap;
|
|
};
|
|
return { b, store, caps, call, thread, claim };
|
|
}
|
|
|
|
test('T1: the human and the reader see a held thread claim; agents still lists the role only', (t) => {
|
|
const { store, call, thread, claim } = setup(t);
|
|
const H = thread('plan');
|
|
claim(H, 'assistant-run');
|
|
const at = store.get("SELECT at FROM thread_claims WHERE thread=? AND op='claim'", H).at;
|
|
const rows = call('human', 'thread.claims');
|
|
// The store's rows have no prototype, so each is copied before the strict compare.
|
|
assert.deepEqual(rows.map((r) => ({ ...r })), [{ thread: H, instance: 'assistant', holder_run: 'assistant-run', harness: 'pi', at }]);
|
|
for (const r of rows) assert.deepEqual(Object.keys(r), ROW);
|
|
assert.deepEqual(call('reader', 'thread.claims'), rows);
|
|
assert.deepEqual(call('human', 'agents').map((r) => r.role), ['chief']);
|
|
});
|
|
|
|
test('T2: a released or revoked thread claim is not listed, though its thread has an earlier claim row', (t) => {
|
|
const { store, call, thread, claim } = setup(t);
|
|
const H = thread('plan');
|
|
const first = claim(H, 'run-1');
|
|
call(first, 'role.release');
|
|
assert.deepEqual(call('human', 'thread.claims'), [], 'released');
|
|
claim(H, 'run-2');
|
|
assert.deepEqual(call('human', 'thread.claims').map((r) => r.holder_run), ['run-2'], 'claimed again');
|
|
call('human', 'assistant.stop', { instance: 'assistant' });
|
|
assert.deepEqual(call('human', 'thread.claims'), [], 'revoked');
|
|
// The thread's first row is still a claim, so only the latest row decides.
|
|
assert.deepEqual(store.all('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq', H).map((r) => r.op), ['claim', 'release', 'claim', 'revoke']);
|
|
});
|
|
|
|
test('T3: a role session refuses human-required and the tester refuses tester-refused', (t) => {
|
|
const { call, thread, claim } = setup(t);
|
|
const run = claim(thread('plan'), 'assistant-run');
|
|
for (const who of ['chief', run]) assert.throws(() => call(who, 'thread.claims'), { code: 'human-required' });
|
|
assert.throws(() => call('tester', 'thread.claims'), { code: 'tester-refused' });
|
|
});
|
|
|
|
test('T4: agents answers each session the same before and after a thread run claims', (t) => {
|
|
const { call, thread, claim } = setup(t);
|
|
const sessions = ['human', 'reader', 'tester', 'chief'];
|
|
const before = Object.fromEntries(sessions.map((who) => [who, call(who, 'agents')]));
|
|
claim(thread('plan'), 'assistant-run');
|
|
assert.equal(call('human', 'thread.claims').length, 1);
|
|
for (const who of sessions) assert.deepEqual(call(who, 'agents'), before[who], who);
|
|
});
|
|
|
|
test('T5: the result carries neither the thread name a user wrote nor a message body', (t) => {
|
|
const { store, call, thread, claim } = setup(t);
|
|
const H = thread('alice secret thread name');
|
|
claim(H, 'assistant-run');
|
|
// The thread's message, written to the store as message-filter.test.mjs's put() writes one.
|
|
const m = randomUUID();
|
|
store.run(
|
|
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
|
m, AT, 'demo', 'assistant', 'assistant-run', 'human', 'INFO', null, null, null, 'alice secret message body',
|
|
);
|
|
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', m, H, H);
|
|
const text = JSON.stringify(call('human', 'thread.claims'));
|
|
assert.ok(text.includes(H), 'the thread row is there');
|
|
assert.doesNotMatch(text, /secret/);
|
|
});
|
|
|
|
// Row 264a (#1769, sage-2591, duckworth-0124 C4): a claim is held per session, so one thread with two
|
|
// held sessions lists both runs, and releasing one leaves the other. A per-thread latest row would show
|
|
// only the last claim.
|
|
test('T6: two held sessions of one thread list as two rows, each its session\'s latest claim', (t) => {
|
|
const { call, thread, claim } = setup(t);
|
|
const H = thread('plan');
|
|
const S = call('human', 'session.create', { thread: H, name: 'second', op: 'op-second' }).id;
|
|
const first = claim(H, 'run-default');
|
|
claim(H, 'run-second', S);
|
|
assert.deepEqual(call('human', 'thread.claims').map((r) => [r.thread, r.holder_run]), [[H, 'run-default'], [H, 'run-second']]);
|
|
call(first, 'role.release');
|
|
assert.deepEqual(call('human', 'thread.claims').map((r) => r.holder_run), ['run-second'], 'the default released, the second held');
|
|
});
|