141 lines
7.3 KiB
JavaScript
141 lines
7.3 KiB
JavaScript
// Row 306 (#1813, row 295's N1): a run whose claim #revokeLost ended answers thread-revoked on every
|
|
// claim-needing call until the run ends, so the runner stops it instead of exiting session-invalid.
|
|
// A claim another cause ended still answers not-holder. access-revoke.test.mjs's fixture, copied, with
|
|
// a limit stub and a usage stub for the limit stop. BUILD.md (agents/morgana/work/queue-306) names
|
|
// the mutant each test kills.
|
|
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 grant = (over = {}) => ({
|
|
id: `g-${over.user ?? 'alice'}`, user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
|
|
});
|
|
const BUSINESSES = {
|
|
demo: {
|
|
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, plugins: { harness: ['pi'] },
|
|
roles: {
|
|
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
|
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
|
},
|
|
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
|
},
|
|
};
|
|
const HIT = { path: ['tokens', 'instances', 'assistant'], limit: 100, count: 120, unknownTurns: 1 };
|
|
|
|
// Alice and Bob each hold a collaborator grant and own a thread; the human owns thread H. Each thread
|
|
// has a claimed run: a-run on A, b-run on B, h-run on H. `stops` are the limit checks' answers, in order.
|
|
function setup(t, { stops = [] } = {}) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-revoke-window-'));
|
|
const store = new Store(root);
|
|
t.after(() => {
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [grant(), grant({ user: 'bob' })] }, rev: 1 };
|
|
const b = new Broker({
|
|
store,
|
|
businesses: BUSINESSES,
|
|
access: () => access.doc,
|
|
accessStamp: () => String(access.rev),
|
|
limits: { mustStop: async () => stops.shift() ?? { stop: false } },
|
|
usage: { collect: async () => ({ recorded: true }), view: () => ({}) },
|
|
});
|
|
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
|
|
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 edit = (fn) => {
|
|
access.doc = { ...access.doc, revision: access.doc.revision + 1, grants: fn(access.doc.grants) };
|
|
access.rev++;
|
|
};
|
|
const launch = (name, thread) => {
|
|
caps[name] = b.bindLaunch({ business: 'demo', role: 'assistant', run: `${name}-run`, harness: 'pi', thread });
|
|
return call(name, 'role.claim');
|
|
};
|
|
const claim = (run) => ({ ...store.get('SELECT op, reason, by FROM thread_claims WHERE holder_run=? ORDER BY seq DESC LIMIT 1', run) });
|
|
// The codes of the run's refusals, as request() logs them.
|
|
const refused = (run) =>
|
|
store.all("SELECT body FROM events WHERE kind='action.refused' AND actor_run=? ORDER BY seq", run).map((e) => JSON.parse(e.body).code);
|
|
const P = call('human', 'project.create', { name: 'shared' }).id;
|
|
caps.alice = b.bindUser({ business: 'demo', user: 'alice' });
|
|
caps.bob = b.bindUser({ business: 'demo', user: 'bob' });
|
|
const A = call('alice', 'thread.create', { project: P, name: 'alice work' }).id;
|
|
const B = call('bob', 'thread.create', { project: P, name: 'bob work' }).id;
|
|
const H = call('human', 'thread.create', { project: P, name: 'owner work' }).id;
|
|
launch('a', A);
|
|
launch('b', B);
|
|
launch('h', H);
|
|
return { b, store, caps, call, code, edit, claim, refused, A, B, H };
|
|
}
|
|
|
|
const withoutAlice = (gs) => gs.filter((g) => g.user !== 'alice');
|
|
|
|
// The run's claim-needing calls after the sweep: each answers thread-revoked, and each is logged.
|
|
function lateCalls(code) {
|
|
return [
|
|
code('a', 'message.receive'),
|
|
code('a', 'state'),
|
|
code('a', 'message.read', { id: 'm-none' }),
|
|
code('a', 'message.send', { to: 'human', body: 'late' }),
|
|
];
|
|
}
|
|
|
|
test('row 306 T1: a run the timer sweep ended answers thread-revoked on every claim-needing call, logged, and its claim still refuses run-revoked', (t) => {
|
|
const { b, code, edit, claim, refused } = setup(t);
|
|
edit(withoutAlice);
|
|
assert.deepEqual(b.accessSweep().map((h) => h.run), ['a-run']);
|
|
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-timer' });
|
|
assert.deepEqual(lateCalls(code), Array(4).fill('thread-revoked'));
|
|
assert.deepEqual(refused('a-run'), Array(4).fill('thread-revoked'));
|
|
assert.equal(code('a', 'role.claim'), 'run-revoked', 'role.claim keeps its order');
|
|
assert.equal(code('b', 'message.receive'), null, 'another user\'s run stays');
|
|
assert.equal(code('h', 'message.receive'), null, 'the human\'s run stays');
|
|
});
|
|
|
|
test('row 306 T1: a user\'s request and access.reload end the run the same way, and it answers thread-revoked', (t) => {
|
|
for (const trigger of ['request', 'reload']) {
|
|
const { code, call, edit, claim } = setup(t);
|
|
edit(withoutAlice);
|
|
if (trigger === 'request') assert.equal(code('bob', 'thread.list'), null);
|
|
else assert.deepEqual(call('human', 'access.reload').ended.map((h) => h.run), ['a-run']);
|
|
assert.equal(claim('a-run').reason, 'access-revoked', trigger);
|
|
assert.equal(code('a', 'message.receive'), 'thread-revoked', trigger);
|
|
}
|
|
});
|
|
|
|
// The control (Dewey's C2 in dewey-0918): the first call after a pending limit stop answers from
|
|
// #limitStop and never reaches #agent, so only the run's next call checks how its claim ended.
|
|
test('row 306 T2: after the sweep, a run the human stopped and a run the limit stop ended still answer not-holder', async (t) => {
|
|
const { b, call, code, edit, claim, refused, B } = setup(t, { stops: [{ stop: true, check: 'token-limit', hit: HIT }] });
|
|
edit(withoutAlice);
|
|
assert.deepEqual(b.accessSweep().map((h) => h.run), ['a-run']);
|
|
// The human's stop takes the human's own thread only: H.
|
|
assert.deepEqual(call('human', 'assistant.stop', { instance: 'assistant' }).stopped.map((s) => s.run), ['h-run']);
|
|
assert.equal(code('h', 'message.receive'), 'not-holder', 'stopped by the human');
|
|
assert.equal(code('h', 'state'), 'not-holder', 'stopped by the human, again');
|
|
// Bob's turn ends at the limit, and the run's next receive ends its claim.
|
|
const { id } = call('bob', 'thread.send', { thread: B, body: 'look' });
|
|
call('b', 'message.receive');
|
|
await call('b', 'usage.begin', { turn: id });
|
|
await call('b', 'usage.end', { turn: id });
|
|
assert.equal(code('b', 'message.receive'), 'not-holder', 'the limit stop');
|
|
assert.equal(claim('b-run').by, 'limit-check');
|
|
assert.equal(code('b', 'message.receive'), 'not-holder', 'the limit-stopped run\'s next call');
|
|
assert.equal(code('b', 'state'), 'not-holder');
|
|
assert.deepEqual(refused('h-run'), ['not-holder', 'not-holder']);
|
|
assert.deepEqual(refused('b-run'), ['not-holder', 'not-holder', 'not-holder']);
|
|
});
|