#threadFor puts a message to a threaded instance in the thread found up its in_reply_to chain. It checked the thread's instance but not its owner, so a tester reply to a message in a human's thread landed in the human's thread. A new #testerOwns check refuses thread-not-owned when the sender is the tester and the thread's owner isn't. It runs on both the thread branch and the row 143 spec-notice branch. A role's reply into a tester's thread is unchanged, since that's how the answer comes back. The bus README says so in its row 177 paragraph. tester-threads.test.mjs adds four T9 tests: a tester reply into the human's thread refuses and stores nothing, one into its own thread lands, the chief's reply into a tester thread lands, and a tester reply to a spec notice refuses while the chief's relay still lands. messaging.test.mjs:203 used to send as the tester into a human thread. It now sends from a tester-owned thread. Filbert approved round 1 in #1680 comment 29388. Their notes N1 (thread-instance-mismatch is checked first, as before) and N2 (BUILD.md missed project-runtime.test.mjs:13 as a text reader of broker.mjs, now corrected) need no change.
299 lines
15 KiB
JavaScript
299 lines
15 KiB
JavaScript
// Row 177 (#1675, Q173): the tester's own threads. Owner `tester`; neither the human nor the
|
|
// tester reaches the other's threads. The assistant's lists are the shipped roles/assistant.json's,
|
|
// and in a tester's thread the broker reads the tester as the human.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { Store } from '../src/store.mjs';
|
|
import { Broker } from '../src/broker.mjs';
|
|
|
|
const shipped = (name) =>
|
|
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
|
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
|
const roles = {
|
|
chief: { definition: 'chief-of-staff', ...none },
|
|
assistant: { definition: 'assistant', ...none },
|
|
};
|
|
const demo = (extra = {}, messaging = shipped('assistant').messaging) => ({
|
|
demo: {
|
|
id: 'demo',
|
|
human: 'jason',
|
|
arbiters: { technical: 'chief', delivery: 'chief' },
|
|
vars: { 'bus.tester': true },
|
|
roles,
|
|
definitions: { 'chief-of-staff': {}, assistant: { messaging } },
|
|
...extra,
|
|
},
|
|
});
|
|
|
|
function setup(t, businesses = demo()) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-tester-threads-'));
|
|
const store = new Store(root);
|
|
t.after(() => {
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const b = new Broker({ store, businesses });
|
|
const caps = {
|
|
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
|
tester: b.bindTester({ business: 'demo' }),
|
|
};
|
|
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
|
const code = (who, verb, args = {}) => {
|
|
try {
|
|
call(who, verb, args);
|
|
} catch (e) {
|
|
return e.code;
|
|
}
|
|
return null;
|
|
};
|
|
const launch = (name, thread) => {
|
|
caps[name] = b.bindLaunch({ business: 'demo', role: 'assistant', run: `${name}-run`, harness: 'pi', thread });
|
|
return call(name, 'role.claim');
|
|
};
|
|
const inputs = () => store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n;
|
|
return { b, store, caps, call, code, launch, inputs };
|
|
}
|
|
|
|
// A project of each, with one thread of the human's (H) and one of the tester's (T).
|
|
function both(t, businesses) {
|
|
const ctx = setup(t, businesses);
|
|
const hp = ctx.call('human', 'project.create', { name: 'human work' }).id;
|
|
const tp = ctx.call('tester', 'project.create', { name: 'test work' }).id;
|
|
const H = ctx.call('human', 'thread.create', { project: hp, name: 'mine' }).id;
|
|
const T = ctx.call('tester', 'thread.create', { project: tp, name: 'probe' }).id;
|
|
return { ...ctx, hp, tp, H, T };
|
|
}
|
|
|
|
test('row 177 T1: a tester thread is owned by tester, and neither side reaches or lists the other', (t) => {
|
|
const { store, call, code, hp, H, T } = both(t);
|
|
assert.equal(store.get('SELECT owner FROM threads WHERE id=?', T).owner, 'tester');
|
|
assert.equal(store.get('SELECT owner FROM threads WHERE id=?', H).owner, 'jason');
|
|
assert.deepEqual(call('human', 'thread.list').map((x) => x.id), [H]);
|
|
assert.deepEqual(call('tester', 'thread.list').map((x) => x.id), [T]);
|
|
assert.deepEqual(call('tester', 'thread.list', { project: hp }), []);
|
|
for (const [verb, args] of [
|
|
['thread.open', {}],
|
|
['thread.send', { body: 'hello' }],
|
|
['thread.messages', {}],
|
|
]) {
|
|
assert.equal(code('human', verb, { thread: T, ...args }), 'thread-not-owned', `human ${verb}`);
|
|
assert.equal(code('tester', verb, { thread: H, ...args }), 'thread-not-owned', `tester ${verb}`);
|
|
}
|
|
// The accepted leak: names are unique per project, not per owner.
|
|
assert.equal(code('tester', 'thread.create', { project: hp, name: 'mine' }), 'thread-exists');
|
|
// A tester may make a thread in the human's project; it is still the tester's.
|
|
const X = call('tester', 'thread.create', { project: hp, name: 'side' }).id;
|
|
assert.deepEqual(call('human', 'thread.list', { project: hp }).map((x) => x.id), [H]);
|
|
assert.deepEqual(call('tester', 'thread.list').map((x) => x.id), [T, X]);
|
|
});
|
|
|
|
test('row 177 T2: the tester thread.send is from tester-cli with no human.input; its open is by tester', (t) => {
|
|
const { store, call, inputs, H, T } = both(t);
|
|
const before = inputs();
|
|
const sent = call('tester', 'thread.send', { thread: T, body: 'hello' });
|
|
assert.equal(sent.request, null);
|
|
assert.equal(inputs(), before);
|
|
const m = store.get('SELECT * FROM messages WHERE id=?', sent.id);
|
|
assert.equal(m.from_role, 'tester');
|
|
assert.equal(m.from_run, 'tester-cli');
|
|
assert.equal(m.to_role, 'assistant');
|
|
assert.equal(store.get('SELECT thread FROM message_threads WHERE message=?', sent.id).thread, T);
|
|
assert.deepEqual(call('tester', 'thread.messages', { thread: T }).map((x) => x.id), [sent.id]);
|
|
assert.deepEqual(call('tester', 'thread.open', { thread: T }), { opened: T });
|
|
assert.equal(store.get('SELECT by FROM thread_opens WHERE thread=?', T).by, 'tester');
|
|
// The human's send still writes its human.input.
|
|
const own = call('human', 'thread.send', { thread: H, body: 'mine' });
|
|
assert.ok(own.request);
|
|
assert.equal(inputs(), before + 1);
|
|
});
|
|
|
|
test('row 177 T3: the lists read the tester as the human in its own thread, and nowhere else', (t) => {
|
|
const { code, call, T } = both(t);
|
|
assert.ok(call('tester', 'thread.send', { thread: T, body: 'in the thread' }).id);
|
|
// Outside a thread the tester is still not on the assistant's lists.
|
|
assert.equal(code('tester', 'message.send', { to: 'assistant', body: 'direct' }), 'message-sender-refused');
|
|
});
|
|
|
|
test('row 177 T3: an assistant that would not hear the human refuses the tester too', (t) => {
|
|
const { code, T } = both(t, demo({}, { sendTo: ['human', 'chief-of-staff'], receiveFrom: ['chief-of-staff'] }));
|
|
assert.equal(code('tester', 'thread.send', { thread: T, body: 'hello' }), 'message-sender-refused');
|
|
});
|
|
|
|
test('row 177 T4: the tester thread run answers the tester, never the human', (t) => {
|
|
const { store, call, code, launch, H, T } = both(t);
|
|
const asked = call('tester', 'thread.send', { thread: T, body: 'probe' }).id;
|
|
launch('a', T);
|
|
const reply = call('a', 'message.send', { to: 'tester', body: 'answer', class: 'RESULT', in_reply_to: asked }).id;
|
|
const r = store.get('SELECT * FROM messages WHERE id=?', reply);
|
|
assert.equal(r.to_role, 'tester');
|
|
assert.equal(store.get('SELECT thread FROM message_threads WHERE message=?', reply).thread, T);
|
|
assert.deepEqual(call('tester', 'thread.messages', { thread: T }).map((x) => x.id), [asked, reply]);
|
|
assert.equal(call('human', 'messages').messages.some((m) => m.id === reply), false);
|
|
assert.deepEqual(call('human', 'message.receive'), []);
|
|
assert.deepEqual(call('tester', 'message.receive').map((m) => m.id), [reply]);
|
|
// The run's own message to the human refuses.
|
|
assert.equal(code('a', 'message.send', { to: 'human', body: 'psst' }), 'tester-thread-human');
|
|
assert.equal(call('human', 'messages').total, 0);
|
|
// A run of the human's thread still answers the human.
|
|
call('human', 'thread.send', { thread: H, body: 'mine' });
|
|
launch('h', H);
|
|
assert.ok(call('h', 'message.send', { to: 'human', body: 'yours' }).id);
|
|
assert.equal(call('human', 'messages').total, 1);
|
|
// Outside a tester's thread the lists still don't name the tester, so the human's thread run
|
|
// can't answer it even in reply to a tester's message.
|
|
assert.equal(code('h', 'message.send', { to: 'tester', body: 'x', in_reply_to: asked }), 'message-recipient-refused');
|
|
});
|
|
|
|
test('row 177 T5: each stop revokes only its own threads, recorded by its own actor', (t) => {
|
|
const { store, call, code, launch, H, T } = both(t);
|
|
launch('a', T);
|
|
launch('h', H);
|
|
const last = (thread) => store.get('SELECT * FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', thread);
|
|
assert.deepEqual(call('tester', 'assistant.stop', { instance: 'assistant' }), { stopped: [{ thread: T, run: 'a-run' }] });
|
|
assert.equal(last(T).op, 'revoke');
|
|
assert.equal(last(T).by, 'tester');
|
|
assert.equal(last(T).reason, 'stopped by the tester');
|
|
assert.equal(last(H).op, 'claim');
|
|
assert.equal(code('tester', 'assistant.stop', { instance: 'assistant' }), 'not-held');
|
|
assert.deepEqual(call('human', 'assistant.stop', { instance: 'assistant' }), { stopped: [{ thread: H, run: 'h-run' }] });
|
|
assert.equal(last(H).by, 'jason');
|
|
assert.equal(last(H).reason, 'stopped by the human');
|
|
assert.equal(code('human', 'assistant.stop', { instance: 'assistant' }), 'not-held');
|
|
});
|
|
|
|
test('row 177 T5: the human stop never ends a tester run', (t) => {
|
|
const { store, code, launch, T } = both(t);
|
|
launch('a', T);
|
|
assert.equal(code('human', 'assistant.stop', { instance: 'assistant' }), 'not-held');
|
|
assert.equal(store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', T).op, 'claim');
|
|
});
|
|
|
|
test('row 177 T6: a business whose human is tester refuses invalid-business', (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-tester-threads-boot-'));
|
|
const store = new Store(root);
|
|
t.after(() => {
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const businesses = demo();
|
|
businesses.demo.human = 'tester';
|
|
assert.throws(() => new Broker({ store, businesses }), { code: 'invalid-business' });
|
|
});
|
|
|
|
test('row 177 T7: the human trail shows a tester thread message as the tester', (t) => {
|
|
const { call, T } = both(t);
|
|
const sent = call('tester', 'thread.send', { thread: T, body: 'probe' }).id;
|
|
const rows = call('human', 'trail', { subject: sent }).filter((r) => r.table === 'messages');
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].from_role, 'tester');
|
|
assert.equal(rows[0].from_run, 'tester-cli');
|
|
});
|
|
|
|
// Row 177 r2 (#1675, F1): an action outside the assistant's grants is gated, and an uncited gated
|
|
// raise routes to the human, so the tester-thread run's raise refuses before anything is stored.
|
|
const raise = (action = 'task.create') => ({
|
|
action,
|
|
question: `${action}?`,
|
|
options: [
|
|
{ key: 'yes', text: 'Yes' },
|
|
{ key: 'no', text: 'No' },
|
|
],
|
|
recommendation: 'no',
|
|
blocking: false,
|
|
});
|
|
|
|
test('row 177 T8: a tester-thread run never raises a decision to the human; a human-thread run still does', (t) => {
|
|
const { store, call, code, launch, H, T } = both(t);
|
|
const decisions = () => store.get('SELECT count(*) n FROM decisions').n;
|
|
launch('a', T);
|
|
launch('h', H);
|
|
assert.equal(code('a', 'decision.raise', raise()), 'tester-thread-human');
|
|
assert.deepEqual(call('human', 'inbox'), []);
|
|
assert.equal(decisions(), 0);
|
|
const raised = call('h', 'decision.raise', raise());
|
|
assert.equal(store.get('SELECT route_to FROM decisions WHERE id=?', raised.id).route_to, 'human');
|
|
assert.deepEqual(call('human', 'inbox').map((d) => d.id), [raised.id]);
|
|
});
|
|
|
|
test('row 177 T8: a tester-thread raise routed to the arbiter stays allowed', (t) => {
|
|
const crossRole = { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: ['task.create'] } };
|
|
const { store, call, launch, T } = both(t, demo({ roles: { ...roles, assistant: crossRole } }));
|
|
launch('a', T);
|
|
const raised = call('a', 'decision.raise', raise());
|
|
assert.equal(store.get('SELECT route_to FROM decisions WHERE id=?', raised.id).route_to, 'chief');
|
|
assert.deepEqual(call('human', 'inbox'), []);
|
|
});
|
|
|
|
// Row 182 (#1680, Filbert N1 on row 177): a tester's message.send to a threaded instance takes its
|
|
// thread from the in_reply_to chain, so the owner check runs there too. The assistant here has no
|
|
// lists, so #checkMessaging lets the tester through and the chain is what decides. `stack` is a
|
|
// project the business names, for the spec notice test.
|
|
const open = () => demo({ definitions: { 'chief-of-staff': {}, assistant: {} }, projects: { stack: { root: '/nonexistent/r182' } } });
|
|
const rows = (store) => ['messages', 'message_threads'].map((x) => store.get(`SELECT count(*) n FROM ${x}`).n);
|
|
|
|
test('row 182 T9: a tester reply into the human\'s thread refuses thread-not-owned and stores nothing', (t) => {
|
|
const { store, call, code, launch, H } = both(t, open());
|
|
launch('h', H);
|
|
const mine = call('human', 'thread.send', { thread: H, body: 'mine' }).id;
|
|
const before = [rows(store), call('human', 'thread.messages', { thread: H })];
|
|
assert.equal(code('tester', 'message.send', { to: 'assistant', body: 'in', in_reply_to: mine }), 'thread-not-owned');
|
|
assert.deepEqual([rows(store), call('human', 'thread.messages', { thread: H })], before);
|
|
assert.deepEqual(call('h', 'message.receive').map((m) => m.id), [mine]);
|
|
});
|
|
|
|
test('row 182 T9: a tester reply into its own thread lands there', (t) => {
|
|
const { store, call, launch, T } = both(t, open());
|
|
launch('a', T);
|
|
const asked = call('tester', 'thread.send', { thread: T, body: 'probe' }).id;
|
|
const again = call('tester', 'message.send', { to: 'assistant', body: 'more', in_reply_to: asked }).id;
|
|
assert.equal(store.get('SELECT thread FROM message_threads WHERE message=?', again).thread, T);
|
|
assert.deepEqual(call('tester', 'thread.messages', { thread: T }).map((x) => x.id), [asked, again]);
|
|
assert.deepEqual(call('a', 'message.receive').map((m) => m.id), [asked, again]);
|
|
});
|
|
|
|
// The chief holds its role as a plain run, outside any thread.
|
|
const chief = ({ b, caps, call }) => {
|
|
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
|
call('chief', 'role.claim');
|
|
};
|
|
|
|
test('row 182 T9: the chief\'s reply up a chain into a tester\'s thread lands there', (t) => {
|
|
const ctx = both(t, open());
|
|
const { store, call, launch, T } = ctx;
|
|
launch('a', T);
|
|
chief(ctx);
|
|
const asked = call('tester', 'thread.send', { thread: T, body: 'ask the chief' }).id;
|
|
const relay = call('a', 'message.send', { to: 'chief', body: 'for you', in_reply_to: asked }).id;
|
|
const back = call('chief', 'message.send', { to: 'assistant', body: 'answer', in_reply_to: relay }).id;
|
|
assert.equal(store.get('SELECT thread FROM message_threads WHERE message=?', back).thread, T);
|
|
assert.deepEqual(call('a', 'message.receive').map((m) => m.id), [asked, back]);
|
|
assert.deepEqual(call('tester', 'thread.messages', { thread: T }).map((x) => x.id), [asked, relay, back]);
|
|
});
|
|
|
|
test('row 182 T9: a tester reply to a spec notice refuses thread-not-owned; the chief\'s relay still lands', (t) => {
|
|
const ctx = both(t, open());
|
|
const { store, call, code, launch } = ctx;
|
|
const project = call('human', 'project.create', { name: 'stack', repo: 'stack' }).id;
|
|
const S = call('human', 'thread.create', { project, name: 'spec' }).id;
|
|
launch('s', S);
|
|
chief(ctx);
|
|
// The row #specNotice (broker.mjs) writes for a human save of the spec in project stack. The save
|
|
// itself needs spec-review.test.mjs's git fixture, which has no tester.
|
|
const notice = 'n-182';
|
|
store.run(
|
|
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
|
notice, new Date().toISOString(), 'demo', '_bus', 'bus', 'chief', 'RESULT', null, null, null,
|
|
'The human saved spec/north-star.md in project stack: commit c1, from the editor\'s base c0.',
|
|
);
|
|
const before = rows(store);
|
|
assert.equal(code('tester', 'message.send', { to: 'assistant', body: 'in', in_reply_to: notice }), 'thread-not-owned');
|
|
assert.deepEqual(rows(store), before);
|
|
// The same chain from the chief reaches the human's thread, so the tester's send got as far as the notice.
|
|
const relay = call('chief', 'message.send', { to: 'assistant', body: 'relay', in_reply_to: notice }).id;
|
|
assert.equal(store.get('SELECT thread FROM message_threads WHERE message=?', relay).thread, S);
|
|
});
|