The business file can name the account claude-code launches use,
`launch.account` = {name, credential}. `name` is an identifier and
`credential` names one login set's environment variables, never a value.
`launch.limits.tokens.daily.<account>` = {limit, since, reset, timeZone}
sets a daily quota for that account. A daily key that isn't the account's
name refuses the business read.
The new plug-in limit-check/daily-quota (interface 2, pure) starts a day at
the first instant at or after `reset` on that local date in `timeZone`. Across
DST that makes a 23- or 25-hour day, and a reset in a skipped hour starts at
the gap's end. It counts the account's known records of all four kinds since
the later of the day start and `since`, and counts unknown turns apart.
The launcher refuses account-credential-missing, account-credential-ambiguous
and account-is-credential (an account name equal to a session credential's
value). Messages name variables, never values. The account rides mayLaunch,
bindLaunch, the usage records, mustStop and the launch record. A pi launch
has no account, records NULL, and no daily quota covers it. `mosaic limits`
and the Console's Settings show one fixed line when a daily row shows:
"Daily quotas count only this stack's own use of the account, not its use
anywhere else."
Bus schema 3h to 3i: 3h reserved usage_records.account as
CHECK (account IS NULL). The new step 3h-3i.sql rebuilds the table in one
transaction, keeping every row's seq and the sqlite_sequence value, and
recreates the three append-only triggers. All of sqlite_master equals 3h's
apart from the account line. A 3h engine refuses a 3i store.
packages/bus/tests/account-migration.test.mjs covers all three, with a
step that fails partway and leaves 3h intact. 3i goes live only through
`stack-release.sh activate ... --migrate` with Jason present (P19 371); the
activate path takes the pre-migrate backup, and the way back is restoring
it (agents/dewey/work/queue-126/MIGRATION.md).
Mos's go mos-0835, six conditions: (1) one transaction, account-migration
test 2 and mutant D29; (2) sqlite_master against 3h, test 1 and mutant D28;
(3) account is never a credential, the launcher's account-is-credential
refusal (launch-account.test.mjs) and the business name pattern
(token-limit-settings.test.mjs); (4) MIGRATION.md, accepted by Mos in
mos-0837; (5) the packet's DESIGN.md "no schema change" line corrected;
(6) the public/ digest below.
The public/ change in bus.js (the daily line under the token-limit table)
carries Mos's decision 123 go mos-0833 on the +/- digest 03e6d9da...ba78,
unchanged at landing.
Owner Dewey, brief docs/plans/2026-10-14_prd-1.2-build.md, "Daily quota for
the credential the stack uses today". Review round 2 rebases the round 4
build onto 1df04f52f (row 167). Reviewer: Filbert approve 29068 on round 1
(manifest d18314f7...8e65); Filbert approve 29080 on
round 2 (manifest f332db76...6fa6).
Seat run on round 2: touched 158/0, singles 1388/0 with 1 esbuild skip,
test-land-work.sh 86/0, 30/30 mutants killed.
Sage gate on 54319f552: GATE_PASSED, 2474+422/0, all 15 packages, the queue
suite and 10 script selftests.
Nothing live.
166 lines
8.7 KiB
JavaScript
166 lines
8.7 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { chmodSync, copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { Store, schemaDigest } from '../src/store.mjs';
|
|
import { Broker } from '../src/broker.mjs';
|
|
|
|
const here = (path) => new URL(path, import.meta.url);
|
|
const AT = '2026-10-15T00:00:00.000Z';
|
|
// [id, business, to_role, delivery ops]
|
|
const MESSAGES = [
|
|
['m-asst-waiting', 'demo', 'assistant', ['routed']],
|
|
['m-asst-delivered', 'demo', 'assistant', ['routed', 'delivered']],
|
|
['m-asst-read', 'demo', 'assistant', ['routed', 'delivered', 'read']],
|
|
['m-chief-waiting', 'demo', 'chief', []],
|
|
['m-human-waiting', 'demo', 'human', ['routed']],
|
|
['m-other-1', 'other', 'assistant', ['routed']],
|
|
['m-other-2', 'other', 'assistant', ['failed']],
|
|
];
|
|
const REPORT = [
|
|
'kept 1 undelivered message(s) for demo/assistant without a thread; if that role is a thread instance, its thread runs never receive them, so the human re-sends them with thread.send',
|
|
'kept 1 undelivered message(s) for demo/chief without a thread; if that role is a thread instance, its thread runs never receive them, so the human re-sends them with thread.send',
|
|
'kept 1 undelivered message(s) for demo/human without a thread; if that role is a thread instance, its thread runs never receive them, so the human re-sends them with thread.send',
|
|
'kept 2 undelivered message(s) for other/assistant without a thread; if that role is a thread instance, its thread runs never receive them, so the human re-sends them with thread.send',
|
|
];
|
|
|
|
// A 3d bus with messages in every delivery state, made by the engine pinned at 3d, plus an untouched copy.
|
|
async function database3d(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-thread-migrate-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
|
const path = join(root, 'bus/bus.sqlite');
|
|
const db = new DatabaseSync(path);
|
|
db.exec(readFileSync(here('./fixtures/schema-3b.sql'), 'utf8'));
|
|
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3b');
|
|
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
|
|
db.close();
|
|
chmodSync(path, 0o600);
|
|
// The engine pinned at 3d: current store.mjs with the 3d→3e step removed and the 3d schema.
|
|
const ddl3d = readFileSync(here('./fixtures/schema-3d.sql'), 'utf8');
|
|
assert.equal(createHash('sha256').update(ddl3d).digest('hex'), 'e4f1de9e16d2fab5e051fd3810189b28cc76a670cfe573b752d9f53bf9ca39b5');
|
|
const moduleRoot = join(root, 'engine-3d');
|
|
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
|
|
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
|
const pinned = source
|
|
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
|
|
.replace("export const SCHEMA_VERSION = '3i';", "export const SCHEMA_VERSION = '3d';")
|
|
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
|
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
|
|
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
|
|
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3d);
|
|
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql'])
|
|
copyFileSync(here('../src/migrations/' + name), join(moduleRoot, 'src/migrations', name));
|
|
const { Store: Store3d } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
|
const old = new Store3d(root, { migrateSchema: true });
|
|
assert.equal(old.get("SELECT value FROM meta WHERE key='schema_version'").value, '3d');
|
|
old.transaction(() => {
|
|
for (const [id, business, to, ops] of MESSAGES) {
|
|
old.run(
|
|
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)',
|
|
id, AT, business, 'human', 'human', to, 'info', 'before threads',
|
|
);
|
|
for (const op of ops)
|
|
old.run('INSERT INTO deliveries(message,at,op,holder_run) VALUES(?,?,?,?)', id, AT, op, op === 'routed' ? null : 'old-run');
|
|
}
|
|
});
|
|
old.close();
|
|
const copy = join(root, 'bus-3d-copy.sqlite');
|
|
copyFileSync(path, copy);
|
|
return { root, path, copy };
|
|
}
|
|
|
|
const rows = (path) => {
|
|
const db = new DatabaseSync(path, { readOnly: true });
|
|
try {
|
|
return {
|
|
messages: db.prepare('SELECT * FROM messages ORDER BY seq').all(),
|
|
deliveries: db.prepare('SELECT * FROM deliveries ORDER BY seq').all(),
|
|
};
|
|
} finally {
|
|
db.close();
|
|
}
|
|
};
|
|
// Row 141: 3f→3g adds messages.to_name, which reads null on a message from before names. Rows from
|
|
// node:sqlite have a null prototype, so the expected rows keep one.
|
|
const named = ({ messages, deliveries }) => ({
|
|
messages: messages.map((m) => Object.assign(Object.create(null), m, { to_name: null })),
|
|
deliveries,
|
|
});
|
|
|
|
test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and leaves messages from before threads as written', async (t) => {
|
|
const { root, path, copy } = await database3d(t);
|
|
const before = rows(path);
|
|
assert.equal(before.messages.length, MESSAGES.length);
|
|
// A 3d bus does not open as 3e until the explicit step runs.
|
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
|
const store = new Store(root, { migrateSchema: true });
|
|
t.after(() => store.close());
|
|
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
|
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
|
|
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
|
|
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
|
|
assert.deepEqual(store.migrationReport, REPORT);
|
|
store.close();
|
|
assert.deepEqual(rows(path), named(before));
|
|
assert.deepEqual(rows(copy), before);
|
|
// Opening again needs no step and reports nothing.
|
|
const again = new Store(root, { migrateSchema: true });
|
|
assert.deepEqual(again.migrationReport, []);
|
|
again.close();
|
|
// A thread run of the instance never receives the old message, and the thread does not count it.
|
|
const current = new Store(root);
|
|
const b = new Broker({
|
|
store: current,
|
|
businesses: {
|
|
demo: {
|
|
id: 'demo',
|
|
human: 'jason',
|
|
arbiters: { technical: 'chief', delivery: 'chief' },
|
|
roles: {
|
|
chief: { definition: 'chief-of-staff', authority: { withinRole: [], crossRole: [] } },
|
|
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
|
},
|
|
definitions: { 'chief-of-staff': {}, assistant: {} },
|
|
projects: { stack: { root: '/nonexistent' } },
|
|
},
|
|
},
|
|
});
|
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const project = b.request(human, { verb: 'project.create', args: { name: 'stack work', repo: 'stack' } }).id;
|
|
const thread = b.request(human, { verb: 'thread.create', args: { project, name: 'after 3e' } }).id;
|
|
const run = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread });
|
|
b.request(run, { verb: 'role.claim', args: {} });
|
|
assert.deepEqual(b.threadsState('demo').map((r) => [r.thread, r.holder, r.pending, r.unread]), [
|
|
[thread, 'assistant-run', 0, 0],
|
|
]);
|
|
assert.deepEqual(b.request(run, { verb: 'message.receive', args: {} }), []);
|
|
current.close();
|
|
assert.deepEqual(rows(path), named(before));
|
|
});
|
|
|
|
test('REQ-THREAD-1: a 3d bus with a changed schema is refused and keeps its version and tables', async (t) => {
|
|
const { root, path } = await database3d(t);
|
|
const db = new DatabaseSync(path);
|
|
db.exec('DROP TRIGGER messages_no_delete');
|
|
db.close();
|
|
assert.throws(() => new Store(root, { migrateSchema: true }), /schema-mismatch/);
|
|
const check = new DatabaseSync(path, { readOnly: true });
|
|
assert.equal(check.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value, '3d');
|
|
assert.equal(check.prepare("SELECT count(*) n FROM sqlite_master WHERE name='threads'").get().n, 0);
|
|
check.close();
|
|
});
|
|
|
|
test('REQ-THREAD-1: the offline command prints the kept-message counts before the ready line', async (t) => {
|
|
const { root } = await database3d(t);
|
|
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
|
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
|
assert.equal(migrated.status, 0, migrated.stderr);
|
|
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3i ready', ''].join('\n'));
|
|
});
|